Commit 79d885e
5
files
+255
added
-12
deleted
@@ -8,10 +8,10 @@ use std::error::Error;
8
8
use libluna_core::style::weight;
9
9
use libluna_core::{Rect, Size, Theme};
10
10
use libluna_render::{Scene, SoftwareRenderer, TextEngine};
11
-use libluna_widgets::{Banner, BannerKind, Button, PaintCtx, Spinner, Tooltip};
11
+use libluna_widgets::{Banner, BannerKind, Button, PaintCtx, Spinner, TabBar, Tooltip};
12
12
13
13
const W: u32 = 520;
14
-const H: u32 = 300;
14
+const H: u32 = 364;
15
15
16
16
fn main() -> Result<(), Box<dyn Error>> {
17
17
let theme = Theme::dark();
@@ -62,6 +62,23 @@ fn main() -> Result<(), Box<dyn Error>> {
62
62
theme.colors.dim_text,
63
63
);
64
64
65
+ // Pasek zakładek z przewijaniem (overflow) — 6 zakładek w wąskim pasku.
66
+ let tabs = TabBar::new([
67
+ "General",
68
+ "Advanced",
69
+ "Appearance",
70
+ "Network",
71
+ "System",
72
+ "About",
73
+ ]);
74
+ let tabs_rect = Rect::from_xywh(
75
+ 16.0,
76
+ size.height - 52.0,
77
+ size.width - 32.0,
78
+ tabs.height(&theme),
79
+ );
80
+ tabs.paint(&mut paint, tabs_rect);
81
+
65
82
let mut renderer = SoftwareRenderer::new(W, H);
66
83
renderer.draw(&scene, 1.0, theme.colors.window_bg);
67
84
let png = renderer.encode_png().ok_or("failed to encode PNG")?;
@@ -36,6 +36,22 @@ pub fn chevron_right(paint: &mut PaintCtx, color: Color, center: Point, size: f6
36
36
);
37
37
}
38
38
39
+/// Strzałka w lewo (chevron); środek w `center`, rozpiętość `size`, grubość `width`.
40
+pub fn chevron_left(paint: &mut PaintCtx, color: Color, center: Point, size: f64, width: f64) {
41
+ paint.scene.stroke_line(
42
+ Point::new(center.x + size * 0.45, center.y - size),
43
+ Point::new(center.x - size * 0.45, center.y),
44
+ width,
45
+ color,
46
+ );
47
+ paint.scene.stroke_line(
48
+ Point::new(center.x - size * 0.45, center.y),
49
+ Point::new(center.x + size * 0.45, center.y + size),
50
+ width,
51
+ color,
52
+ );
53
+}
54
+
39
55
/// Lupka (wyszukiwanie); środek w `center`, bok `size`.
40
56
pub fn search(paint: &mut PaintCtx, color: Color, center: Point, size: f64) {
41
57
let r = size * 0.34;
@@ -100,7 +100,7 @@ pub use spinner::Spinner;
100
100
pub use status_page::{StatusIcon, StatusPage, StatusRects};
101
101
pub use stepper::{Stepper, StepperSide};
102
102
pub use switch::Switch;
103
-pub use tab_bar::{Tab, TabBar};
103
+pub use tab_bar::{ScrollDir, Tab, TabBar};
104
104
pub use tab_view::TabView;
105
105
pub use toast::Toast;
106
106
pub use tooltip::Tooltip;
@@ -12,6 +12,16 @@ use libluna_render::TextEngine;
12
12
use crate::ctx::PaintCtx;
13
13
use crate::icon;
14
14
15
+/// Szerokość przycisku przewijania (chevron) na krawędzi paska.
16
+const SCROLL_W: f64 = 22.0;
17
+
18
+/// Kierunek przewijania paska zakładek.
19
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20
+pub enum ScrollDir {
21
+ Left,
22
+ Right,
23
+}
24
+
15
25
/// Pojedyncza zakładka.
16
26
#[derive(Debug, Clone, PartialEq)]
17
27
pub struct Tab {
@@ -42,6 +52,8 @@ pub struct TabBar {
42
52
selected: usize,
43
53
hovered: Option<usize>,
44
54
expand: bool,
55
+ /// Przesunięcie poziome (gdy zakładki się nie mieszczą).
56
+ scroll: f64,
45
57
}
46
58
47
59
impl TabBar {
@@ -56,6 +68,7 @@ impl TabBar {
56
68
selected: 0,
57
69
hovered: None,
58
70
expand: true,
71
+ scroll: 0.0,
59
72
}
60
73
}
61
74
@@ -66,6 +79,7 @@ impl TabBar {
66
79
selected: 0,
67
80
hovered: None,
68
81
expand: true,
82
+ scroll: 0.0,
69
83
}
70
84
}
71
85
@@ -111,17 +125,16 @@ impl TabBar {
111
125
theme.metrics.tabs.height
112
126
}
113
127
114
- /// Prostokąty kolejnych zakładek (wyrównane do środka paska).
115
- pub fn segments(&self, rect: Rect, text: &TextEngine, theme: &Theme) -> Vec<Rect> {
128
+ /// Szerokości zakładek (bez odstępów) dla zadanego prostokąta.
129
+ fn widths(&self, rect: Rect, text: &TextEngine, theme: &Theme) -> Vec<f64> {
116
130
let m = theme.metrics.tabs;
117
131
let n = self.tabs.len();
118
- if n == 0 || rect.width() <= 0.0 {
132
+ if n == 0 {
119
133
return Vec::new();
120
134
}
121
- let gaps = m.gap * (n as f64 - 1.0);
122
- let avail = (rect.width() - gaps).max(0.0);
123
-
124
- let widths: Vec<f64> = if self.expand {
135
+ if self.expand {
136
+ let gaps = m.gap * (n as f64 - 1.0);
137
+ let avail = (rect.width() - gaps).max(0.0);
125
138
vec![(avail / n as f64).clamp(m.min_width, m.max_width); n]
126
139
} else {
127
140
self.tabs
@@ -137,10 +150,124 @@ impl TabBar {
137
150
w.clamp(m.min_width, m.max_width)
138
151
})
139
152
.collect()
153
+ }
154
+ }
155
+
156
+ /// Łączna szerokość zawartości (zakładki + odstępy).
157
+ pub fn content_width(&self, rect: Rect, text: &TextEngine, theme: &Theme) -> f64 {
158
+ let m = theme.metrics.tabs;
159
+ let n = self.tabs.len();
160
+ if n == 0 {
161
+ return 0.0;
162
+ }
163
+ self.widths(rect, text, theme).iter().sum::<f64>() + m.gap * (n as f64 - 1.0)
164
+ }
165
+
166
+ /// Maksymalne przewinięcie (0, gdy wszystko się mieści).
167
+ pub fn max_scroll(&self, rect: Rect, text: &TextEngine, theme: &Theme) -> f64 {
168
+ let content = self.content_width(rect, text, theme);
169
+ // Gdy się nie mieści, po bokach są przyciski przewijania — one zawężają widok.
170
+ let inset = if content > rect.width() {
171
+ SCROLL_W
172
+ } else {
173
+ 0.0
140
174
};
175
+ let visible = (rect.width() - 2.0 * inset).max(0.0);
176
+ (content - visible).max(0.0)
177
+ }
178
+
179
+ /// Czy zakładki nie mieszczą się w pasku (trzeba przewijać).
180
+ pub fn is_overflowing(&self, rect: Rect, text: &TextEngine, theme: &Theme) -> bool {
181
+ self.max_scroll(rect, text, theme) > 0.5
182
+ }
183
+
184
+ pub fn scroll(&self) -> f64 {
185
+ self.scroll
186
+ }
187
+
188
+ /// Ustawia przewinięcie (przycięte do zakresu).
189
+ pub fn set_scroll(&mut self, scroll: f64, rect: Rect, text: &TextEngine, theme: &Theme) {
190
+ self.scroll = scroll.clamp(0.0, self.max_scroll(rect, text, theme));
191
+ }
192
+
193
+ /// Przewija o `dx`; zwraca `true`, jeśli zawartość da się przewinąć.
194
+ pub fn on_scroll(&mut self, dx: f64, rect: Rect, text: &TextEngine, theme: &Theme) -> bool {
195
+ if !self.is_overflowing(rect, text, theme) {
196
+ return false;
197
+ }
198
+ self.set_scroll(self.scroll + dx, rect, text, theme);
199
+ true
200
+ }
201
+
202
+ /// Przewija tak, aby zakładka `index` była w pełni widoczna.
203
+ pub fn scroll_into_view(&mut self, index: usize, rect: Rect, text: &TextEngine, theme: &Theme) {
204
+ let segs = self.segments(rect, text, theme);
205
+ let Some(seg) = segs.get(index).copied() else {
206
+ return;
207
+ };
208
+ let inset = if self.is_overflowing(rect, text, theme) {
209
+ SCROLL_W
210
+ } else {
211
+ 0.0
212
+ };
213
+ let left = rect.x() + inset;
214
+ let right = rect.right() - inset;
215
+ if seg.x() < left {
216
+ self.set_scroll(self.scroll - (left - seg.x()), rect, text, theme);
217
+ } else if seg.right() > right {
218
+ self.set_scroll(self.scroll + (seg.right() - right), rect, text, theme);
219
+ }
220
+ }
221
+
222
+ /// Prostokąt przycisku przewijania przy danej krawędzi.
223
+ fn scroll_button(&self, rect: Rect, dir: ScrollDir) -> Rect {
224
+ let x = match dir {
225
+ ScrollDir::Left => rect.x(),
226
+ ScrollDir::Right => rect.right() - SCROLL_W,
227
+ };
228
+ Rect::from_xywh(x, rect.y(), SCROLL_W, rect.height())
229
+ }
230
+
231
+ /// Czy punkt trafia w przycisk przewijania (tylko gdy jest co przewijać w tę stronę).
232
+ pub fn scroll_dir_at(
233
+ &self,
234
+ rect: Rect,
235
+ text: &TextEngine,
236
+ theme: &Theme,
237
+ p: Point,
238
+ ) -> Option<ScrollDir> {
239
+ if !self.is_overflowing(rect, text, theme) {
240
+ return None;
241
+ }
242
+ let left = self.scroll_button(rect, ScrollDir::Left);
243
+ let right = self.scroll_button(rect, ScrollDir::Right);
244
+ if self.scroll > 0.0 && left.contains(p) {
245
+ Some(ScrollDir::Left)
246
+ } else if self.scroll < self.max_scroll(rect, text, theme) - 0.5 && right.contains(p) {
247
+ Some(ScrollDir::Right)
248
+ } else {
249
+ None
250
+ }
251
+ }
141
252
253
+ /// Prostokąty kolejnych zakładek (z uwzględnieniem przewijania).
254
+ pub fn segments(&self, rect: Rect, text: &TextEngine, theme: &Theme) -> Vec<Rect> {
255
+ let m = theme.metrics.tabs;
256
+ let n = self.tabs.len();
257
+ if n == 0 || rect.width() <= 0.0 {
258
+ return Vec::new();
259
+ }
260
+ let widths = self.widths(rect, text, theme);
261
+ let gaps = m.gap * (n as f64 - 1.0);
142
262
let total: f64 = widths.iter().sum::<f64>() + gaps;
143
- let mut x = rect.x() + ((rect.width() - total) / 2.0).max(0.0);
263
+ let overflowing = total > rect.width();
264
+ let inset = if overflowing { SCROLL_W } else { 0.0 };
265
+ let avail = (rect.width() - 2.0 * inset).max(0.0);
266
+ let mut x = if overflowing {
267
+ rect.x() + inset - self.scroll
268
+ } else {
269
+ rect.x() + inset + ((avail - total) / 2.0).max(0.0)
270
+ };
144
271
let mut out = Vec::with_capacity(n);
145
272
for w in widths {
146
273
out.push(Rect::from_xywh(x, rect.y(), w, rect.height()));
@@ -189,13 +316,14 @@ impl TabBar {
189
316
match self.index_at(rect, text, theme, p) {
190
317
Some(i) if i != self.selected => {
191
318
self.selected = i;
319
+ self.scroll_into_view(i, rect, text, theme);
192
320
true
193
321
}
194
322
_ => false,
195
323
}
196
324
}
197
325
198
- /// Rysuje pasek zakładek w prostokącie `rect`.
326
+ /// Rysuje pasek zakładek w prostokącie `rect` (z przycinaniem i przewijaniem).
199
327
pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
200
328
let theme = *ctx.theme;
201
329
let colors = theme.colors;
@@ -203,7 +331,9 @@ impl TabBar {
203
331
let size = theme.typography.body.size;
204
332
let m = theme.metrics.tabs;
205
333
let text = ctx.text;
334
+ let overflow = self.is_overflowing(rect, text, &theme);
206
335
336
+ ctx.scene.push_clip(rect);
207
337
for (i, seg) in self.segments(rect, text, &theme).iter().enumerate() {
208
338
let tab = &self.tabs[i];
209
339
let selected = i == self.selected;
@@ -243,6 +373,31 @@ impl TabBar {
243
373
);
244
374
}
245
375
}
376
+ ctx.scene.pop_clip();
377
+
378
+ // Przyciski przewijania na krawędziach, gdy zakładki się nie mieszczą.
379
+ if overflow {
380
+ let max = self.max_scroll(rect, text, &theme);
381
+ if self.scroll > 0.0 {
382
+ self.paint_scroll_button(ctx, rect, ScrollDir::Left);
383
+ }
384
+ if self.scroll < max - 0.5 {
385
+ self.paint_scroll_button(ctx, rect, ScrollDir::Right);
386
+ }
387
+ }
388
+ }
389
+
390
+ fn paint_scroll_button(&self, ctx: &mut PaintCtx, rect: Rect, dir: ScrollDir) {
391
+ let theme = *ctx.theme;
392
+ let colors = theme.colors;
393
+ let r = self.scroll_button(rect, dir);
394
+ // Tło zasłaniające przewinięte zakładki + chevron.
395
+ ctx.scene.fill_rect(r, colors.card_bg);
396
+ let center = r.center();
397
+ match dir {
398
+ ScrollDir::Left => icon::chevron_left(ctx, colors.dim_text, center, 4.0, 2.0),
399
+ ScrollDir::Right => icon::chevron_right(ctx, colors.dim_text, center, 4.0, 2.0),
400
+ }
246
401
}
247
402
}
248
403
@@ -336,4 +491,59 @@ mod tests {
336
491
assert!(s.width() <= m.max_width + 1e-9);
337
492
}
338
493
}
494
+
495
+ #[test]
496
+ fn overflow_scrolls_and_clamps() {
497
+ let text = TextEngine::system();
498
+ let theme = theme();
499
+ let rect = Rect::from_xywh(0.0, 0.0, 200.0, 34.0);
500
+ let mut bar = TabBar::new(["A", "B", "C", "D", "E", "F"]);
501
+ assert!(bar.is_overflowing(rect, &text, &theme));
502
+ let max = bar.max_scroll(rect, &text, &theme);
503
+ assert!(max > 0.0);
504
+
505
+ assert!(bar.on_scroll(30.0, rect, &text, &theme));
506
+ assert!((bar.scroll() - 30.0).abs() < 1e-9);
507
+ bar.on_scroll(9999.0, rect, &text, &theme);
508
+ assert!((bar.scroll() - max).abs() < 1e-9);
509
+ bar.on_scroll(-9999.0, rect, &text, &theme);
510
+ assert_eq!(bar.scroll(), 0.0);
511
+ }
512
+
513
+ #[test]
514
+ fn scroll_into_view_reveals_last_tab() {
515
+ let text = TextEngine::system();
516
+ let theme = theme();
517
+ let rect = Rect::from_xywh(0.0, 0.0, 200.0, 34.0);
518
+ let mut bar = TabBar::new(["A", "B", "C", "D", "E", "F"]);
519
+ bar.scroll_into_view(5, rect, &text, &theme);
520
+ let segs = bar.segments(rect, &text, &theme);
521
+ assert!(segs[5].right() <= rect.right() - SCROLL_W + 1e-6);
522
+ assert!(segs[5].x() >= rect.x() + SCROLL_W - 1e-6);
523
+ }
524
+
525
+ #[test]
526
+ fn scroll_buttons_hit_only_when_scrollable() {
527
+ let text = TextEngine::system();
528
+ let theme = theme();
529
+ let narrow = Rect::from_xywh(0.0, 0.0, 200.0, 34.0);
530
+ let bar = TabBar::new(["A", "B", "C", "D", "E", "F"]);
531
+ let left = bar.scroll_button(narrow, ScrollDir::Left);
532
+ let right = bar.scroll_button(narrow, ScrollDir::Right);
533
+ // Na starcie: lewy nieaktywny, prawy aktywny.
534
+ assert_eq!(
535
+ bar.scroll_dir_at(narrow, &text, &theme, left.center()),
536
+ None
537
+ );
538
+ assert_eq!(
539
+ bar.scroll_dir_at(narrow, &text, &theme, right.center()),
540
+ Some(ScrollDir::Right)
541
+ );
542
+ // Gdy wszystko się mieści — brak przycisków.
543
+ let fits = TabBar::new(["A"]);
544
+ assert_eq!(
545
+ fits.scroll_dir_at(rect(), &text, &theme, rect().center()),
546
+ None
547
+ );
548
+ }
339
549
}
340
550
Binary files a/docs/images/widgets.png and b/docs/images/widgets.png differ