1use teksilo_canvas::{Canvas, Rect, SizeProposal};
30use teksilo_core::accessibility::AccessNodeBuilder;
31use teksilo_core::binding::BindingLevel;
32use teksilo_core::build_context::BuildContext;
33use teksilo_core::signal::Signal;
34use teksilo_core::styles::{
35 TabBarChromeConfig, TabBarOrientation, TabIndicatorPosition, TabStyle, TabStyleConfig,
36};
37use teksilo_core::widget::{LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement};
38use teksilo_core::widget_id::WidgetId;
39use teksilo_tokens::CornerRadius;
40
41use crate::primitives::{HStack, RectWidget, ZStack};
42
43pub const TAB_EDITOR_HEIGHT: f32 = 50.0;
46pub const TAB_TOOL_WINDOW_HEIGHT: f32 = 28.0;
47pub const TAB_PADDING_HORIZONTAL: f32 = 12.0;
48pub const TAB_UNDERLINE_ACTIVE: f32 = 2.0;
49pub const TAB_UNDERLINE_HOVER: f32 = 2.0;
50pub const TAB_CLOSE_BUTTON_SIZE: f32 = 16.0;
51const DROP_INDICATOR_WIDTH: f32 = 2.0;
53
54#[derive(Debug, Clone, Copy, PartialEq)]
59pub struct TabRecipe {
60 pub editor_height: f32,
61 pub tool_window_height: f32,
62 pub padding_horizontal: f32,
63 pub underline_active: f32,
64 pub underline_hover: f32,
65 pub close_button_size: f32,
66}
67
68impl Default for TabRecipe {
69 fn default() -> Self {
70 Self {
71 editor_height: TAB_EDITOR_HEIGHT,
72 tool_window_height: TAB_TOOL_WINDOW_HEIGHT,
73 padding_horizontal: TAB_PADDING_HORIZONTAL,
74 underline_active: TAB_UNDERLINE_ACTIVE,
75 underline_hover: TAB_UNDERLINE_HOVER,
76 close_button_size: TAB_CLOSE_BUTTON_SIZE,
77 }
78 }
79}
80
81#[derive(Debug, Default, Clone, Copy)]
83pub struct RecipeTabStyle {
84 pub recipe: TabRecipe,
85}
86
87impl RecipeTabStyle {
88 pub fn new(recipe: TabRecipe) -> Self {
89 Self { recipe }
90 }
91}
92
93impl TabStyle for RecipeTabStyle {
94 fn make_body(&self, cfg: &TabStyleConfig, ctx: &mut BuildContext) -> WidgetId {
95 let painter = ctx.add(TabBodyPainter {
98 is_active: cfg.is_active.clone(),
99 is_focused: cfg.is_focused.clone(),
100 is_disabled: cfg.is_disabled.clone(),
101 orientation: cfg.orientation,
102 indicator_position: cfg.indicator_position,
103 recipe: self.recipe,
104 });
105
106 let mut row = HStack::new();
110 if let Some(id) = cfg.leading {
111 row = row.add_child(id);
112 }
113 row = row.add_child(cfg.label);
114 if let Some(id) = cfg.trailing {
115 row = row.add_child(id);
116 }
117 let row_id = ctx.add(row);
118
119 ctx.add(ZStack::new().add_child(painter).add_child(row_id))
120 }
121
122 fn make_bar(&self, cfg: &TabBarChromeConfig, ctx: &mut BuildContext) -> WidgetId {
123 let painter = ctx.add(TabBarChromePainter {
135 orientation: cfg.orientation,
136 show_separator: cfg.show_separator,
137 drop_indicator: cfg.drop_indicator.clone(),
138 });
139
140 let mut layers = Vec::with_capacity(3);
141 if let Some(role) = &cfg.surface_role {
142 let backdrop = ctx.add(RectWidget::new().background(role.clone()));
146 layers.push(backdrop);
147 }
148 layers.push(painter);
149 layers.push(cfg.content);
150
151 ctx.add(TabBarChrome {
152 layers,
153 content: cfg.content,
154 })
155 }
156}
157
158#[derive(Debug)]
164struct TabBarChrome {
165 layers: Vec<WidgetId>,
167 content: WidgetId,
169}
170
171impl Widget for TabBarChrome {
172 fn build(&mut self, _ctx: &mut BuildContext) -> Vec<WidgetId> {
173 self.layers.clone()
174 }
175
176 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
177 ctx.child_size(self.content, proposal)
178 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
179 .into()
180 }
181
182 fn place_children(
183 &self,
184 bounds: Rect,
185 _proposal: SizeProposal,
186 children: &mut [WidgetPlacement],
187 _ctx: &LayoutContext,
188 ) {
189 for child in children.iter_mut() {
190 child.origin = bounds.origin();
191 child.size = bounds.size();
192 }
193 }
194
195 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
196 builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
197 }
198
199 fn children(&self) -> Vec<WidgetId> {
200 self.layers.clone()
201 }
202}
203
204struct TabBarChromePainter {
209 orientation: TabBarOrientation,
210 show_separator: bool,
211 drop_indicator: Signal<Option<f32>>,
212}
213
214impl std::fmt::Debug for TabBarChromePainter {
215 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
216 f.debug_struct("TabBarChromePainter")
217 .field("orientation", &self.orientation)
218 .field("show_separator", &self.show_separator)
219 .finish()
220 }
221}
222
223impl Widget for TabBarChromePainter {
224 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
225 self.drop_indicator.bind_to(
226 ctx.self_id(),
227 ctx.binding_registry(),
228 BindingLevel::RepaintOnly,
229 );
230 vec![]
231 }
232
233 fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
234 proposal.resolve(0.0, 0.0).into()
240 }
241
242 fn place_children(
243 &self,
244 _bounds: Rect,
245 _proposal: SizeProposal,
246 _children: &mut [WidgetPlacement],
247 _ctx: &LayoutContext,
248 ) {
249 }
250
251 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
252 if self.show_separator {
253 let border_width = ctx.theme.shape.border_width;
260 let envelope = ctx.theme.shape.focus_ring_offset + ctx.theme.shape.focus_ring_width;
261 let separator = match self.orientation {
262 TabBarOrientation::Horizontal => Rect::new(
263 bounds.x,
264 (bounds.bottom() - envelope - border_width).max(bounds.y),
265 bounds.width,
266 border_width,
267 ),
268 TabBarOrientation::Vertical => Rect::new(
269 (bounds.right() - envelope - border_width).max(bounds.x),
270 bounds.y,
271 border_width,
272 bounds.height,
273 ),
274 };
275 canvas.fill_rect(separator, ctx.theme.colors.border);
276 }
277
278 if let Some(local_pos) = self.drop_indicator.get() {
284 let indicator = match self.orientation {
285 TabBarOrientation::Horizontal => {
286 let world_x = bounds.x + local_pos;
287 Rect::new(
288 (world_x - DROP_INDICATOR_WIDTH * 0.5).max(bounds.x),
289 bounds.y,
290 DROP_INDICATOR_WIDTH,
291 bounds.height,
292 )
293 }
294 TabBarOrientation::Vertical => {
295 let world_y = bounds.y + local_pos;
296 Rect::new(
297 bounds.x,
298 (world_y - DROP_INDICATOR_WIDTH * 0.5).max(bounds.y),
299 bounds.width,
300 DROP_INDICATOR_WIDTH,
301 )
302 }
303 };
304 canvas.fill_rect(indicator, ctx.theme.colors.accent);
305 }
306 }
307
308 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
309 builder.set_hidden();
311 }
312}
313
314struct TabBodyPainter {
318 is_active: Signal<bool>,
319 is_focused: Signal<bool>,
320 is_disabled: Signal<bool>,
321 orientation: TabBarOrientation,
322 indicator_position: TabIndicatorPosition,
323 recipe: TabRecipe,
324}
325
326impl std::fmt::Debug for TabBodyPainter {
327 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
328 f.debug_struct("TabBodyPainter")
329 .field("orientation", &self.orientation)
330 .field("indicator_position", &self.indicator_position)
331 .finish()
332 }
333}
334
335impl TabBodyPainter {
336 fn indicator_rect(&self, bounds: Rect, thickness: f32, rtl: bool) -> Rect {
344 match self.orientation {
345 TabBarOrientation::Horizontal => match self.indicator_position {
346 TabIndicatorPosition::OuterEdge => {
347 Rect::new(bounds.x, bounds.y, bounds.width, thickness)
348 }
349 TabIndicatorPosition::InnerEdge => Rect::new(
350 bounds.x,
351 bounds.bottom() - thickness,
352 bounds.width,
353 thickness,
354 ),
355 },
356 TabBarOrientation::Vertical => {
357 let on_left = match self.indicator_position {
360 TabIndicatorPosition::OuterEdge => !rtl,
361 TabIndicatorPosition::InnerEdge => rtl,
362 };
363 let x = if on_left {
364 bounds.x
365 } else {
366 bounds.right() - thickness
367 };
368 Rect::new(x, bounds.y, thickness, bounds.height)
369 }
370 }
371 }
372}
373
374impl Widget for TabBodyPainter {
375 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
376 let id = ctx.self_id();
377 let registry = ctx.binding_registry();
378 self.is_active
379 .bind_to(id, registry, BindingLevel::RepaintOnly);
380 self.is_focused
381 .bind_to(id, registry, BindingLevel::RepaintOnly);
382 self.is_disabled
383 .bind_to(id, registry, BindingLevel::RepaintOnly);
384 vec![]
385 }
386
387 fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
388 proposal.resolve(0.0, 0.0).into()
395 }
396
397 fn place_children(
398 &self,
399 _bounds: Rect,
400 _proposal: SizeProposal,
401 _children: &mut [WidgetPlacement],
402 _ctx: &LayoutContext,
403 ) {
404 }
405
406 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
407 let colors = &ctx.theme.colors;
408 let shape = &ctx.theme.shape;
409 let active = self.is_active.get();
410 let focused = self.is_focused.get();
411 let disabled = self.is_disabled.get();
412
413 let indicator_thickness = self.recipe.underline_active;
422 if active && !disabled {
423 let rtl = matches!(
424 ctx.layout_direction,
425 teksilo_core::environment::LayoutDirection::RightToLeft
426 );
427 let indicator = self.indicator_rect(bounds, indicator_thickness, rtl);
428 canvas.fill_rect(indicator, colors.accent);
429 }
430
431 if focused {
435 let half_stroke = shape.focus_ring_width * 0.5;
436 let inset = half_stroke + shape.focus_ring_offset;
437 let ring_rect = Rect::new(
438 bounds.x + inset,
439 bounds.y + inset,
440 (bounds.width - inset * 2.0).max(0.0),
441 (bounds.height - inset * 2.0).max(0.0),
442 );
443 canvas.stroke_rounded_rect(
444 ring_rect,
445 CornerRadius::uniform(shape.radius_control),
446 colors.focus_ring,
447 shape.focus_ring_width,
448 );
449 }
450 }
451
452 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
453 builder.set_hidden();
456 }
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462
463 fn painter(
464 orientation: TabBarOrientation,
465 indicator_position: TabIndicatorPosition,
466 ) -> TabBodyPainter {
467 TabBodyPainter {
468 is_active: Signal::new(true),
469 is_focused: Signal::new(false),
470 is_disabled: Signal::new(false),
471 orientation,
472 indicator_position,
473 recipe: TabRecipe::default(),
474 }
475 }
476
477 const B: Rect = Rect {
479 x: 10.0,
480 y: 20.0,
481 width: 100.0,
482 height: 40.0,
483 };
484 const T: f32 = TAB_UNDERLINE_ACTIVE;
485
486 #[test]
487 fn horizontal_outer_edge_is_top_and_rtl_invariant() {
488 let p = painter(
489 TabBarOrientation::Horizontal,
490 TabIndicatorPosition::OuterEdge,
491 );
492 let expected = Rect::new(B.x, B.y, B.width, T);
493 assert_eq!(p.indicator_rect(B, T, false), expected);
494 assert_eq!(
495 p.indicator_rect(B, T, true),
496 expected,
497 "top edge is RTL-invariant"
498 );
499 }
500
501 #[test]
502 fn horizontal_inner_edge_is_bottom() {
503 let p = painter(
504 TabBarOrientation::Horizontal,
505 TabIndicatorPosition::InnerEdge,
506 );
507 let expected = Rect::new(B.x, B.bottom() - T, B.width, T);
508 assert_eq!(p.indicator_rect(B, T, false), expected);
509 assert_eq!(p.indicator_rect(B, T, true), expected);
510 }
511
512 #[test]
513 fn vertical_outer_edge_is_leading() {
514 let p = painter(TabBarOrientation::Vertical, TabIndicatorPosition::OuterEdge);
515 assert_eq!(
517 p.indicator_rect(B, T, false),
518 Rect::new(B.x, B.y, T, B.height)
519 );
520 assert_eq!(
521 p.indicator_rect(B, T, true),
522 Rect::new(B.right() - T, B.y, T, B.height)
523 );
524 }
525
526 #[test]
527 fn vertical_inner_edge_is_trailing() {
528 let p = painter(TabBarOrientation::Vertical, TabIndicatorPosition::InnerEdge);
529 assert_eq!(
531 p.indicator_rect(B, T, false),
532 Rect::new(B.right() - T, B.y, T, B.height)
533 );
534 assert_eq!(
535 p.indicator_rect(B, T, true),
536 Rect::new(B.x, B.y, T, B.height)
537 );
538 }
539}