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 FocusRingStroke, SharedTabStyle, TabBarChromeConfig, TabBarOrientation, TabIndicatorPosition,
36 TabStyle, TabStyleConfig,
37};
38use teksilo_core::widget::{LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement};
39use teksilo_core::widget_id::WidgetId;
40use teksilo_tokens::{CornerRadius, InputTokens, TargetRole};
41
42use crate::primitives::{HStack, RectWidget, ZStack};
43use teksilo_core::styles::density::{dp, spacing};
44
45pub const TAB_EDITOR_HEIGHT: f32 = 50.0;
48pub const TAB_PADDING_HORIZONTAL: f32 = 12.0;
49pub const TAB_UNDERLINE_ACTIVE: f32 = 2.0;
50pub const TAB_UNDERLINE_HOVER: f32 = 2.0;
51pub const TAB_CLOSE_BUTTON_SIZE: f32 = 16.0;
52const DROP_INDICATOR_WIDTH: f32 = 2.0;
54
55#[derive(Debug, Clone, Copy, PartialEq)]
60pub struct TabRecipe {
61 pub editor_height: f32,
65 pub padding_horizontal: f32,
68 pub underline_active: f32,
69 pub underline_hover: f32,
70 pub close_button_size: f32,
71}
72
73impl TabRecipe {
74 pub fn for_tokens(tokens: &InputTokens) -> Self {
80 Self {
81 editor_height: dp(TAB_EDITOR_HEIGHT, TargetRole::Target, tokens),
82 padding_horizontal: spacing(TAB_PADDING_HORIZONTAL, tokens),
83 underline_active: TAB_UNDERLINE_ACTIVE,
84 underline_hover: TAB_UNDERLINE_HOVER,
85 close_button_size: TAB_CLOSE_BUTTON_SIZE,
86 }
87 }
88}
89
90impl Default for TabRecipe {
91 fn default() -> Self {
92 Self::for_tokens(&InputTokens::default())
93 }
94}
95
96#[derive(Debug, Default, Clone, Copy)]
98pub struct RecipeTabStyle {
99 pub recipe: TabRecipe,
100}
101
102impl RecipeTabStyle {
103 pub fn new(recipe: TabRecipe) -> Self {
104 Self { recipe }
105 }
106
107 pub fn for_tokens(tokens: &InputTokens) -> Self {
114 Self {
115 recipe: TabRecipe::for_tokens(tokens),
116 }
117 }
118}
119
120impl TabStyle for RecipeTabStyle {
121 fn make_body(&self, cfg: &TabStyleConfig, ctx: &mut BuildContext) -> WidgetId {
122 let painter = ctx.add(TabBodyPainter {
125 is_active: cfg.is_active.clone(),
126 is_focused: cfg.is_focused.clone(),
127 is_disabled: cfg.is_disabled.clone(),
128 orientation: cfg.orientation,
129 indicator_position: cfg.indicator_position,
130 recipe: self.recipe,
131 });
132
133 let mut row = HStack::new();
137 if let Some(id) = cfg.leading {
138 row = row.child(id);
139 }
140 row = row.child(cfg.label);
141 if let Some(id) = cfg.trailing {
142 row = row.child(id);
143 }
144 let row_id = ctx.add(row);
145
146 ctx.add(ZStack::new().child(painter).child(row_id))
147 }
148
149 fn make_bar(&self, cfg: &TabBarChromeConfig, ctx: &mut BuildContext) -> WidgetId {
150 let painter = ctx.add(TabBarChromePainter {
162 orientation: cfg.orientation,
163 show_separator: cfg.show_separator,
164 drop_indicator: cfg.drop_indicator.clone(),
165 });
166
167 let mut layers = Vec::with_capacity(3);
168 if let Some(role) = &cfg.surface_role {
169 let backdrop = ctx.add(RectWidget::new().background(role.clone()));
173 layers.push(backdrop);
174 }
175 layers.push(painter);
176 layers.push(cfg.content);
177
178 ctx.add(TabBarChrome {
179 layers,
180 content: cfg.content,
181 })
182 }
183
184 fn editor_height(&self, _tokens: &InputTokens) -> f32 {
190 self.recipe.editor_height
191 }
192
193 fn padding_horizontal(&self, _tokens: &InputTokens) -> f32 {
196 self.recipe.padding_horizontal
197 }
198}
199
200pub(crate) fn resolve_tab_style(
208 style_override: Option<&SharedTabStyle>,
209 ctx: &BuildContext,
210) -> SharedTabStyle {
211 style_override
212 .cloned()
213 .or_else(|| ctx.theme().style_slots.tab.clone())
214 .unwrap_or_else(|| {
215 std::rc::Rc::new(RecipeTabStyle::for_tokens(&ctx.theme().input)) as SharedTabStyle
216 })
217}
218
219pub(crate) fn tab_editor_height(style: Option<&SharedTabStyle>, tokens: &InputTokens) -> f32 {
222 match style {
223 Some(style) => style.editor_height(tokens),
224 None => TabRecipe::for_tokens(tokens).editor_height,
225 }
226}
227
228pub(crate) fn tab_padding_horizontal(style: Option<&SharedTabStyle>, tokens: &InputTokens) -> f32 {
231 match style {
232 Some(style) => style.padding_horizontal(tokens),
233 None => TabRecipe::for_tokens(tokens).padding_horizontal,
234 }
235}
236
237#[derive(Debug)]
243struct TabBarChrome {
244 layers: Vec<WidgetId>,
246 content: WidgetId,
248}
249
250impl Widget for TabBarChrome {
251 fn build(&mut self, _ctx: &mut BuildContext) -> Vec<WidgetId> {
252 self.layers.clone()
253 }
254
255 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
256 ctx.child_size(self.content, proposal)
257 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
258 .into()
259 }
260
261 fn place_children(
262 &self,
263 bounds: Rect,
264 _proposal: SizeProposal,
265 children: &mut [WidgetPlacement],
266 _ctx: &LayoutContext,
267 ) {
268 for child in children.iter_mut() {
269 child.origin = bounds.origin();
270 child.size = bounds.size();
271 }
272 }
273
274 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
275 builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
276 }
277
278 fn children(&self) -> Vec<WidgetId> {
279 self.layers.clone()
280 }
281}
282
283struct TabBarChromePainter {
288 orientation: TabBarOrientation,
289 show_separator: bool,
290 drop_indicator: Signal<Option<f32>>,
291}
292
293impl std::fmt::Debug for TabBarChromePainter {
294 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
295 f.debug_struct("TabBarChromePainter")
296 .field("orientation", &self.orientation)
297 .field("show_separator", &self.show_separator)
298 .finish()
299 }
300}
301
302impl Widget for TabBarChromePainter {
303 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
304 self.drop_indicator.bind_to(
305 ctx.self_id(),
306 ctx.binding_registry(),
307 BindingLevel::RepaintOnly,
308 );
309 vec![]
310 }
311
312 fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
313 proposal.resolve(0.0, 0.0).into()
319 }
320
321 fn place_children(
322 &self,
323 _bounds: Rect,
324 _proposal: SizeProposal,
325 _children: &mut [WidgetPlacement],
326 _ctx: &LayoutContext,
327 ) {
328 }
329
330 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
331 if self.show_separator {
332 let border_width = ctx.theme.shape.border_width;
339 let envelope = ctx.theme.shape.focus_ring_envelope();
340 let separator = match self.orientation {
341 TabBarOrientation::Horizontal => Rect::new(
342 bounds.x,
343 (bounds.bottom() - envelope - border_width).max(bounds.y),
344 bounds.width,
345 border_width,
346 ),
347 TabBarOrientation::Vertical => Rect::new(
348 (bounds.right() - envelope - border_width).max(bounds.x),
349 bounds.y,
350 border_width,
351 bounds.height,
352 ),
353 };
354 canvas.fill_rect(separator, ctx.theme.colors.border);
355 }
356
357 if let Some(local_pos) = self.drop_indicator.get() {
363 let indicator = match self.orientation {
364 TabBarOrientation::Horizontal => {
365 let world_x = bounds.x + local_pos;
366 Rect::new(
367 (world_x - DROP_INDICATOR_WIDTH * 0.5).max(bounds.x),
368 bounds.y,
369 DROP_INDICATOR_WIDTH,
370 bounds.height,
371 )
372 }
373 TabBarOrientation::Vertical => {
374 let world_y = bounds.y + local_pos;
375 Rect::new(
376 bounds.x,
377 (world_y - DROP_INDICATOR_WIDTH * 0.5).max(bounds.y),
378 bounds.width,
379 DROP_INDICATOR_WIDTH,
380 )
381 }
382 };
383 canvas.fill_rect(indicator, ctx.theme.colors.accent);
384 }
385 }
386
387 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
388 builder.set_hidden();
390 }
391}
392
393struct TabBodyPainter {
397 is_active: Signal<bool>,
398 is_focused: Signal<bool>,
399 is_disabled: Signal<bool>,
400 orientation: TabBarOrientation,
401 indicator_position: TabIndicatorPosition,
402 recipe: TabRecipe,
403}
404
405impl std::fmt::Debug for TabBodyPainter {
406 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
407 f.debug_struct("TabBodyPainter")
408 .field("orientation", &self.orientation)
409 .field("indicator_position", &self.indicator_position)
410 .finish()
411 }
412}
413
414impl TabBodyPainter {
415 fn indicator_rect(&self, bounds: Rect, thickness: f32, rtl: bool) -> Rect {
423 match self.orientation {
424 TabBarOrientation::Horizontal => match self.indicator_position {
425 TabIndicatorPosition::OuterEdge => {
426 Rect::new(bounds.x, bounds.y, bounds.width, thickness)
427 }
428 TabIndicatorPosition::InnerEdge => Rect::new(
429 bounds.x,
430 bounds.bottom() - thickness,
431 bounds.width,
432 thickness,
433 ),
434 },
435 TabBarOrientation::Vertical => {
436 let on_left = match self.indicator_position {
439 TabIndicatorPosition::OuterEdge => !rtl,
440 TabIndicatorPosition::InnerEdge => rtl,
441 };
442 let x = if on_left {
443 bounds.x
444 } else {
445 bounds.right() - thickness
446 };
447 Rect::new(x, bounds.y, thickness, bounds.height)
448 }
449 }
450 }
451}
452
453impl Widget for TabBodyPainter {
454 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
455 let id = ctx.self_id();
456 let registry = ctx.binding_registry();
457 self.is_active
458 .bind_to(id, registry, BindingLevel::RepaintOnly);
459 self.is_focused
460 .bind_to(id, registry, BindingLevel::RepaintOnly);
461 self.is_disabled
462 .bind_to(id, registry, BindingLevel::RepaintOnly);
463 vec![]
464 }
465
466 fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
467 proposal.resolve(0.0, 0.0).into()
474 }
475
476 fn place_children(
477 &self,
478 _bounds: Rect,
479 _proposal: SizeProposal,
480 _children: &mut [WidgetPlacement],
481 _ctx: &LayoutContext,
482 ) {
483 }
484
485 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
486 let colors = &ctx.theme.colors;
487 let shape = &ctx.theme.shape;
488 let active = self.is_active.get();
489 let focused = self.is_focused.get();
490 let disabled = self.is_disabled.get();
491
492 let indicator_thickness = self.recipe.underline_active;
501 if active && !disabled {
502 let rtl = matches!(
503 ctx.layout_direction,
504 teksilo_core::environment::LayoutDirection::RightToLeft
505 );
506 let indicator = self.indicator_rect(bounds, indicator_thickness, rtl);
507 canvas.fill_rect(indicator, colors.accent);
508 }
509
510 if focused {
514 canvas.stroke_rounded_rect(
515 focus_ring_rect(bounds, shape),
516 CornerRadius::uniform(shape.radius_control),
517 colors.focus_ring,
518 shape.focus_ring_width,
519 );
520 }
521 }
522
523 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
524 builder.set_hidden();
527 }
528}
529
530fn focus_ring_rect(bounds: Rect, shape: &teksilo_tokens::ShapeTokens) -> Rect {
538 let half_stroke = shape.focus_ring_width * 0.5;
539 let inset = (half_stroke + shape.focus_ring_offset).max(half_stroke);
540 FocusRingStroke::at_distance(bounds, 0.0, -inset).rect
541}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546
547 fn painter(
548 orientation: TabBarOrientation,
549 indicator_position: TabIndicatorPosition,
550 ) -> TabBodyPainter {
551 TabBodyPainter {
552 is_active: Signal::new(true),
553 is_focused: Signal::new(false),
554 is_disabled: Signal::new(false),
555 orientation,
556 indicator_position,
557 recipe: TabRecipe::default(),
558 }
559 }
560
561 const B: Rect = Rect {
563 x: 10.0,
564 y: 20.0,
565 width: 100.0,
566 height: 40.0,
567 };
568 const T: f32 = TAB_UNDERLINE_ACTIVE;
569
570 #[test]
571 fn horizontal_outer_edge_is_top_and_rtl_invariant() {
572 let p = painter(
573 TabBarOrientation::Horizontal,
574 TabIndicatorPosition::OuterEdge,
575 );
576 let expected = Rect::new(B.x, B.y, B.width, T);
577 assert_eq!(p.indicator_rect(B, T, false), expected);
578 assert_eq!(
579 p.indicator_rect(B, T, true),
580 expected,
581 "top edge is RTL-invariant"
582 );
583 }
584
585 #[test]
586 fn horizontal_inner_edge_is_bottom() {
587 let p = painter(
588 TabBarOrientation::Horizontal,
589 TabIndicatorPosition::InnerEdge,
590 );
591 let expected = Rect::new(B.x, B.bottom() - T, B.width, T);
592 assert_eq!(p.indicator_rect(B, T, false), expected);
593 assert_eq!(p.indicator_rect(B, T, true), expected);
594 }
595
596 #[test]
597 fn vertical_outer_edge_is_leading() {
598 let p = painter(TabBarOrientation::Vertical, TabIndicatorPosition::OuterEdge);
599 assert_eq!(
601 p.indicator_rect(B, T, false),
602 Rect::new(B.x, B.y, T, B.height)
603 );
604 assert_eq!(
605 p.indicator_rect(B, T, true),
606 Rect::new(B.right() - T, B.y, T, B.height)
607 );
608 }
609
610 #[test]
611 fn vertical_inner_edge_is_trailing() {
612 let p = painter(TabBarOrientation::Vertical, TabIndicatorPosition::InnerEdge);
613 assert_eq!(
615 p.indicator_rect(B, T, false),
616 Rect::new(B.right() - T, B.y, T, B.height)
617 );
618 assert_eq!(
619 p.indicator_rect(B, T, true),
620 Rect::new(B.x, B.y, T, B.height)
621 );
622 }
623
624 fn shape(offset: f32) -> teksilo_tokens::ShapeTokens {
625 teksilo_tokens::ShapeTokens {
626 focus_ring_offset: offset,
627 focus_ring_width: 2.0,
628 ..teksilo_tokens::ShapeTokens::default()
629 }
630 }
631
632 #[test]
633 fn the_focus_ring_follows_the_offset_inside_the_tab() {
634 assert_eq!(
636 focus_ring_rect(B, &shape(2.0)),
637 Rect::new(13.0, 23.0, 94.0, 34.0)
638 );
639 assert_eq!(
641 focus_ring_rect(B, &shape(0.0)),
642 Rect::new(11.0, 21.0, 98.0, 38.0)
643 );
644 }
645
646 #[test]
647 fn a_negative_focus_ring_offset_keeps_the_ring_inside_the_tab() {
648 for offset in [-1.0, -5.0, -100.0] {
649 let ring = focus_ring_rect(B, &shape(offset));
650 assert!(
652 ring.x - 1.0 >= B.x && ring.y - 1.0 >= B.y,
653 "{offset}: {ring:?}"
654 );
655 assert!(ring.right() + 1.0 <= B.right(), "{offset}: {ring:?}");
656 assert!(ring.bottom() + 1.0 <= B.bottom(), "{offset}: {ring:?}");
657 }
658 }
659
660 #[test]
664 fn every_offset_at_or_below_zero_draws_the_same_tab_ring() {
665 let flush = focus_ring_rect(B, &shape(0.0));
666 for offset in [-1.0, -5.0, -100.0] {
667 assert_eq!(focus_ring_rect(B, &shape(offset)), flush, "{offset}");
668 }
669 }
670
671 #[test]
672 fn the_focus_ring_never_inverts_on_a_narrow_tab() {
673 let narrow = Rect::new(0.0, 0.0, 4.0, 3.0);
674 for offset in [-1.0, 2.0, 50.0] {
675 let ring = focus_ring_rect(narrow, &shape(offset));
676 assert!(
677 ring.width >= 0.0 && ring.height >= 0.0,
678 "{offset}: {ring:?}"
679 );
680 }
681 }
682}