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::event::{Event, EventResult, MouseButton};
13use crate::geometry::{Point, Rect, Size};
14use crate::draw_ctx::DrawCtx;
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 { self.tab_bar_height = h; self }
79    pub fn with_font_size(mut self, size: f64) -> Self { self.font_size = size; self }
80
81    /// Bind the active tab index to a shared cell.  The cell's current
82    /// value seeds the initial selection on the next layout (so a
83    /// persisted choice rehydrates); later user clicks write back
84    /// through the cell.
85    pub fn with_active_tab_cell(mut self, cell: Rc<Cell<usize>>) -> Self {
86        self.active_tab_cell = Some(cell);
87        self
88    }
89
90    pub fn with_margin(mut self, m: Insets)    -> Self { self.base.margin   = m; self }
91    pub fn with_h_anchor(mut self, h: HAnchor) -> Self { self.base.h_anchor = h; self }
92    pub fn with_v_anchor(mut self, v: VAnchor) -> Self { self.base.v_anchor = v; self }
93    pub fn with_min_size(mut self, s: Size)    -> Self { self.base.min_size = s; self }
94    pub fn with_max_size(mut self, s: Size)    -> Self { self.base.max_size = s; self }
95
96    /// Add an action button at the right end of the tab bar.
97    pub fn with_action_button(
98        mut self,
99        label: impl Into<String>,
100        on_click: impl Fn() + 'static,
101    ) -> Self {
102        self.action_label = Some(label.into());
103        self.on_action = Some(Box::new(on_click));
104        self
105    }
106
107    /// Update the visual active (pressed/on) state of the action button.
108    pub fn set_action_active(&mut self, active: bool) { self.action_active = active; }
109
110    /// Add a tab with a label and its content widget.
111    pub fn add_tab(mut self, label: impl Into<String>, content: Box<dyn Widget>) -> Self {
112        let idx = self.tab_labels.len();
113        self.tab_labels.push(label.into());
114        if idx == 0 {
115            // Content always lives at children[0].
116            self.children.insert(0, content);
117            self.tab_contents.push(Box::new(Spacer::new()));
118        } else {
119            self.tab_contents.push(content);
120        }
121        self
122    }
123
124    /// Attach a sidebar widget shown to the right of the content area when
125    /// `show.get()` is true.  The divider between content and sidebar is
126    /// user-draggable.  Call this AFTER all `add_tab` calls.
127    pub fn with_sidebar(
128        mut self,
129        widget: Box<dyn Widget>,
130        show: Rc<Cell<bool>>,
131    ) -> Self {
132        self.show_sidebar = Some(show);
133        self.children.push(widget); // sidebar always at children[1]
134        self
135    }
136
137    // ── private helpers ───────────────────────────────────────────────────────
138
139    fn sidebar_showing(&self) -> bool {
140        self.show_sidebar.as_ref().map(|s| s.get()).unwrap_or(false)
141    }
142
143    fn content_height(&self) -> f64 {
144        (self.bounds.height - self.tab_bar_height).max(0.0)
145    }
146
147    fn tabs_width(&self) -> f64 {
148        if self.action_label.is_some() {
149            (self.bounds.width - ACTION_BTN_W).max(0.0)
150        } else {
151            self.bounds.width
152        }
153    }
154
155    /// X position of the vertical divider (in content-area local coords).
156    fn divider_x(&self) -> f64 {
157        (self.bounds.width - self.sidebar_w - DIVIDER_W).max(0.0)
158    }
159
160    fn tab_index_at(&self, pos: Point) -> Option<usize> {
161        if pos.y < self.content_height() { return None; }
162        if pos.x >= self.tabs_width() { return None; }
163        let n = self.tab_labels.len().max(1);
164        let tab_w = self.tabs_width() / n as f64;
165        let i = (pos.x / tab_w) as usize;
166        if i < self.tab_labels.len() { Some(i) } else { None }
167    }
168
169    fn action_btn_hit(&self, pos: Point) -> bool {
170        self.action_label.is_some()
171            && pos.y >= self.content_height()
172            && pos.x >= self.tabs_width()
173    }
174
175    fn switch_to(&mut self, new_idx: usize) {
176        if new_idx == self.active_tab || new_idx >= self.tab_labels.len() { return; }
177        // children layout: [content, sidebar?]
178        // Pop sidebar first (index 1), then pop content (index 0).
179        let old_sidebar = if self.children.len() > 1 { self.children.pop() } else { None };
180        if let Some(current) = self.children.pop() {
181            self.tab_contents[self.active_tab] = current;
182        }
183        let placeholder: Box<dyn Widget> = Box::new(Spacer::new());
184        let new_child = std::mem::replace(&mut self.tab_contents[new_idx], placeholder);
185        self.children.push(new_child);                    // content at index 0
186        if let Some(s) = old_sidebar { self.children.push(s); } // sidebar at index 1
187        self.active_tab = new_idx;
188        if let Some(cell) = &self.active_tab_cell {
189            cell.set(new_idx);
190        }
191    }
192}
193
194impl Widget for TabView {
195    fn type_name(&self) -> &'static str { "TabView" }
196    fn bounds(&self) -> Rect { self.bounds }
197    fn set_bounds(&mut self, b: Rect) { self.bounds = b; }
198    fn children(&self) -> &[Box<dyn Widget>] { &self.children }
199    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> { &mut self.children }
200
201    fn margin(&self)   -> Insets  { self.base.margin }
202    fn h_anchor(&self) -> HAnchor { self.base.h_anchor }
203    fn v_anchor(&self) -> VAnchor { self.base.v_anchor }
204    fn min_size(&self) -> Size    { self.base.min_size }
205    fn max_size(&self) -> Size    { self.base.max_size }
206
207    fn layout(&mut self, available: Size) -> Size {
208        // Honour a persisted tab selection.  Done here (rather than in
209        // `new`) because `add_tab` runs after `new`, so the child vector
210        // isn't populated until the builder chain is complete.
211        if let Some(cell) = self.active_tab_cell.clone() {
212            let want = cell.get();
213            if want != self.active_tab && want < self.tab_labels.len() {
214                self.switch_to(want);
215            }
216        }
217        let content_h = (available.height - self.tab_bar_height).max(0.0);
218        let showing = self.sidebar_showing();
219        let sw = if showing {
220            self.sidebar_w.clamp(MIN_SIDEBAR_W, available.width * 0.8)
221        } else {
222            0.0
223        };
224        let content_w = if showing {
225            (available.width - sw - DIVIDER_W).max(0.0)
226        } else {
227            available.width
228        };
229
230        // Content at children[0]
231        if let Some(child) = self.children.get_mut(0) {
232            child.layout(Size::new(content_w, content_h));
233            child.set_bounds(Rect::new(0.0, 0.0, content_w, content_h));
234        }
235        // Sidebar at children[1]
236        if let Some(sidebar) = self.children.get_mut(1) {
237            if showing {
238                sidebar.layout(Size::new(sw, content_h));
239                sidebar.set_bounds(Rect::new(content_w + DIVIDER_W, 0.0, sw, content_h));
240            } else {
241                sidebar.layout(Size::new(0.0, 0.0));
242                // Place off-screen so hit_test never fires
243                sidebar.set_bounds(Rect::new(available.width + 1.0, 0.0, 0.0, 0.0));
244            }
245        }
246
247        available
248    }
249
250    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
251        let w = self.bounds.width;
252        let h = self.bounds.height;
253        let tab_h = self.tab_bar_height;
254        let content_h = self.content_height();
255        let tabs_w = self.tabs_width();
256        let n = self.tab_labels.len().max(1);
257        let tab_w = tabs_w / n as f64;
258        let bar_y = content_h;
259
260        let v = ctx.visuals();
261
262        // Tab bar background
263        ctx.set_fill_color(v.panel_fill);
264        ctx.begin_path();
265        ctx.rect(0.0, bar_y, w, tab_h);
266        ctx.fill();
267
268        // Bottom separator line
269        ctx.set_stroke_color(v.separator);
270        ctx.set_line_width(1.0);
271        ctx.begin_path();
272        ctx.move_to(0.0, bar_y);
273        ctx.line_to(w, bar_y);
274        ctx.stroke();
275
276        // Honour the thread-local system-font override so changes in the
277        // System window re-style tab titles live.
278        let font = crate::font_settings::current_system_font()
279            .unwrap_or_else(|| Arc::clone(&self.font));
280        ctx.set_font(Arc::clone(&font));
281        ctx.set_font_size(self.font_size);
282
283        // Tab labels
284        for (i, label) in self.tab_labels.iter().enumerate() {
285            let tx = i as f64 * tab_w;
286            let is_active  = i == self.active_tab;
287            let is_hovered = self.hovered_tab == Some(i);
288
289            if is_hovered && !is_active {
290                ctx.set_fill_color(v.widget_bg_hovered);
291                ctx.begin_path();
292                ctx.rect(tx, bar_y, tab_w, tab_h);
293                ctx.fill();
294            }
295            if is_active {
296                ctx.set_fill_color(v.accent);
297                ctx.begin_path();
298                ctx.rect(tx, h - 2.5, tab_w, 2.5);
299                ctx.fill();
300            }
301            let label_color = if is_active {
302                v.accent
303            } else if is_hovered {
304                v.text_color
305            } else {
306                v.text_dim
307            };
308            ctx.set_fill_color(label_color);
309            if let Some(m) = ctx.measure_text(label) {
310                let lx = tx + (tab_w - m.width) * 0.5;
311                let ly = bar_y + (tab_h - (m.ascent + m.descent)) * 0.5 + m.descent;
312                ctx.fill_text(label, lx, ly);
313            }
314        }
315
316        // Action button (right side of tab bar)
317        if let Some(ref label) = self.action_label.clone() {
318            let bx = tabs_w;
319            let bg = if self.action_active {
320                Color::rgba(v.accent.r, v.accent.g, v.accent.b, 0.18)
321            } else if self.action_hovered {
322                v.widget_bg_hovered
323            } else {
324                Color::transparent()
325            };
326            if bg.a > 0.0 {
327                ctx.set_fill_color(bg);
328                ctx.begin_path();
329                ctx.rect(bx, bar_y, ACTION_BTN_W, tab_h);
330                ctx.fill();
331            }
332            ctx.set_stroke_color(v.separator);
333            ctx.set_line_width(1.0);
334            ctx.begin_path();
335            ctx.move_to(bx, bar_y + 6.0);
336            ctx.line_to(bx, h - 6.0);
337            ctx.stroke();
338
339            let lc = if self.action_active { v.accent } else { v.text_dim };
340            ctx.set_fill_color(lc);
341            if let Some(m) = ctx.measure_text(label) {
342                let lx = bx + (ACTION_BTN_W - m.width) * 0.5;
343                let ly = bar_y + (tab_h - (m.ascent + m.descent)) * 0.5 + m.descent;
344                ctx.fill_text(label, lx, ly);
345            }
346        }
347
348        // Vertical sidebar divider (painted in content area, under children)
349        if self.sidebar_showing() {
350            let div_x = self.divider_x();
351            let div_color = if self.sidebar_dragging {
352                Color::rgba(v.accent.r, v.accent.g, v.accent.b, 0.55)
353            } else {
354                v.separator
355            };
356            ctx.set_fill_color(div_color);
357            ctx.begin_path();
358            ctx.rect(div_x, 0.0, DIVIDER_W, content_h);
359            ctx.fill();
360
361            // Grip dots
362            if content_h > 30.0 {
363                let grip = if self.sidebar_dragging {
364                    Color::rgba(v.accent.r, v.accent.g, v.accent.b, 0.8)
365                } else {
366                    v.text_dim
367                };
368                ctx.set_fill_color(grip);
369                let cx = div_x + DIVIDER_W * 0.5;
370                let cy = content_h * 0.5;
371                for i in -1i32..=1 {
372                    ctx.begin_path();
373                    ctx.circle(cx, cy + i as f64 * 5.0, 1.5);
374                    ctx.fill();
375                }
376            }
377        }
378    }
379
380    fn hit_test(&self, local_pos: Point) -> bool {
381        // Capture all mouse events during sidebar drag, even if cursor leaves bounds.
382        if self.sidebar_dragging { return true; }
383        local_pos.x >= 0.0 && local_pos.x <= self.bounds.width
384            && local_pos.y >= 0.0 && local_pos.y <= self.bounds.height
385    }
386
387    fn on_event(&mut self, event: &Event) -> EventResult {
388        match event {
389            Event::MouseMove { pos } => {
390                let was_tab   = self.hovered_tab;
391                let was_act   = self.action_hovered;
392                self.hovered_tab    = self.tab_index_at(*pos);
393                self.action_hovered = self.action_btn_hit(*pos);
394                if self.sidebar_dragging {
395                    // Resize: sidebar_w = window_width - cursor_x - divider
396                    let new_w = self.bounds.width - pos.x;
397                    self.sidebar_w = new_w.clamp(MIN_SIDEBAR_W, self.bounds.width * 0.8);
398                    crate::animation::request_tick();
399                    return EventResult::Consumed;
400                }
401                if was_tab != self.hovered_tab || was_act != self.action_hovered {
402                    crate::animation::request_tick();
403                }
404                EventResult::Ignored
405            }
406            Event::MouseDown { pos, button: MouseButton::Left, .. } => {
407                if self.action_btn_hit(*pos) {
408                    self.action_active = !self.action_active;
409                    if let Some(ref cb) = self.on_action { cb(); }
410                    crate::animation::request_tick();
411                    return EventResult::Consumed;
412                }
413                // Divider drag — only in the content area (y < content_h)
414                if self.sidebar_showing() && pos.y < self.content_height() {
415                    let div_x = self.divider_x();
416                    if pos.x >= div_x - 2.0 && pos.x <= div_x + DIVIDER_W + 2.0 {
417                        self.sidebar_dragging = true;
418                        crate::animation::request_tick();
419                        return EventResult::Consumed;
420                    }
421                }
422                if let Some(i) = self.tab_index_at(*pos) {
423                    self.switch_to(i);
424                    crate::animation::request_tick();
425                    return EventResult::Consumed;
426                }
427                EventResult::Ignored
428            }
429            Event::MouseUp { button: MouseButton::Left, .. } => {
430                if self.sidebar_dragging {
431                    self.sidebar_dragging = false;
432                    crate::animation::request_tick();
433                    return EventResult::Consumed;
434                }
435                EventResult::Ignored
436            }
437            _ => EventResult::Ignored,
438        }
439    }
440}