Skip to main content

egui_charts/ui_kit/sidebar_layout/
mod.rs

1//! Sidebar + content two-column layout using `egui_extras::StripBuilder`.
2//!
3//! Use [`SidebarLayout`] for a fixed-width navigation column beside flexible
4//! content. Sizes use egui logical points.
5//!
6//! # Example
7//! ```
8//! # use egui_charts::ui_kit::sidebar_layout::SidebarLayout;
9//! # egui::__run_test_ui(|ui| {
10//! SidebarLayout::new(160.0)
11//!     .show(ui,
12//!         |ui| { /* sidebar navigation */ },
13//!         |ui| { /* scrollable content */ },
14//!     );
15//! # });
16//! ```
17
18use crate::localization as l10n;
19use crate::theme::ThemeExt;
20use crate::tokens::DESIGN_TOKENS;
21use egui::{Color32, Pos2, Rect, Role, Sense, Ui, Vec2, WidgetInfo};
22use egui_extras::{Size, StripBuilder};
23
24/// Two-column layout: fixed-width sidebar + flexible content area.
25///
26/// Uses `egui_extras::StripBuilder` for clean proportional column sizing.
27pub struct SidebarLayout {
28    sidebar_width: f32,
29}
30
31impl SidebarLayout {
32    /// Create a layout with a sidebar width in egui logical points.
33    pub fn new(sidebar_width: f32) -> Self {
34        Self { sidebar_width }
35    }
36
37    /// Show the two-column layout.
38    ///
39    /// The `sidebar` closure renders the left navigation.
40    /// The `content` closure renders the right content area.
41    pub fn show(self, ui: &mut Ui, sidebar: impl FnOnce(&mut Ui), content: impl FnOnce(&mut Ui)) {
42        StripBuilder::new(ui)
43            .size(Size::exact(self.sidebar_width))
44            .size(Size::remainder())
45            .horizontal(|mut strip| {
46                // Sidebar column
47                strip.cell(|ui| {
48                    sidebar(ui);
49                });
50
51                // Content column, inset from the sidebar and the top.
52                strip.cell(|ui| {
53                    let inset = ui.active_theme().spacing.xxl;
54                    let left = inset - ui.spacing().item_spacing.x;
55                    egui::Frame::new()
56                        .inner_margin(egui::Margin {
57                            left: left as i8,
58                            top: inset as i8,
59                            ..Default::default()
60                        })
61                        .show(ui, content);
62                });
63            });
64    }
65}
66
67/// A sidebar tab item with icon, label, and selection state.
68///
69/// Used inside `SidebarLayout`'s sidebar column. Renders a full-width
70/// row with optional icon and label, with painted background for
71/// active/hover states.
72///
73/// # Example
74/// ```
75/// # use egui_charts::ui_kit::sidebar_layout::SidebarTab;
76/// # egui::__run_test_ui(|ui| {
77/// let tabs = ["General", "Appearance", "Trading"];
78/// let mut active = "General";
79/// for tab in tabs {
80///     if SidebarTab::new(tab, tab == active).show(ui) {
81///         active = tab;
82///     }
83/// }
84/// # });
85/// ```
86pub struct SidebarTab<'a> {
87    label: &'a str,
88    icon: Option<&'a crate::icons::Icon>,
89    is_active: bool,
90    fill: Option<Color32>,
91}
92
93impl<'a> SidebarTab<'a> {
94    /// Create a sidebar tab with a label and selection state.
95    pub fn new(label: &'a str, is_active: bool) -> Self {
96        Self {
97            label,
98            icon: None,
99            is_active,
100            fill: None,
101        }
102    }
103
104    /// Set the tab's optional icon.
105    pub fn icon(mut self, icon: &'a crate::icons::Icon) -> Self {
106        self.icon = Some(icon);
107        self
108    }
109
110    /// Set the fill of the selected tile, in place of the theme's neutral fill.
111    pub(crate) fn fill(mut self, fill: Color32) -> Self {
112        self.fill = Some(fill);
113        self
114    }
115
116    /// Show the sidebar tab. Returns `true` if clicked.
117    ///
118    /// The selected tab has a neutral fill and a semibold label in the text colour.
119    pub fn show(self, ui: &mut Ui) -> bool {
120        let theme = ui.active_theme();
121        let width = ui.available_width();
122        let height = DESIGN_TOKENS.sizing.button_xl;
123        // Rows touch, so the pitch is the tile height; the caller's spacing is kept for its other widgets.
124        let spacing = std::mem::take(&mut ui.spacing_mut().item_spacing.y);
125        let (rect, response) = ui.allocate_exact_size(Vec2::new(width, height), Sense::click());
126        ui.spacing_mut().item_spacing.y = spacing;
127        let translated_label = l10n::text(ui.ctx(), self.label);
128        response.widget_info(|| WidgetInfo::labeled(Role::Tab, ui.is_enabled(), &translated_label));
129        ui.ctx()
130            .accesskit_node_builder(response.id, |node| node.set_selected(self.is_active));
131
132        if self.is_active || response.hovered() {
133            ui.painter().rect_filled(
134                rect,
135                theme.radius.md,
136                if let Some(fill) = self.fill.filter(|_| self.is_active) {
137                    fill
138                } else if self.is_active && !theme.dark {
139                    theme.ui.text.gamma_multiply(0.06)
140                } else {
141                    ui.style().visuals.widgets.hovered.bg_fill
142                },
143            );
144        }
145
146        let text_color = ui.style().visuals.text_color();
147
148        // Every tab reserves the icon slot, so labels line up with or without an icon.
149        let icon_size = DESIGN_TOKENS.sizing.icon_md;
150        let icon_x = rect.min.x + theme.spacing.xxl + theme.spacing.sm;
151        if let Some(icon) = self.icon {
152            let icon_rect = Rect::from_min_size(
153                Pos2::new(icon_x, rect.center().y - icon_size / 2.0),
154                Vec2::splat(icon_size),
155            );
156            icon.as_image_tinted(icon_rect.size(), text_color)
157                .paint_at(ui, icon_rect);
158        }
159
160        // Label
161        let mut format =
162            egui::TextFormat::simple(egui::FontId::proportional(theme.text.title), text_color);
163        if self.is_active {
164            format.coords.push("wght", 600.0);
165        }
166        let label_x = icon_x + icon_size + theme.spacing.sm;
167        let mut job = egui::text::LayoutJob::single_section(translated_label, format);
168        job.wrap = egui::text::TextWrapping::truncate_at_width((rect.right() - label_x).max(0.0));
169        let galley = ui.painter().layout_job(job);
170        let label_pos = Pos2::new(label_x, rect.center().y - galley.size().y / 2.0);
171        ui.painter().galley(label_pos, galley, text_color);
172        crate::styles::focus::draw_focus_ring(ui, &response, theme.radius.md);
173
174        response.clicked()
175    }
176}