1use std::sync::Arc;
2use rosace_core::types::{Point, Rect, Size};
3use rosace_render::Color;
4use super::{Widget, LayoutCtx, PaintCtx, avail_w};
5
6pub struct Tab {
8 pub label: String,
9}
10
11impl Tab {
12 pub fn new(label: impl Into<String>) -> Self { Self { label: label.into() } }
13}
14
15pub struct TabBar {
23 tabs: Vec<Tab>,
24 selected: usize,
25 background: Option<Color>,
28 active_color: Option<Color>,
29 inactive_color: Option<Color>,
30 indicator_color: Option<Color>,
31 border_color: Option<Color>,
32 height: f32,
33 font_size: Option<f32>,
37 animated: bool,
38 on_change: Option<Arc<dyn Fn(usize) + Send + Sync>>,
39 scrollable: bool,
43}
44
45const SCROLLABLE_TAB_PAD: f32 = 20.0;
48
49impl TabBar {
50 pub fn new() -> Self {
51 Self {
52 tabs: Vec::new(),
53 selected: 0,
54 background: None,
55 active_color: None,
56 inactive_color: None,
57 indicator_color: None,
58 border_color: None,
59 height: 40.0,
60 font_size: None,
61 animated: true,
62 on_change: None,
63 scrollable: false,
64 }
65 }
66 pub fn tab(mut self, t: Tab) -> Self { self.tabs.push(t); self }
67 pub fn selected(mut self, i: usize) -> Self { self.selected = i; self }
68 pub fn height(mut self, h: f32) -> Self { self.height = h; self }
69 pub fn font_size(mut self, s: f32) -> Self { self.font_size = Some(s); self }
70
71 fn resolved_font_size(&self, theme: &rosace_theme::ThemeData) -> f32 {
72 self.font_size.unwrap_or(theme.typography.label_large.size)
73 }
74 pub fn background(mut self, c: Color) -> Self { self.background = Some(c); self }
76 pub fn active_color(mut self, c: Color) -> Self { self.active_color = Some(c); self }
78 pub fn inactive_color(mut self, c: Color) -> Self { self.inactive_color = Some(c); self }
80 pub fn indicator_color(mut self, c: Color) -> Self { self.indicator_color = Some(c); self }
82 pub fn border_color(mut self, c: Color) -> Self { self.border_color = Some(c); self }
84 pub fn animated(mut self, on: bool) -> Self { self.animated = on; self }
86 pub fn on_change(mut self, f: impl Fn(usize) + Send + Sync + 'static) -> Self {
88 self.on_change = Some(Arc::new(f));
89 self
90 }
91 pub fn scrollable(mut self, on: bool) -> Self { self.scrollable = on; self }
96
97 fn natural_tab_width(&self, i: usize, font: &rosace_render::FontCache, font_size: f32) -> f32 {
99 font.measure_text(&self.tabs[i].label, font_size) + SCROLLABLE_TAB_PAD * 2.0
100 }
101
102 fn natural_total_width(&self, font: &rosace_render::FontCache, font_size: f32) -> f32 {
104 self.tabs.iter().map(|t| font.measure_text(&t.label, font_size) + SCROLLABLE_TAB_PAD * 2.0).sum()
105 }
106}
107
108impl Default for TabBar {
109 fn default() -> Self { Self::new() }
110}
111
112impl Widget for TabBar {
113 fn layout(&self, ctx: &LayoutCtx) -> Size {
114 if self.scrollable {
115 let font_size = self.resolved_font_size(ctx.theme);
120 let w = self.natural_total_width(ctx.font, font_size).max(avail_w(ctx.constraints));
121 return Size { width: w, height: self.height };
122 }
123 Size { width: avail_w(ctx.constraints), height: self.height }
124 }
125
126 fn paint(&self, ctx: &mut PaintCtx) {
127 let (bg, active, inactive, indicator, border) = {
130 let t = &ctx.theme.colors;
131 let on_surface = ctx.tc(t.on_surface);
132 let surface = ctx.tc(t.surface);
133 (
134 self.background.unwrap_or(surface),
135 self.active_color.unwrap_or(on_surface),
136 self.inactive_color.unwrap_or_else(|| super::lerp_color(on_surface, surface, 0.5)),
138 self.indicator_color.unwrap_or_else(|| ctx.tc(t.primary)),
139 self.border_color.unwrap_or_else(|| ctx.tc(t.outline)),
140 )
141 };
142
143 let r = ctx.rect;
144 ctx.fill_rect(r, bg);
145 ctx.fill_rect(
147 Rect { origin: Point { x: r.origin.x, y: r.origin.y + r.size.height - 1.0 },
148 size: Size { width: r.size.width, height: 1.0 } },
149 border,
150 );
151
152 if self.tabs.is_empty() { return; }
153 let font_size = self.resolved_font_size(&ctx.theme);
154
155 let widths: Vec<f32> = if self.scrollable {
160 (0..self.tabs.len()).map(|i| self.natural_tab_width(i, ctx.font, font_size)).collect()
161 } else {
162 let w = r.size.width / self.tabs.len() as f32;
163 vec![w; self.tabs.len()]
164 };
165 let mut offsets = Vec::with_capacity(widths.len());
166 let mut acc = 0.0;
167 for w in &widths { offsets.push(acc); acc += w; }
168
169 let pos = if self.animated {
172 ctx.animate_to(self.selected as f32, 0.0)
173 } else {
174 self.selected as f32
175 };
176 let lo = (pos.floor().max(0.0) as usize).min(widths.len() - 1);
182 let hi = (pos.ceil().max(0.0) as usize).min(widths.len() - 1);
183 let frac = pos - lo as f32;
184 let seg_x = offsets[lo] + (offsets[hi] - offsets[lo]) * frac;
185 let seg_w = widths[lo] + (widths[hi] - widths[lo]) * frac;
186 let underline = Rect {
187 origin: Point { x: r.origin.x + seg_x + seg_w * 0.15, y: r.origin.y + r.size.height - 2.5 },
188 size: Size { width: seg_w * 0.7, height: 2.5 },
189 };
190 ctx.fill_rect(underline, indicator);
191
192 let with_alpha = |c: Color, a: f32| Color::rgba(c.r, c.g, c.b, (a.clamp(0.0, 1.0) * 255.0).round() as u8);
193 for (i, tab) in self.tabs.iter().enumerate() {
194 let tab_x = r.origin.x + offsets[i];
195 let tab_w = widths[i];
196 let tab_rect = Rect { origin: Point { x: tab_x, y: r.origin.y }, size: Size { width: tab_w, height: r.size.height } };
197 let mut child = ctx.child(tab_rect);
198 let hov = child.hovered();
199 let prs = child.pressed();
200 if hov || prs {
203 child.fill_rect(Rect {
204 origin: Point { x: tab_x + 2.0, y: r.origin.y + 2.0 },
205 size: Size { width: tab_w - 4.0, height: r.size.height - 4.0 },
206 }, with_alpha(active, if prs { 0.10 } else { 0.06 }));
207 }
208 let nearness = (1.0 - (pos - i as f32).abs()).clamp(0.0, 1.0);
211 let mut label_color = super::lerp_color(inactive, active, nearness);
212 if hov { label_color = super::lerp_color(label_color, active, 0.6); }
213 let text_w = child.font.measure_text(&tab.label, font_size);
214 let line_h = child.font.line_height(font_size);
215 let tx = tab_x + (tab_w - text_w) / 2.0;
216 let ty = r.origin.y + (r.size.height - line_h) / 2.0;
217 child.draw_text_at(&tab.label, Point { x: tx, y: ty }, label_color, font_size);
218
219 child.semantics(
220 super::Semantics::new(rosace_core::Role::Tab)
221 .label(&tab.label)
222 .value(if i == self.selected { "selected" } else { "not selected" }),
223 );
224 match &self.on_change {
227 Some(cb) => { let cb = cb.clone(); child.register_hit(Arc::new(move || cb(i))); }
228 None => child.register_hit(Arc::new(|| {})),
229 }
230 }
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237 use rosace_layout::Constraints;
238
239 #[test]
240 fn fills_width_and_fixed_height() {
241 let font = rosace_render::FontCache::embedded();
242 let theme = rosace_theme::built_in::dark_theme();
243 let ctx = LayoutCtx::new(Constraints::loose(400.0, 400.0), &font, &theme);
244 let bar = TabBar::new().tab(Tab::new("A")).tab(Tab::new("B")).height(44.0);
245 let size = bar.layout(&ctx);
246 assert_eq!(size.width, 400.0);
247 assert_eq!(size.height, 44.0);
248 }
249}