← luna

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