Skip to main content

agg_gui/widgets/
tab_view.rs

1//! `TabView` — a tabbed container with a clickable tab bar.
2//!
3//! An optional action button can be placed at the right end of the tab bar.
4//! An optional sidebar widget can be shown to the right of the content area
5//! via [`with_sidebar`], separated by a draggable vertical divider.
6
7use std::cell::Cell;
8use std::rc::Rc;
9use std::sync::Arc;
10
11use crate::color::Color;
12use crate::draw_ctx::DrawCtx;
13use crate::event::{Event, EventResult, MouseButton};
14use crate::geometry::{Point, Rect, Size};
15use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
16use crate::text::Font;
17use crate::widget::Widget;
18use crate::widgets::primitives::Spacer;
19
20const ACTION_BTN_W: f64 = 100.0;
21const DIVIDER_W: f64 = 6.0;
22const MIN_SIDEBAR_W: f64 = 160.0;
23
24/// A tabbed panel container.
25///
26/// `children[0]` = active tab content.
27/// `children[1]` = sidebar widget (optional, always stored even when hidden).
28pub struct TabView {
29    bounds: Rect,
30    /// children[0]=active content, children[1]=sidebar (if any)
31    children: Vec<Box<dyn Widget>>,
32    base: WidgetBase,
33    tab_contents: Vec<Box<dyn Widget>>,
34    tab_labels: Vec<String>,
35    active_tab: usize,
36    tab_bar_height: f64,
37    font: Arc<Font>,
38    font_size: f64,
39    hovered_tab: Option<usize>,
40    action_label: Option<String>,
41    action_hovered: bool,
42    on_action: Option<Box<dyn Fn()>>,
43    action_active: bool,
44    // Sidebar state
45    show_sidebar: Option<Rc<Cell<bool>>>,
46    sidebar_w: f64,
47    sidebar_dragging: bool,
48    /// When set, writes `active_tab` on every tab switch AND is re-read
49    /// each layout so external code (state persistence) can drive the
50    /// selection too.  Pattern mirrors ScrollView / ToggleSwitch cells.
51    active_tab_cell: Option<Rc<Cell<usize>>>,
52}
53
54impl TabView {
55    pub fn new(font: Arc<Font>) -> Self {
56        Self {
57            bounds: Rect::default(),
58            children: Vec::new(),
59            base: WidgetBase::new(),
60            tab_contents: Vec::new(),
61            tab_labels: Vec::new(),
62            active_tab: 0,
63            tab_bar_height: 36.0,
64            font,
65            font_size: 13.0,
66            hovered_tab: None,
67            action_label: None,
68            action_hovered: false,
69            on_action: None,
70            action_active: false,
71            show_sidebar: None,
72            sidebar_w: 320.0,
73            sidebar_dragging: false,
74            active_tab_cell: None,
75        }
76    }
77
78    pub fn with_tab_bar_height(mut self, h: f64) -> Self {
79        self.tab_bar_height = h;
80        self
81    }
82    pub fn with_font_size(mut self, size: f64) -> Self {
83        self.font_size = size;
84        self
85    }
86
87    /// Bind the active tab index to a shared cell.  The cell's current
88    /// value seeds the initial selection on the next layout (so a
89    /// persisted choice rehydrates); later user clicks write back
90    /// through the cell.
91    pub fn with_active_tab_cell(mut self, cell: Rc<Cell<usize>>) -> Self {
92        self.active_tab_cell = Some(cell);
93        self
94    }
95
96    pub fn with_margin(mut self, m: Insets) -> Self {
97        self.base.margin = m;
98        self
99    }
100    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
101        self.base.h_anchor = h;
102        self
103    }
104    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
105        self.base.v_anchor = v;
106        self
107    }
108    pub fn with_min_size(mut self, s: Size) -> Self {
109        self.base.min_size = s;
110        self
111    }
112    pub fn with_max_size(mut self, s: Size) -> Self {
113        self.base.max_size = s;
114        self
115    }
116
117    /// Add an action button at the right end of the tab bar.
118    pub fn with_action_button(
119        mut self,
120        label: impl Into<String>,
121        on_click: impl Fn() + 'static,
122    ) -> Self {
123        self.action_label = Some(label.into());
124        self.on_action = Some(Box::new(on_click));
125        self
126    }
127
128    /// Update the visual active (pressed/on) state of the action button.
129    pub fn set_action_active(&mut self, active: bool) {
130        self.action_active = active;
131    }
132
133    /// Add a tab with a label and its content widget.
134    pub fn add_tab(mut self, label: impl Into<String>, content: Box<dyn Widget>) -> Self {
135        let idx = self.tab_labels.len();
136        self.tab_labels.push(label.into());
137        if idx == 0 {
138            // Content always lives at children[0].
139            self.children.insert(0, content);
140            self.tab_contents.push(Box::new(Spacer::new()));
141        } else {
142            self.tab_contents.push(content);
143        }
144        self
145    }
146
147    /// Attach a sidebar widget shown to the right of the content area when
148    /// `show.get()` is true.  The divider between content and sidebar is
149    /// user-draggable.  Call this AFTER all `add_tab` calls.
150    pub fn with_sidebar(mut self, widget: Box<dyn Widget>, show: Rc<Cell<bool>>) -> Self {
151        self.show_sidebar = Some(show);
152        self.children.push(widget); // sidebar always at children[1]
153        self
154    }
155
156    // ── private helpers ───────────────────────────────────────────────────────
157
158    fn sidebar_showing(&self) -> bool {
159        self.show_sidebar.as_ref().map(|s| s.get()).unwrap_or(false)
160    }
161
162    fn content_height(&self) -> f64 {
163        (self.bounds.height - self.tab_bar_height).max(0.0)
164    }
165
166    fn tabs_width(&self) -> f64 {
167        if self.action_label.is_some() {
168            (self.bounds.width - ACTION_BTN_W).max(0.0)
169        } else {
170            self.bounds.width
171        }
172    }
173
174    /// X position of the vertical divider (in content-area local coords).
175    fn divider_x(&self) -> f64 {
176        (self.bounds.width - self.sidebar_w - DIVIDER_W).max(0.0)
177    }
178
179    fn tab_index_at(&self, pos: Point) -> Option<usize> {
180        if pos.y < self.content_height() {
181            return None;
182        }
183        if pos.x >= self.tabs_width() {
184            return None;
185        }
186        let n = self.tab_labels.len().max(1);
187        let tab_w = self.tabs_width() / n as f64;
188        let i = (pos.x / tab_w) as usize;
189        if i < self.tab_labels.len() {
190            Some(i)
191        } else {
192            None
193        }
194    }
195
196    fn action_btn_hit(&self, pos: Point) -> bool {
197        self.action_label.is_some() && pos.y >= self.content_height() && pos.x >= self.tabs_width()
198    }
199
200    fn switch_to(&mut self, new_idx: usize) {
201        if new_idx == self.active_tab || new_idx >= self.tab_labels.len() {
202            return;
203        }
204        // children layout: [content, sidebar?]
205        // Pop sidebar first (index 1), then pop content (index 0).
206        let old_sidebar = if self.children.len() > 1 {
207            self.children.pop()
208        } else {
209            None
210        };
211        if let Some(current) = self.children.pop() {
212            self.tab_contents[self.active_tab] = current;
213        }
214        let placeholder: Box<dyn Widget> = Box::new(Spacer::new());
215        let new_child = std::mem::replace(&mut self.tab_contents[new_idx], placeholder);
216        self.children.push(new_child); // content at index 0
217        if let Some(s) = old_sidebar {
218            self.children.push(s);
219        } // sidebar at index 1
220        self.active_tab = new_idx;
221        if let Some(cell) = &self.active_tab_cell {
222            cell.set(new_idx);
223        }
224    }
225}
226
227impl Widget for TabView {
228    fn type_name(&self) -> &'static str {
229        "TabView"
230    }
231    fn bounds(&self) -> Rect {
232        self.bounds
233    }
234    fn set_bounds(&mut self, b: Rect) {
235        self.bounds = b;
236    }
237    fn children(&self) -> &[Box<dyn Widget>] {
238        &self.children
239    }
240    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
241        &mut self.children
242    }
243
244    fn margin(&self) -> Insets {
245        self.base.margin
246    }
247    fn widget_base(&self) -> Option<&WidgetBase> {
248        Some(&self.base)
249    }
250    fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
251        Some(&mut self.base)
252    }
253    fn h_anchor(&self) -> HAnchor {
254        self.base.h_anchor
255    }
256    fn v_anchor(&self) -> VAnchor {
257        self.base.v_anchor
258    }
259    fn min_size(&self) -> Size {
260        self.base.min_size
261    }
262    fn max_size(&self) -> Size {
263        self.base.max_size
264    }
265
266    fn layout(&mut self, available: Size) -> Size {
267        // Honour a persisted tab selection.  Done here (rather than in
268        // `new`) because `add_tab` runs after `new`, so the child vector
269        // isn't populated until the builder chain is complete.
270        if let Some(cell) = self.active_tab_cell.clone() {
271            let want = cell.get();
272            if want != self.active_tab && want < self.tab_labels.len() {
273                self.switch_to(want);
274            }
275        }
276        let content_h = (available.height - self.tab_bar_height).max(0.0);
277        let showing = self.sidebar_showing();
278        let sw = if showing {
279            self.sidebar_w.clamp(MIN_SIDEBAR_W, available.width * 0.8)
280        } else {
281            0.0
282        };
283        let content_w = if showing {
284            (available.width - sw - DIVIDER_W).max(0.0)
285        } else {
286            available.width
287        };
288
289        // Content at children[0]
290        if let Some(child) = self.children.get_mut(0) {
291            child.layout(Size::new(content_w, content_h));
292            child.set_bounds(Rect::new(0.0, 0.0, content_w, content_h));
293        }
294        // Sidebar at children[1]
295        if let Some(sidebar) = self.children.get_mut(1) {
296            if showing {
297                sidebar.layout(Size::new(sw, content_h));
298                sidebar.set_bounds(Rect::new(content_w + DIVIDER_W, 0.0, sw, content_h));
299            } else {
300                sidebar.layout(Size::new(0.0, 0.0));
301                // Place off-screen so hit_test never fires
302                sidebar.set_bounds(Rect::new(available.width + 1.0, 0.0, 0.0, 0.0));
303            }
304        }
305
306        available
307    }
308
309    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
310        let w = self.bounds.width;
311        let h = self.bounds.height;
312        let tab_h = self.tab_bar_height;
313        let content_h = self.content_height();
314        let tabs_w = self.tabs_width();
315        let n = self.tab_labels.len().max(1);
316        let tab_w = tabs_w / n as f64;
317        let bar_y = content_h;
318
319        let v = ctx.visuals();
320
321        // Tab bar background
322        ctx.set_fill_color(v.panel_fill);
323        ctx.begin_path();
324        ctx.rect(0.0, bar_y, w, tab_h);
325        ctx.fill();
326
327        // Bottom separator line
328        ctx.set_stroke_color(v.separator);
329        ctx.set_line_width(1.0);
330        ctx.begin_path();
331        ctx.move_to(0.0, bar_y);
332        ctx.line_to(w, bar_y);
333        ctx.stroke();
334
335        // Honour the thread-local system-font override so changes in the
336        // System window re-style tab titles live.
337        let font =
338            crate::font_settings::current_system_font().unwrap_or_else(|| Arc::clone(&self.font));
339        ctx.set_font(Arc::clone(&font));
340        ctx.set_font_size(self.font_size);
341
342        // Tab labels
343        for (i, label) in self.tab_labels.iter().enumerate() {
344            let tx = i as f64 * tab_w;
345            let is_active = i == self.active_tab;
346            let is_hovered = self.hovered_tab == Some(i);
347
348            if is_hovered && !is_active {
349                ctx.set_fill_color(v.widget_bg_hovered);
350                ctx.begin_path();
351                ctx.rect(tx, bar_y, tab_w, tab_h);
352                ctx.fill();
353            }
354            if is_active {
355                ctx.set_fill_color(v.accent);
356                ctx.begin_path();
357                ctx.rect(tx, h - 2.5, tab_w, 2.5);
358                ctx.fill();
359            }
360            let label_color = if is_active {
361                v.accent
362            } else if is_hovered {
363                v.text_color
364            } else {
365                v.text_dim
366            };
367            ctx.set_fill_color(label_color);
368            if let Some(m) = ctx.measure_text(label) {
369                let lx = tx + (tab_w - m.width) * 0.5;
370                let ly = bar_y + (tab_h - (m.ascent + m.descent)) * 0.5 + m.descent;
371                ctx.fill_text(label, lx, ly);
372            }
373        }
374
375        // Action button (right side of tab bar)
376        if let Some(ref label) = self.action_label.clone() {
377            let bx = tabs_w;
378            let bg = if self.action_active {
379                Color::rgba(v.accent.r, v.accent.g, v.accent.b, 0.18)
380            } else if self.action_hovered {
381                v.widget_bg_hovered
382            } else {
383                Color::transparent()
384            };
385            if bg.a > 0.0 {
386                ctx.set_fill_color(bg);
387                ctx.begin_path();
388                ctx.rect(bx, bar_y, ACTION_BTN_W, tab_h);
389                ctx.fill();
390            }
391            ctx.set_stroke_color(v.separator);
392            ctx.set_line_width(1.0);
393            ctx.begin_path();
394            ctx.move_to(bx, bar_y + 6.0);
395            ctx.line_to(bx, h - 6.0);
396            ctx.stroke();
397
398            let lc = if self.action_active {
399                v.accent
400            } else {
401                v.text_dim
402            };
403            ctx.set_fill_color(lc);
404            if let Some(m) = ctx.measure_text(label) {
405                let lx = bx + (ACTION_BTN_W - m.width) * 0.5;
406                let ly = bar_y + (tab_h - (m.ascent + m.descent)) * 0.5 + m.descent;
407                ctx.fill_text(label, lx, ly);
408            }
409        }
410
411        // Vertical sidebar divider (painted in content area, under children)
412        if self.sidebar_showing() {
413            let div_x = self.divider_x();
414            let div_color = if self.sidebar_dragging {
415                Color::rgba(v.accent.r, v.accent.g, v.accent.b, 0.55)
416            } else {
417                v.separator
418            };
419            ctx.set_fill_color(div_color);
420            ctx.begin_path();
421            ctx.rect(div_x, 0.0, DIVIDER_W, content_h);
422            ctx.fill();
423
424            // Grip dots
425            if content_h > 30.0 {
426                let grip = if self.sidebar_dragging {
427                    Color::rgba(v.accent.r, v.accent.g, v.accent.b, 0.8)
428                } else {
429                    v.text_dim
430                };
431                ctx.set_fill_color(grip);
432                let cx = div_x + DIVIDER_W * 0.5;
433                let cy = content_h * 0.5;
434                for i in -1i32..=1 {
435                    ctx.begin_path();
436                    ctx.circle(cx, cy + i as f64 * 5.0, 1.5);
437                    ctx.fill();
438                }
439            }
440        }
441    }
442
443    fn hit_test(&self, local_pos: Point) -> bool {
444        // Capture all mouse events during sidebar drag, even if cursor leaves bounds.
445        if self.sidebar_dragging {
446            return true;
447        }
448        local_pos.x >= 0.0
449            && local_pos.x <= self.bounds.width
450            && local_pos.y >= 0.0
451            && local_pos.y <= self.bounds.height
452    }
453
454    fn on_event(&mut self, event: &Event) -> EventResult {
455        match event {
456            Event::MouseMove { pos } => {
457                let was_tab = self.hovered_tab;
458                let was_act = self.action_hovered;
459                self.hovered_tab = self.tab_index_at(*pos);
460                self.action_hovered = self.action_btn_hit(*pos);
461                if self.sidebar_dragging {
462                    // Resize: sidebar_w = window_width - cursor_x - divider
463                    let new_w = self.bounds.width - pos.x;
464                    self.sidebar_w = new_w.clamp(MIN_SIDEBAR_W, self.bounds.width * 0.8);
465                    crate::animation::request_draw();
466                    return EventResult::Consumed;
467                }
468                if was_tab != self.hovered_tab || was_act != self.action_hovered {
469                    crate::animation::request_draw();
470                }
471                EventResult::Ignored
472            }
473            Event::MouseDown {
474                pos,
475                button: MouseButton::Left,
476                ..
477            } => {
478                if self.action_btn_hit(*pos) {
479                    self.action_active = !self.action_active;
480                    if let Some(ref cb) = self.on_action {
481                        cb();
482                    }
483                    crate::animation::request_draw();
484                    return EventResult::Consumed;
485                }
486                // Divider drag — only in the content area (y < content_h)
487                if self.sidebar_showing() && pos.y < self.content_height() {
488                    let div_x = self.divider_x();
489                    if pos.x >= div_x - 2.0 && pos.x <= div_x + DIVIDER_W + 2.0 {
490                        self.sidebar_dragging = true;
491                        crate::animation::request_draw();
492                        return EventResult::Consumed;
493                    }
494                }
495                if let Some(i) = self.tab_index_at(*pos) {
496                    self.switch_to(i);
497                    crate::animation::request_draw();
498                    return EventResult::Consumed;
499                }
500                EventResult::Ignored
501            }
502            Event::MouseUp {
503                button: MouseButton::Left,
504                ..
505            } => {
506                if self.sidebar_dragging {
507                    self.sidebar_dragging = false;
508                    crate::animation::request_draw();
509                    return EventResult::Consumed;
510                }
511                EventResult::Ignored
512            }
513            _ => EventResult::Ignored,
514        }
515    }
516}