1use std::cell::{Cell, RefCell};
33use std::collections::HashMap;
34use std::rc::Rc;
35
36use teksilo_canvas::{Canvas, Rect, SizeProposal};
37use teksilo_core::accessibility::{AccessNodeBuilder, widget_id_to_node_id};
38use teksilo_core::binding::BindingLevel;
39use teksilo_core::build_context::BuildContext;
40use teksilo_core::color_prop::ColorProp;
41use teksilo_core::event::{EventResponse, Key, WidgetEvent};
42use teksilo_core::gesture::DragPhase;
43use teksilo_core::signal::{Prop, Signal};
44use teksilo_core::widget::{
45 CursorIcon, EventContext, LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement,
46};
47use teksilo_core::widget_builder::HandlerSet;
48use teksilo_core::widget_id::WidgetId;
49use teksilo_core::{DragPayload, DropFeedback};
50use teksilo_i18n::{LocalizedString, lit};
51use teksilo_tokens::{BorderRole, CornerRadius, HAlignment, SurfaceRole, TextRole, TextStyleRole};
52
53use crate::icon_button::{IconButton, IconButtonSize};
54use crate::popover_widget::PopoverIconButton;
55use crate::primitives::{
56 Center, FixedSize, HStack, IconWidget, Padding, RectWidget, Spacer, TextWidget, VStack, ZStack,
57};
58use crate::styles::recipe_icon_button_style::ICON_BUTTON_CORNER_RADIUS;
59use crate::tool_box::RotatedLabel;
60
61use super::context_menu::{DockMenuKind, activity_context_menu, background_menu};
62use super::drag::{DockTabDragData, dropped_dock_tab, dropped_dock_widget};
63use super::geometry::DockSide;
64use super::model::{DockIconFactory, DockRailItemSize, DockTabId, DockingModel};
65
66type RailItemBounds = Rc<RefCell<Vec<(usize, Rect)>>>;
72
73type RailItemIds = Rc<RefCell<Vec<(usize, WidgetId)>>>;
77
78pub type DockRailSlot = Rc<dyn Fn() -> Box<dyn Widget>>;
85
86fn item_extent(size: IconButtonSize) -> f32 {
88 use crate::styles::recipe_icon_button_style::*;
89 match size {
90 IconButtonSize::Compact => ICON_BUTTON_SIZE_COMPACT,
91 IconButtonSize::Default => ICON_BUTTON_SIZE_DEFAULT,
92 IconButtonSize::Toolbar => ICON_BUTTON_SIZE_TOOLBAR,
93 IconButtonSize::Large => ICON_BUTTON_SIZE_LARGE,
94 IconButtonSize::Hero => ICON_BUTTON_SIZE_HERO,
95 }
96}
97
98fn item_glyph_size(size: IconButtonSize) -> f32 {
103 use crate::styles::recipe_icon_button_style::*;
104 match size {
105 IconButtonSize::Compact | IconButtonSize::Default => ICON_BUTTON_ICON_SIZE,
106 IconButtonSize::Toolbar => ICON_BUTTON_ICON_SIZE_TOOLBAR,
107 IconButtonSize::Large => ICON_BUTTON_ICON_SIZE_LARGE,
108 IconButtonSize::Hero => ICON_BUTTON_ICON_SIZE_HERO,
109 }
110}
111
112const RAIL_ITEM_SPACING: f32 = 2.0;
114const RAIL_PADDING: f32 = 4.0;
116const LABELED_TITLE_ALLOWANCE: f32 = 72.0;
119const LABELED_TOP_MARGIN: f32 = 6.0;
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
136pub struct DockActionId(u64);
137
138impl DockActionId {
139 pub const fn named(name: &str) -> Self {
153 let bytes = name.as_bytes();
157 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
158 let mut i = 0;
159 while i < bytes.len() {
160 hash ^= bytes[i] as u64;
161 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
162 i += 1;
163 }
164 Self(hash)
165 }
166
167 pub const fn from_raw(v: u64) -> Self {
169 Self(v)
170 }
171
172 pub const fn raw(self) -> u64 {
173 self.0
174 }
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179pub enum DockActionPlacement {
180 Start,
182 End,
185 Pinned,
189}
190
191#[derive(Clone)]
214pub struct DockAction {
215 pub(crate) id: DockActionId,
216 pub(crate) placement: DockActionPlacement,
217 pub(crate) label: LocalizedString,
218 pub(crate) icon: DockIconFactory,
219 pub(crate) tooltip: Option<LocalizedString>,
220 pub(crate) enabled: Prop<bool>,
221 pub(crate) toggled: Option<Signal<bool>>,
230 pub(crate) on_activate: Rc<dyn Fn(&mut EventContext)>,
231}
232
233impl std::fmt::Debug for DockAction {
234 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
235 f.debug_struct("DockAction")
236 .field("id", &self.id)
237 .field("placement", &self.placement)
238 .finish()
239 }
240}
241
242impl DockAction {
243 pub fn new(
246 id: DockActionId,
247 label: impl Into<LocalizedString>,
248 icon: impl Fn() -> IconWidget + 'static,
249 on_activate: impl Fn(&mut EventContext) + 'static,
250 ) -> Self {
251 Self {
252 id,
253 placement: DockActionPlacement::End,
254 label: label.into(),
255 icon: Rc::new(icon),
256 tooltip: None,
257 enabled: Prop::Static(true),
258 toggled: None,
259 on_activate: Rc::new(on_activate),
260 }
261 }
262
263 pub fn placement(mut self, placement: DockActionPlacement) -> Self {
265 self.placement = placement;
266 self
267 }
268
269 pub fn tooltip(mut self, tooltip: impl Into<LocalizedString>) -> Self {
272 self.tooltip = Some(tooltip.into());
273 self
274 }
275
276 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
278 self.enabled = enabled.into();
279 self
280 }
281
282 pub fn toggled(mut self, state: Signal<bool>) -> Self {
286 self.toggled = Some(state);
287 self
288 }
289
290 pub fn id(&self) -> DockActionId {
292 self.id
293 }
294}
295
296#[derive(Clone)]
307pub struct DockRail {
308 pub(crate) side: DockSide,
309 pub(crate) size: IconButtonSize,
310 pub(crate) background: Option<ColorProp>,
311 pub(crate) divider: Option<ColorProp>,
312 pub(crate) top_slot: Option<DockRailSlot>,
313 pub(crate) bottom_slot: Option<DockRailSlot>,
314 pub(crate) leading_slot: Option<DockRailSlot>,
315 pub(crate) trailing_slot: Option<DockRailSlot>,
316 pub(crate) actions: Vec<DockAction>,
317 pub(crate) overflow_icon: Option<DockIconFactory>,
318}
319
320impl std::fmt::Debug for DockRail {
321 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
322 f.debug_struct("DockRail")
323 .field("side", &self.side)
324 .field("size", &self.size)
325 .finish()
326 }
327}
328
329impl DockRail {
330 pub fn new(side: DockSide) -> Self {
332 Self {
333 side,
334 size: IconButtonSize::Large,
335 background: None,
336 divider: None,
337 top_slot: None,
338 bottom_slot: None,
339 leading_slot: None,
340 trailing_slot: None,
341 actions: Vec::new(),
342 overflow_icon: None,
343 }
344 }
345
346 pub fn size(mut self, size: IconButtonSize) -> Self {
349 self.size = size;
350 self
351 }
352
353 pub fn background(mut self, color: impl Into<ColorProp>) -> Self {
357 self.background = Some(color.into());
358 self
359 }
360
361 pub fn divider(mut self) -> Self {
366 self.divider = Some(BorderRole::Divider.into());
367 self
368 }
369
370 pub fn divider_color(mut self, color: impl Into<ColorProp>) -> Self {
374 self.divider = Some(color.into());
375 self
376 }
377
378 pub fn top_slot<W: Widget + 'static>(mut self, f: impl Fn() -> W + 'static) -> Self {
383 self.top_slot = Some(Rc::new(move || Box::new(f()) as Box<dyn Widget>));
384 self
385 }
386
387 pub fn bottom_slot<W: Widget + 'static>(mut self, f: impl Fn() -> W + 'static) -> Self {
392 self.bottom_slot = Some(Rc::new(move || Box::new(f()) as Box<dyn Widget>));
393 self
394 }
395
396 pub fn leading_slot<W: Widget + 'static>(mut self, f: impl Fn() -> W + 'static) -> Self {
408 self.leading_slot = Some(Rc::new(move || Box::new(f()) as Box<dyn Widget>));
409 self
410 }
411
412 pub fn trailing_slot<W: Widget + 'static>(mut self, f: impl Fn() -> W + 'static) -> Self {
417 self.trailing_slot = Some(Rc::new(move || Box::new(f()) as Box<dyn Widget>));
418 self
419 }
420
421 pub fn action(mut self, action: DockAction) -> Self {
432 debug_assert!(
440 !self.actions.iter().any(|a| a.id == action.id),
441 "duplicate DockActionId {:?} on the {:?} rail — ids must be unique \
442 per side (two `DockAction`s declared with the same id, or an \
443 FNV-1a collision between two `DockActionId::named` values)",
444 action.id,
445 self.side,
446 );
447 self.actions.push(action);
448 self
449 }
450
451 pub fn overflow_icon(mut self, f: impl Fn() -> IconWidget + 'static) -> Self {
455 self.overflow_icon = Some(Rc::new(f));
456 self
457 }
458
459 pub(crate) fn actions_at(&self, placement: DockActionPlacement) -> Vec<DockAction> {
461 self.actions
462 .iter()
463 .filter(|a| a.placement == placement)
464 .cloned()
465 .collect()
466 }
467
468 pub(crate) fn side(&self) -> DockSide {
469 self.side
470 }
471
472 pub(crate) fn effective_thickness(&self, mode: DockRailItemSize) -> f32 {
478 let size = if matches!(mode, DockRailItemSize::Compact) {
479 IconButtonSize::Default
480 } else {
481 self.size
482 };
483 item_extent(size) + RAIL_PADDING * 2.0
484 }
485}
486
487pub(crate) struct DockActivityBar {
492 side: DockSide,
493 model: DockingModel,
494 config: DockRail,
495 visible_count: Signal<usize>,
499 item_count: usize,
500 item_bounds: RailItemBounds,
506 item_ids: RailItemIds,
513 self_bounds: Rc<Cell<Rect>>,
516 side_panel_ids: Rc<RefCell<HashMap<DockSide, WidgetId>>>,
520 drop_indicator: Signal<Option<f32>>,
523 root: Option<WidgetId>,
524}
525
526impl std::fmt::Debug for DockActivityBar {
527 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
528 f.debug_struct("DockActivityBar")
529 .field("side", &self.side)
530 .finish()
531 }
532}
533
534impl DockActivityBar {
535 pub(crate) fn new(
536 side: DockSide,
537 model: DockingModel,
538 config: DockRail,
539 side_panel_ids: Rc<RefCell<HashMap<DockSide, WidgetId>>>,
540 ) -> Self {
541 Self {
542 side,
543 model,
544 config,
545 visible_count: Signal::new(usize::MAX),
546 item_count: 0,
547 item_bounds: Rc::new(RefCell::new(Vec::new())),
548 item_ids: Rc::new(RefCell::new(Vec::new())),
549 self_bounds: Rc::new(Cell::new(Rect::ZERO)),
550 side_panel_ids,
551 drop_indicator: Signal::new(None),
552 root: None,
553 }
554 }
555
556 fn effective_item_size(&self) -> IconButtonSize {
562 match self.model.side_rail_size(self.side) {
563 DockRailItemSize::Compact => IconButtonSize::Default,
564 DockRailItemSize::Default | DockRailItemSize::Labeled => self.config.size,
565 }
566 }
567
568 fn effective_item_extent(&self) -> f32 {
569 item_extent(self.effective_item_size())
570 }
571
572 fn item_stride(&self) -> f32 {
576 let mut s = self.effective_item_extent() + RAIL_ITEM_SPACING;
577 if self.model.side_rail_size(self.side).shows_label() {
578 s += LABELED_TITLE_ALLOWANCE;
579 }
580 s
581 }
582
583 fn build_action_group(
589 &self,
590 ctx: &mut BuildContext,
591 placement: DockActionPlacement,
592 ) -> Option<WidgetId> {
593 let actions = self.config.actions_at(placement);
594 if actions.is_empty() {
595 return None;
596 }
597 Some(ctx.add(DockRailActionGroup::new(
598 self.side,
599 placement,
600 actions,
601 self.effective_item_extent(),
602 item_glyph_size(self.effective_item_size()),
603 self.model.side_rail_size(self.side).shows_label(),
604 )))
605 }
606}
607
608impl Widget for DockActivityBar {
609 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
610 let self_id = ctx.self_id();
613 self.model.rail_size_signal(self.side).bind_to(
614 self_id,
615 ctx.binding_registry(),
616 BindingLevel::Rebuild,
617 );
618
619 let bg_color = self
620 .config
621 .background
622 .clone()
623 .unwrap_or_else(|| SurfaceRole::Sunken.into());
624 let bg = ctx.add(RectWidget::new().background(bg_color));
625 let selected = self.model.side_selected_tab_signal(self.side);
626 let visible = self.model.side_visible_signal(self.side);
627 let tabs = self.model.side_tabs(self.side);
628 let extent = self.effective_item_extent();
629 let glyph = item_glyph_size(self.effective_item_size());
630 let labeled = self.model.side_rail_size(self.side).shows_label();
631 let visible_count = self.visible_count.clone();
632
633 self.item_bounds.borrow_mut().clear();
640 self.item_ids.borrow_mut().clear();
641 let mut model_indices: Vec<usize> = Vec::with_capacity(tabs.len());
642 let mut items: Vec<WidgetId> = Vec::with_capacity(tabs.len());
643 let mut pos = 0usize;
644 for (model_i, tab) in tabs.iter().enumerate() {
645 if tab.hidden {
646 continue;
647 }
648 let p = pos;
649 pos += 1;
650 model_indices.push(model_i);
651 let icon = self.model.activity_icon(tab);
654 let label = self.model.activity_label(tab);
655 let id = ctx.add(DockRailItem::new(
656 self.side,
657 model_i,
658 p,
659 tab.id,
660 icon,
661 label,
662 extent,
663 glyph,
664 labeled,
665 selected.clone(),
666 visible.clone(),
667 self.model.clone(),
668 self.item_bounds.clone(),
669 self.item_ids.clone(),
670 self.visible_count.clone(),
671 self.side_panel_ids.clone(),
672 ));
673 self.item_ids.borrow_mut().push((p, id));
675 ctx.visible_when(id, visible_count.map(move |c| p < *c));
676 items.push(id);
677 }
678 self.item_count = pos;
679
680 let mut items_stack = VStack::new().spacing(RAIL_ITEM_SPACING);
687 for id in &items {
688 items_stack = items_stack.add_child(*id);
689 }
690 let items_stack = ctx.add(items_stack);
691 let tab_list = ctx.add(DockRailTabList::new(self.side, items_stack));
692
693 let mut column = VStack::new().spacing(RAIL_ITEM_SPACING);
695 if let Some(top) = &self.config.top_slot {
696 column = column.add_child(ctx.add_boxed((top)()));
697 }
698 if let Some(group) = self.build_action_group(ctx, DockActionPlacement::Start) {
699 column = column.add_child(group);
700 }
701 column = column.add_child(tab_list);
702 if let Some(of_icon) = &self.config.overflow_icon {
705 let total = pos;
706 let trigger = IconButton::new((of_icon)())
707 .size(self.effective_item_size())
708 .tooltip(lit!("More panels"));
709 let overflow = ctx.add(
710 PopoverIconButton::new(trigger)
711 .content(DockOverflowMenu::new(
712 self.side,
713 self.model.clone(),
714 visible_count.clone(),
715 ))
716 .placement(teksilo_core::overlay::OverlayPlacement::TrailingEdge),
717 );
718 ctx.visible_when(overflow, visible_count.map(move |c| *c < total));
719 column = column.add_child(overflow);
720 }
721 if let Some(group) = self.build_action_group(ctx, DockActionPlacement::End) {
722 column = column.add_child(group);
723 }
724 let spacer = ctx.add(Spacer::new());
725 column = column.add_child(spacer);
726 if let Some(group) = self.build_action_group(ctx, DockActionPlacement::Pinned) {
727 column = column.add_child(group);
728 }
729 if let Some(bottom) = &self.config.bottom_slot {
730 let b = ctx.add_boxed((bottom)());
731 column = column.add_child(b);
732 }
733
734 let column_id = ctx.add(column);
735 let padded = ctx.add(Padding::uniform(RAIL_PADDING).child_id(column_id));
736 let indicator = ctx.add(RailDropIndicator::new(self.drop_indicator.clone()));
739 let mut stack = ZStack::new()
740 .add_child(bg)
741 .add_child(padded)
742 .add_child(indicator);
743 if let Some(color) = self.config.divider.clone() {
746 stack = stack.add_child(ctx.add(RailEdgeDivider {
747 side: self.side,
748 color,
749 }));
750 }
751 let root = ctx.add(stack);
752 self.root = Some(root);
753
754 let menu_model = self.model.clone();
758 let menu_side = self.side;
759 let side = self.side;
766 let model = self.model.clone();
767 let item_bounds_hover = self.item_bounds.clone();
768 let item_bounds_drop = self.item_bounds.clone();
769 let self_bounds_hover = self.self_bounds.clone();
770 let self_bounds_drop = self.self_bounds.clone();
771 let indicator_hover = self.drop_indicator.clone();
772 let indicator_leave = self.drop_indicator.clone();
773 let indicator_drop = self.drop_indicator.clone();
774 let visible_count_hover = self.visible_count.clone();
775 let visible_count_drop = self.visible_count.clone();
776 let model_indices_hover = model_indices.clone();
777 let model_indices_drop = model_indices;
778 ctx.apply_self_handlers(
779 HandlerSet::new()
780 .context_menu(move |_pos, _ctx| {
781 Some(Box::new(background_menu(
782 &menu_model,
783 menu_side,
784 DockMenuKind::Rail,
785 )))
786 })
787 .on_drag_hover(move |payload, pos, _ctx| {
788 if dropped_dock_tab(payload).is_none() && dropped_dock_widget(payload).is_none()
789 {
790 indicator_hover.set(None);
791 return DropFeedback::NoFeedback;
792 }
793 let bar = self_bounds_hover.get();
794 let shown = shown_items(
795 &item_bounds_hover,
796 &model_indices_hover,
797 &visible_count_hover,
798 );
799 let (_, line_y) = rail_insertion(pos.y, &shown, bar.y, bar.height);
800 indicator_hover.set(Some(line_y));
801 DropFeedback::InsertionLine {
802 y: line_y,
803 width: bar.width,
804 }
805 })
806 .on_drag_leave(move |_ctx| indicator_leave.set(None))
807 .on_drop(move |payload, pos, ctx| {
808 indicator_drop.set(None);
809 if !model.is_side_enabled(side) {
814 return false;
815 }
816 let bar = self_bounds_drop.get();
817 let shown =
818 shown_items(&item_bounds_drop, &model_indices_drop, &visible_count_drop);
819 let (vpos, _) = rail_insertion(pos.y, &shown, bar.y, bar.height);
820 let at = model_indices_drop
824 .get(vpos)
825 .copied()
826 .unwrap_or_else(|| model.side_append_index(side));
827 if let Some(tab_id) = dropped_dock_tab(&payload) {
828 model.move_tab(tab_id, side, at);
829 model.set_side_visible(side, true);
830 ctx.request_accessibility_update();
831 true
832 } else if let Some(dock_id) = dropped_dock_widget(&payload) {
833 model.promote_to_tab(dock_id, side, at);
834 model.set_side_visible(side, true);
835 ctx.request_accessibility_update();
836 true
837 } else {
838 false
839 }
840 }),
841 );
842
843 vec![root]
844 }
845
846 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
847 self.root
848 .and_then(|id| ctx.child_size(id, proposal))
849 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
850 .into()
851 }
852
853 fn place_children(
854 &self,
855 bounds: Rect,
856 _proposal: SizeProposal,
857 children: &mut [WidgetPlacement],
858 _ctx: &LayoutContext,
859 ) {
860 self.self_bounds.set(bounds);
863 for child in children.iter_mut() {
864 child.origin = bounds.origin();
865 child.size = bounds.size();
866 }
867
868 let stride = self.item_stride();
873 if stride <= 0.0 {
874 return;
875 }
876 let new_visible = shown_capacity(RailCapacity {
877 height: bounds.height,
878 stride,
879 slots: usize::from(self.config.top_slot.is_some())
880 + usize::from(self.config.bottom_slot.is_some()),
881 actions: self.config.actions.len(),
882 total: self.item_count,
883 has_overflow_trigger: self.config.overflow_icon.is_some(),
884 });
885 if self.visible_count.get() != new_visible {
886 self.visible_count.set(new_visible);
887 }
888 }
889
890 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
891 builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
900 }
901
902 fn children(&self) -> Vec<WidgetId> {
903 self.root.into_iter().collect()
904 }
905}
906
907#[derive(Debug, Clone, Copy, PartialEq)]
909struct RailCapacity {
910 height: f32,
912 stride: f32,
915 slots: usize,
920 actions: usize,
924 total: usize,
926 has_overflow_trigger: bool,
929}
930
931fn shown_capacity(c: RailCapacity) -> usize {
939 if c.stride <= 0.0 {
940 return c.total;
941 }
942 let reserve = RAIL_PADDING * 2.0 + (c.slots + c.actions) as f32 * c.stride;
943 let avail = (c.height - reserve).max(0.0);
944 let fit = (avail / c.stride).floor() as usize;
945 if c.total <= fit {
946 c.total
947 } else if c.has_overflow_trigger {
948 fit.saturating_sub(1)
950 } else {
951 fit
952 }
953}
954
955fn shown_items(
959 bounds: &RailItemBounds,
960 model_indices: &[usize],
961 visible_count: &Signal<usize>,
962) -> Vec<(usize, Rect)> {
963 let shown = model_indices.len().min(visible_count.get());
964 let mut out: Vec<(usize, Rect)> = bounds
965 .borrow()
966 .iter()
967 .filter(|(p, _)| *p < shown)
968 .copied()
969 .collect();
970 out.sort_by_key(|(p, _)| *p);
971 out
972}
973
974enum RailNav {
976 Prev,
977 Next,
978 First,
979 Last,
980}
981
982fn shown_rail_count(item_ids: &RailItemIds, visible_count: &Signal<usize>) -> usize {
986 let count = visible_count.get();
987 item_ids.borrow().iter().filter(|(p, _)| *p < count).count()
988}
989
990fn rail_focus_target(
997 item_ids: &RailItemIds,
998 visible_count: &Signal<usize>,
999 current_pos: usize,
1000 nav: RailNav,
1001) -> Option<WidgetId> {
1002 let count = visible_count.get();
1003 let mut shown: Vec<(usize, WidgetId)> = item_ids
1004 .borrow()
1005 .iter()
1006 .filter(|(p, _)| *p < count)
1007 .copied()
1008 .collect();
1009 shown.sort_by_key(|(p, _)| *p);
1010 if shown.is_empty() {
1011 return None;
1012 }
1013 let cur = shown.iter().position(|(p, _)| *p == current_pos)?;
1014 let target = match nav {
1015 RailNav::Prev => (cur + shown.len() - 1) % shown.len(),
1016 RailNav::Next => (cur + 1) % shown.len(),
1017 RailNav::First => 0,
1018 RailNav::Last => shown.len() - 1,
1019 };
1020 Some(shown[target].1)
1021}
1022
1023fn overflow_shown_count(row_ids: &RailItemIds, visible_count: &Signal<usize>) -> usize {
1027 let count = visible_count.get();
1028 row_ids.borrow().iter().filter(|(p, _)| *p >= count).count()
1029}
1030
1031fn overflow_focus_target(
1035 row_ids: &RailItemIds,
1036 visible_count: &Signal<usize>,
1037 current_pos: usize,
1038 nav: RailNav,
1039) -> Option<WidgetId> {
1040 let count = visible_count.get();
1041 let mut shown: Vec<(usize, WidgetId)> = row_ids
1042 .borrow()
1043 .iter()
1044 .filter(|(p, _)| *p >= count)
1045 .copied()
1046 .collect();
1047 shown.sort_by_key(|(p, _)| *p);
1048 if shown.is_empty() {
1049 return None;
1050 }
1051 let cur = shown.iter().position(|(p, _)| *p == current_pos)?;
1052 let target = match nav {
1053 RailNav::Prev => (cur + shown.len() - 1) % shown.len(),
1054 RailNav::Next => (cur + 1) % shown.len(),
1055 RailNav::First => 0,
1056 RailNav::Last => shown.len() - 1,
1057 };
1058 Some(shown[target].1)
1059}
1060
1061fn rail_insertion(
1065 local_y: f32,
1066 shown: &[(usize, Rect)],
1067 bar_origin_y: f32,
1068 bar_height: f32,
1069) -> (usize, f32) {
1070 if shown.is_empty() {
1071 return (0, (RAIL_PADDING).min(bar_height));
1072 }
1073 let mut vpos = shown.len();
1076 for (i, (_, b)) in shown.iter().enumerate() {
1077 let center = (b.y + b.height * 0.5) - bar_origin_y;
1078 if local_y < center {
1079 vpos = i;
1080 break;
1081 }
1082 }
1083 let line_y = if vpos == 0 {
1084 let top = shown[0].1.y - bar_origin_y;
1085 (top - RAIL_ITEM_SPACING * 0.5).max(0.0)
1086 } else if vpos >= shown.len() {
1087 let last = &shown[shown.len() - 1].1;
1088 (last.y + last.height - bar_origin_y + RAIL_ITEM_SPACING * 0.5).min(bar_height)
1089 } else {
1090 let prev = &shown[vpos - 1].1;
1091 let next = &shown[vpos].1;
1092 let prev_bottom = prev.y + prev.height - bar_origin_y;
1093 let next_top = next.y - bar_origin_y;
1094 (prev_bottom + next_top) * 0.5
1095 };
1096 (vpos, line_y.clamp(0.0, bar_height))
1097}
1098
1099#[derive(Debug)]
1115pub(crate) struct DockRailTabList {
1116 side: DockSide,
1117 stack: WidgetId,
1118}
1119
1120impl DockRailTabList {
1121 fn new(side: DockSide, stack: WidgetId) -> Self {
1122 Self { side, stack }
1123 }
1124}
1125
1126impl Widget for DockRailTabList {
1127 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
1128 ctx.child_size(self.stack, proposal)
1129 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
1130 .into()
1131 }
1132
1133 fn place_children(
1134 &self,
1135 bounds: Rect,
1136 _proposal: SizeProposal,
1137 children: &mut [WidgetPlacement],
1138 _ctx: &LayoutContext,
1139 ) {
1140 for child in children.iter_mut() {
1141 child.origin = bounds.origin();
1142 child.size = bounds.size();
1143 }
1144 }
1145
1146 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1147 use teksilo_core::accesskit::{Orientation as A11yOrientation, Role};
1148 builder.set_role(Role::TabList);
1149 builder.set_name(super::a11y::rail_label(self.side).resolve_now());
1150 builder.set_orientation(A11yOrientation::Vertical);
1151 }
1152
1153 fn children(&self) -> Vec<WidgetId> {
1154 vec![self.stack]
1155 }
1156}
1157
1158pub(crate) struct DockRailActionGroup {
1176 side: DockSide,
1177 placement: DockActionPlacement,
1178 actions: Vec<DockAction>,
1179 extent: f32,
1180 glyph: f32,
1181 labeled: bool,
1182 roving: Signal<usize>,
1186 item_ids: Rc<RefCell<Vec<WidgetId>>>,
1187 root: Option<WidgetId>,
1188}
1189
1190impl std::fmt::Debug for DockRailActionGroup {
1191 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1192 f.debug_struct("DockRailActionGroup")
1193 .field("side", &self.side)
1194 .field("placement", &self.placement)
1195 .field("actions", &self.actions.len())
1196 .finish()
1197 }
1198}
1199
1200impl DockRailActionGroup {
1201 fn new(
1202 side: DockSide,
1203 placement: DockActionPlacement,
1204 actions: Vec<DockAction>,
1205 extent: f32,
1206 glyph: f32,
1207 labeled: bool,
1208 ) -> Self {
1209 Self {
1210 side,
1211 placement,
1212 actions,
1213 extent,
1214 glyph,
1215 labeled,
1216 roving: Signal::new(0),
1217 item_ids: Rc::new(RefCell::new(Vec::new())),
1218 root: None,
1219 }
1220 }
1221}
1222
1223impl Widget for DockRailActionGroup {
1224 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1225 self.item_ids.borrow_mut().clear();
1226 let mut stack = VStack::new().spacing(RAIL_ITEM_SPACING);
1227 let mut ids = Vec::with_capacity(self.actions.len());
1228 for (i, action) in self.actions.iter().enumerate() {
1229 let id = ctx.add(DockRailActionItem::new(
1230 action.clone(),
1231 i,
1232 self.extent,
1233 self.glyph,
1234 self.labeled,
1235 self.roving.clone(),
1236 self.item_ids.clone(),
1237 ));
1238 ids.push(id);
1239 stack = stack.add_child(id);
1240 }
1241 *self.item_ids.borrow_mut() = ids;
1242 if self.roving.get() >= self.actions.len() {
1246 self.roving.set(0);
1247 }
1248 let root = ctx.add(stack);
1249 self.root = Some(root);
1250 vec![root]
1251 }
1252
1253 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
1254 self.root
1255 .and_then(|id| ctx.child_size(id, proposal))
1256 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
1257 .into()
1258 }
1259
1260 fn place_children(
1261 &self,
1262 bounds: Rect,
1263 _proposal: SizeProposal,
1264 children: &mut [WidgetPlacement],
1265 _ctx: &LayoutContext,
1266 ) {
1267 for child in children.iter_mut() {
1268 child.origin = bounds.origin();
1269 child.size = bounds.size();
1270 }
1271 }
1272
1273 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1274 use teksilo_core::accesskit::{Orientation as A11yOrientation, Role};
1275 builder.set_role(Role::Toolbar);
1276 builder.set_name(super::a11y::rail_actions_label(self.side, self.placement).resolve_now());
1277 builder.set_orientation(A11yOrientation::Vertical);
1278 }
1279
1280 fn children(&self) -> Vec<WidgetId> {
1281 self.root.into_iter().collect()
1282 }
1283}
1284
1285struct DockRailActionItem {
1300 action: DockAction,
1301 index: usize,
1302 extent: f32,
1303 glyph: f32,
1304 labeled: bool,
1305 roving: Signal<usize>,
1306 siblings: Rc<RefCell<Vec<WidgetId>>>,
1307 focused: Signal<bool>,
1308 root: Option<WidgetId>,
1309}
1310
1311impl std::fmt::Debug for DockRailActionItem {
1312 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1313 f.debug_struct("DockRailActionItem")
1314 .field("index", &self.index)
1315 .finish()
1316 }
1317}
1318
1319impl DockRailActionItem {
1320 fn new(
1321 action: DockAction,
1322 index: usize,
1323 extent: f32,
1324 glyph: f32,
1325 labeled: bool,
1326 roving: Signal<usize>,
1327 siblings: Rc<RefCell<Vec<WidgetId>>>,
1328 ) -> Self {
1329 Self {
1330 action,
1331 index,
1332 extent,
1333 glyph,
1334 labeled,
1335 roving,
1336 siblings,
1337 focused: Signal::new(false),
1338 root: None,
1339 }
1340 }
1341}
1342
1343impl Widget for DockRailActionItem {
1344 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1345 let self_id = ctx.self_id();
1346 let enabled = self.action.enabled.as_signal();
1347 enabled.bind_to(self_id, ctx.binding_registry(), BindingLevel::RepaintOnly);
1348
1349 let toggled = self
1353 .action
1354 .toggled
1355 .clone()
1356 .unwrap_or_else(|| Signal::new(false));
1357 toggled.bind_to(self_id, ctx.binding_registry(), BindingLevel::RepaintOnly);
1358 let bg = toggled.zip(&ctx.window_active_signal()).map(|(t, win)| {
1359 if *t {
1360 if *win {
1361 SurfaceRole::Selected
1362 } else {
1363 SurfaceRole::SelectedInactive
1364 }
1365 } else {
1366 SurfaceRole::Transparent
1367 }
1368 });
1369 let ring = self.focused.and(&ctx.focus_visible());
1370 let focus_ring_width = ctx.theme().shape.focus_ring_width;
1371 let border_color: ColorProp = ring
1372 .map(|f| {
1373 if *f {
1374 BorderRole::Focused
1375 } else {
1376 BorderRole::Transparent
1377 }
1378 })
1379 .into();
1380 let border_width = ring.map(move |f| if *f { focus_ring_width } else { 0.0 });
1381 let bg_rect = ctx.add(
1382 RectWidget::new()
1383 .background(bg)
1384 .border_color(border_color)
1385 .border_width(border_width)
1386 .corner_radius(CornerRadius::uniform(ICON_BUTTON_CORNER_RADIUS)),
1387 );
1388
1389 let glyph_color: ColorProp = enabled
1390 .map(|e| {
1391 if *e {
1392 TextRole::Primary
1393 } else {
1394 TextRole::Disabled
1395 }
1396 })
1397 .into();
1398 let icon = ctx.add(
1399 (self.action.icon)()
1400 .icon_size(self.glyph)
1401 .color(glyph_color.clone()),
1402 );
1403 let centered = ctx.add(Center::new().child_id(icon));
1404 let icon_box = ctx.add(
1405 FixedSize::new()
1406 .width(self.extent)
1407 .height(self.extent)
1408 .child_id(centered),
1409 );
1410
1411 let content = if self.labeled {
1412 let label = ctx.add(RotatedLabel::new(
1413 self.action.label.clone(),
1414 Signal::new(TextRole::Secondary),
1415 ));
1416 let stack = ctx.add(
1417 VStack::new()
1418 .alignment(HAlignment::Center)
1419 .spacing(2.0)
1420 .add_child(label)
1421 .add_child(icon_box),
1422 );
1423 ctx.add(Padding::new(LABELED_TOP_MARGIN, 0.0, 0.0, 0.0).child_id(stack))
1424 } else {
1425 icon_box
1426 };
1427 let root = ctx.add(ZStack::new().add_child(bg_rect).add_child(content));
1428 self.root = Some(root);
1429
1430 if !self.labeled {
1431 let text = self
1434 .action
1435 .tooltip
1436 .clone()
1437 .unwrap_or_else(|| self.action.label.clone());
1438 let delay = ctx.theme().motion.tooltip_delay;
1439 crate::tooltip::attach_plain_tooltip_with_placement(
1440 ctx,
1441 root,
1442 text,
1443 delay,
1444 crate::tooltip::TooltipPlacement::Side,
1445 );
1446 }
1447
1448 let activate: Rc<dyn Fn(&mut EventContext)> = {
1451 let on_activate = self.action.on_activate.clone();
1452 let enabled = enabled.clone();
1453 let roving = self.roving.clone();
1454 let index = self.index;
1455 Rc::new(move |ctx: &mut EventContext| {
1456 if !enabled.get() {
1457 return;
1458 }
1459 roving.set(index);
1460 (on_activate)(ctx);
1461 })
1462 };
1463
1464 let index = self.index;
1466 ctx.set_tab_stop(self_id, self.roving.map(move |r| *r == index));
1467 let focused_sig = self.focused.clone();
1483 ctx.apply_self_handlers(
1484 HandlerSet::new()
1485 .on_tap({
1486 let activate = activate.clone();
1487 move |_e, ctx| activate(ctx)
1488 })
1489 .on_focus(move |gained, _ctx| focused_sig.set(gained))
1490 .on_key({
1491 let activate = activate.clone();
1492 let siblings = self.siblings.clone();
1493 let roving = self.roving.clone();
1494 let index = self.index;
1495 move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
1496 let WidgetEvent::KeyDown { key, .. } = event else {
1497 return EventResponse::Ignored;
1498 };
1499 let ids = siblings.borrow();
1500 if ids.is_empty() {
1501 return EventResponse::Ignored;
1502 }
1503 let next = match key {
1504 Key::ArrowUp | Key::ArrowLeft => (index + ids.len() - 1) % ids.len(),
1505 Key::ArrowDown | Key::ArrowRight => (index + 1) % ids.len(),
1506 Key::Home => 0,
1507 Key::End => ids.len() - 1,
1508 Key::Enter | Key::Space => {
1509 drop(ids);
1510 activate(ctx);
1511 return EventResponse::Handled;
1512 }
1513 _ => return EventResponse::Ignored,
1514 };
1515 let target = ids[next];
1516 drop(ids);
1517 roving.set(next);
1518 ctx.request_focus(target);
1519 EventResponse::Handled
1520 }
1521 })
1522 .on_access_action({
1523 let activate = activate.clone();
1524 move |action: teksilo_core::accesskit::Action, ctx: &mut EventContext| {
1525 if action == teksilo_core::accesskit::Action::Click {
1526 activate(ctx);
1527 EventResponse::Handled
1528 } else {
1529 EventResponse::Ignored
1530 }
1531 }
1532 })
1533 .focusable(true)
1534 .cursor(CursorIcon::Pointer),
1535 );
1536 vec![root]
1537 }
1538
1539 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
1540 self.root
1541 .and_then(|id| ctx.child_size(id, proposal))
1542 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
1543 .into()
1544 }
1545
1546 fn place_children(
1547 &self,
1548 bounds: Rect,
1549 _proposal: SizeProposal,
1550 children: &mut [WidgetPlacement],
1551 _ctx: &LayoutContext,
1552 ) {
1553 for child in children.iter_mut() {
1554 child.origin = bounds.origin();
1555 child.size = bounds.size();
1556 }
1557 }
1558
1559 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1560 use teksilo_core::accesskit::{Action, Role};
1561 builder.set_role(Role::Button);
1564 builder.set_name(self.action.label.resolve_now());
1565 builder.add_action(Action::Focus);
1566 if self.action.enabled.get() {
1570 builder.add_action(Action::Click);
1571 } else {
1572 builder.set_disabled();
1573 }
1574 builder.set_position_in_set(self.index + 1);
1575 builder.set_size_of_set(self.siblings.borrow().len());
1576 if let Some(t) = &self.action.toggled {
1578 builder.set_toggled(t.get());
1579 }
1580 }
1581
1582 fn children(&self) -> Vec<WidgetId> {
1583 self.root.into_iter().collect()
1584 }
1585}
1586
1587struct RailDropIndicator {
1597 y: Signal<Option<f32>>,
1598 color: ColorProp,
1599}
1600
1601impl std::fmt::Debug for RailDropIndicator {
1602 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1603 f.debug_struct("RailDropIndicator").finish()
1604 }
1605}
1606
1607impl RailDropIndicator {
1608 fn new(y: Signal<Option<f32>>) -> Self {
1609 Self {
1610 y,
1611 color: ColorProp::from(BorderRole::Accent),
1612 }
1613 }
1614}
1615
1616impl Widget for RailDropIndicator {
1617 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1618 self.y.bind_to(
1619 ctx.self_id(),
1620 ctx.binding_registry(),
1621 BindingLevel::RepaintOnly,
1622 );
1623 ctx.apply_self_handlers(HandlerSet::new().event_pass_through(true));
1624 vec![]
1625 }
1626
1627 fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
1628 proposal.resolve(0.0, 0.0).into()
1629 }
1630
1631 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
1632 let Some(y) = self.y.get() else {
1633 return;
1634 };
1635 let color = self.color.resolve(ctx.theme, true);
1636 let t = 2.0;
1637 let yy = bounds.y + y - t * 0.5;
1638 let x = bounds.x + RAIL_PADDING;
1641 let w = (bounds.width - RAIL_PADDING * 2.0).max(0.0);
1642 canvas.fill_rect(Rect::new(x, yy, w, t), color);
1643 }
1644
1645 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1646 builder.set_hidden();
1647 }
1648}
1649
1650struct RailEdgeDivider {
1661 side: DockSide,
1662 color: ColorProp,
1663}
1664
1665impl std::fmt::Debug for RailEdgeDivider {
1666 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1667 f.debug_struct("RailEdgeDivider").finish()
1668 }
1669}
1670
1671impl Widget for RailEdgeDivider {
1672 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1673 ctx.apply_self_handlers(HandlerSet::new().event_pass_through(true));
1674 vec![]
1675 }
1676
1677 fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
1678 proposal.resolve(0.0, 0.0).into()
1679 }
1680
1681 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
1682 let rtl = matches!(
1683 ctx.layout_direction,
1684 teksilo_core::environment::LayoutDirection::RightToLeft
1685 );
1686 let content_on_right = match self.side {
1691 DockSide::Trailing => rtl,
1692 _ => !rtl,
1693 };
1694 let t = 1.0;
1695 let x = if content_on_right {
1696 bounds.x + bounds.width - t
1697 } else {
1698 bounds.x
1699 };
1700 let color = self.color.resolve(ctx.theme, true);
1701 canvas.fill_rect(Rect::new(x, bounds.y, t, bounds.height), color);
1702 }
1703
1704 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1705 builder.set_hidden();
1706 }
1707}
1708
1709#[derive(Debug)]
1717struct DockOverflowMenu {
1718 side: DockSide,
1719 model: DockingModel,
1720 visible_count: Signal<usize>,
1721 row_ids: RailItemIds,
1724 root: Option<WidgetId>,
1725}
1726
1727impl DockOverflowMenu {
1728 fn new(side: DockSide, model: DockingModel, visible_count: Signal<usize>) -> Self {
1729 Self {
1730 side,
1731 model,
1732 visible_count,
1733 row_ids: Rc::new(RefCell::new(Vec::new())),
1734 root: None,
1735 }
1736 }
1737}
1738
1739impl Widget for DockOverflowMenu {
1740 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1741 let tabs = self.model.side_tabs(self.side);
1742 let mut column = VStack::new().spacing(2.0);
1743 self.row_ids.borrow_mut().clear();
1744 let mut pos = 0usize;
1748 for (model_i, tab) in tabs.iter().enumerate() {
1749 if tab.hidden {
1750 continue;
1751 }
1752 let p = pos;
1753 pos += 1;
1754 let label = self.model.activity_label(tab);
1755 let row = ctx.add(DockOverflowRow::new(
1756 self.side,
1757 model_i,
1758 p,
1759 tab.id,
1760 label,
1761 self.model.clone(),
1762 self.row_ids.clone(),
1763 self.visible_count.clone(),
1764 ));
1765 self.row_ids.borrow_mut().push((p, row));
1766 ctx.visible_when(row, self.visible_count.map(move |c| p >= *c));
1767 column = column.add_child(row);
1768 }
1769 let column_id = ctx.add(column);
1770 let root = ctx.add(Padding::uniform(4.0).child_id(column_id));
1771 self.root = Some(root);
1772 vec![root]
1773 }
1774
1775 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
1776 self.root
1777 .and_then(|id| ctx.child_size(id, proposal))
1778 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
1779 .into()
1780 }
1781
1782 fn place_children(
1783 &self,
1784 bounds: Rect,
1785 _proposal: SizeProposal,
1786 children: &mut [WidgetPlacement],
1787 _ctx: &LayoutContext,
1788 ) {
1789 for child in children.iter_mut() {
1790 child.origin = bounds.origin();
1791 child.size = bounds.size();
1792 }
1793 }
1794
1795 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1796 use teksilo_core::accesskit::{Orientation, Role};
1797 builder.set_role(Role::Menu);
1798 builder.set_orientation(Orientation::Vertical);
1799 }
1800
1801 fn children(&self) -> Vec<WidgetId> {
1802 self.root.into_iter().collect()
1803 }
1804}
1805
1806struct DockRailItem {
1811 side: DockSide,
1812 index: usize,
1813 pos: usize,
1816 tab_id: DockTabId,
1817 icon: Option<DockIconFactory>,
1818 label: LocalizedString,
1819 extent: f32,
1820 glyph: f32,
1823 labeled: bool,
1826 selected: Signal<usize>,
1827 visible: Signal<bool>,
1828 model: DockingModel,
1829 bounds_sink: RailItemBounds,
1832 item_ids: RailItemIds,
1835 visible_count: Signal<usize>,
1836 side_panel_ids: Rc<RefCell<HashMap<DockSide, WidgetId>>>,
1838 focused: Signal<bool>,
1841 root: Option<WidgetId>,
1842}
1843
1844impl std::fmt::Debug for DockRailItem {
1845 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1846 f.debug_struct("DockRailItem")
1847 .field("index", &self.index)
1848 .finish()
1849 }
1850}
1851
1852impl DockRailItem {
1853 #[allow(clippy::too_many_arguments)]
1854 fn new(
1855 side: DockSide,
1856 index: usize,
1857 pos: usize,
1858 tab_id: DockTabId,
1859 icon: Option<DockIconFactory>,
1860 label: LocalizedString,
1861 extent: f32,
1862 glyph: f32,
1863 labeled: bool,
1864 selected: Signal<usize>,
1865 visible: Signal<bool>,
1866 model: DockingModel,
1867 bounds_sink: RailItemBounds,
1868 item_ids: RailItemIds,
1869 visible_count: Signal<usize>,
1870 side_panel_ids: Rc<RefCell<HashMap<DockSide, WidgetId>>>,
1871 ) -> Self {
1872 Self {
1873 side,
1874 index,
1875 pos,
1876 tab_id,
1877 icon,
1878 label,
1879 extent,
1880 glyph,
1881 labeled,
1882 selected,
1883 visible,
1884 model,
1885 bounds_sink,
1886 item_ids,
1887 visible_count,
1888 side_panel_ids,
1889 focused: Signal::new(false),
1890 root: None,
1891 }
1892 }
1893}
1894
1895impl Widget for DockRailItem {
1896 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1897 let idx = self.index;
1898 let active = self
1899 .selected
1900 .zip(&self.visible)
1901 .map(move |(s, v)| *s == idx && *v);
1902 let bg = active.zip(&ctx.window_active_signal()).map(|(a, win)| {
1914 if *a {
1915 if *win {
1916 SurfaceRole::Selected
1917 } else {
1918 SurfaceRole::SelectedInactive
1919 }
1920 } else {
1921 SurfaceRole::Transparent
1922 }
1923 });
1924 let ring = self.focused.and(&ctx.focus_visible());
1929 let focus_ring_width = ctx.theme().shape.focus_ring_width;
1930 let border_color: ColorProp = ring
1931 .map(|f| {
1932 if *f {
1933 BorderRole::Focused
1934 } else {
1935 BorderRole::Transparent
1936 }
1937 })
1938 .into();
1939 let border_width = ring.map(move |f| if *f { focus_ring_width } else { 0.0 });
1940 let bg_rect = ctx.add(
1943 RectWidget::new()
1944 .background(bg)
1945 .border_color(border_color)
1946 .border_width(border_width)
1947 .corner_radius(CornerRadius::uniform(ICON_BUTTON_CORNER_RADIUS)),
1948 );
1949
1950 let glyph = if let Some(icon) = self.icon.take() {
1951 ctx.add((icon)().icon_size(self.glyph))
1955 } else {
1956 let s = self.label.resolve_now();
1957 let ch: String = s.chars().take(1).collect();
1958 ctx.add(
1959 TextWidget::new(lit!(ch))
1960 .style(TextStyleRole::BodyBold)
1961 .color(TextRole::Primary),
1962 )
1963 };
1964 let centered = ctx.add(Center::new().child_id(glyph));
1965 let icon_box = ctx.add(
1966 FixedSize::new()
1967 .width(self.extent)
1968 .height(self.extent)
1969 .child_id(centered),
1970 );
1971
1972 let content = if self.labeled {
1976 let label = ctx.add(RotatedLabel::new(
1977 self.label.clone(),
1978 Signal::new(TextRole::Secondary),
1979 ));
1980 let stack = ctx.add(
1981 VStack::new()
1982 .alignment(HAlignment::Center)
1983 .spacing(2.0)
1984 .add_child(label)
1985 .add_child(icon_box),
1986 );
1987 ctx.add(Padding::new(LABELED_TOP_MARGIN, 0.0, 0.0, 0.0).child_id(stack))
1990 } else {
1991 icon_box
1992 };
1993 let root = ctx.add(ZStack::new().add_child(bg_rect).add_child(content));
1994 self.root = Some(root);
1995
1996 if !self.labeled {
1997 let delay = ctx.theme().motion.tooltip_delay;
2001 crate::tooltip::attach_plain_tooltip_with_placement(
2002 ctx,
2003 root,
2004 self.label.clone(),
2005 delay,
2006 crate::tooltip::TooltipPlacement::Side,
2007 );
2008 }
2009
2010 let self_id = ctx.self_id();
2011 let policy = self.model.policy();
2012 let side = self.side;
2013 let tab_id = self.tab_id;
2014 let menu_model = self.model.clone();
2015 let allow_collapse = policy.allow_side_collapse;
2016
2017 let activate: Rc<dyn Fn(&mut EventContext)> = {
2023 let model = self.model.clone();
2024 let selected = self.selected.clone();
2025 let visible = self.visible.clone();
2026 Rc::new(move |_ctx: &mut EventContext| {
2027 if selected.get() == idx && visible.get() {
2028 if allow_collapse {
2029 model.set_side_visible(side, false);
2030 }
2031 } else {
2032 model.select_tab(side, idx);
2033 model.set_side_visible(side, true);
2034 }
2035 })
2036 };
2037
2038 let focused_sig = self.focused.clone();
2040 ctx.set_tab_stop(self_id, self.selected.map(move |s| *s == idx));
2044
2045 let mut handlers = HandlerSet::new()
2046 .on_tap({
2047 let activate = activate.clone();
2048 move |_e, ctx| activate(ctx)
2049 })
2050 .on_focus(move |gained, _ctx| focused_sig.set(gained))
2051 .on_key({
2052 let activate = activate.clone();
2053 let item_ids = self.item_ids.clone();
2054 let visible_count = self.visible_count.clone();
2055 let pos = self.pos;
2056 move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
2057 let WidgetEvent::KeyDown { key, .. } = event else {
2058 return EventResponse::Ignored;
2059 };
2060 let nav = match key {
2061 Key::ArrowUp | Key::ArrowLeft => RailNav::Prev,
2062 Key::ArrowDown | Key::ArrowRight => RailNav::Next,
2063 Key::Home => RailNav::First,
2064 Key::End => RailNav::Last,
2065 Key::Enter | Key::Space => {
2066 activate(ctx);
2069 return EventResponse::Handled;
2070 }
2071 _ => return EventResponse::Ignored,
2072 };
2073 if let Some(target) = rail_focus_target(&item_ids, &visible_count, pos, nav) {
2074 ctx.request_focus(target);
2075 EventResponse::Handled
2076 } else {
2077 EventResponse::Ignored
2078 }
2079 }
2080 })
2081 .on_access_action({
2082 let activate = activate.clone();
2083 move |action: teksilo_core::accesskit::Action, ctx: &mut EventContext| {
2084 if action == teksilo_core::accesskit::Action::Click {
2085 activate(ctx);
2086 EventResponse::Handled
2087 } else {
2088 EventResponse::Ignored
2089 }
2090 }
2091 });
2092 if policy.allow_activity_drag {
2094 handlers = handlers.on_drag(move |phase, ctx| {
2095 if let DragPhase::Started { .. } = phase {
2096 ctx.start_drag(
2097 self_id,
2098 DragPayload::typed(DockTabDragData {
2099 tab_id,
2100 source_side: side,
2101 }),
2102 );
2103 }
2104 });
2105 }
2106 handlers = handlers
2107 .context_menu(move |_pos, _ctx| {
2108 Some(Box::new(activity_context_menu(
2109 &menu_model,
2110 side,
2111 tab_id,
2112 DockMenuKind::Rail,
2113 )))
2114 })
2115 .focusable(true)
2116 .cursor(CursorIcon::Pointer);
2117 ctx.apply_self_handlers(handlers);
2118 vec![root]
2119 }
2120
2121 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
2122 self.root
2123 .and_then(|id| ctx.child_size(id, proposal))
2124 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
2125 .into()
2126 }
2127
2128 fn place_children(
2129 &self,
2130 bounds: Rect,
2131 _proposal: SizeProposal,
2132 children: &mut [WidgetPlacement],
2133 _ctx: &LayoutContext,
2134 ) {
2135 {
2138 let mut sink = self.bounds_sink.borrow_mut();
2139 if let Some(slot) = sink.iter_mut().find(|(p, _)| *p == self.pos) {
2140 slot.1 = bounds;
2141 } else {
2142 sink.push((self.pos, bounds));
2143 }
2144 }
2145 for child in children.iter_mut() {
2146 child.origin = bounds.origin();
2147 child.size = bounds.size();
2148 }
2149 }
2150
2151 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2152 use teksilo_core::accesskit::{Action, Role};
2153 builder.set_role(Role::Tab);
2154 builder.set_name(self.label.resolve_now());
2155 let is_selected = self.selected.get() == self.index;
2156 builder.set_selected(is_selected && self.visible.get());
2157 builder.add_action(Action::Focus);
2158 builder.add_action(Action::Click);
2159 builder.set_position_in_set(self.pos + 1);
2164 builder.set_size_of_set(shown_rail_count(&self.item_ids, &self.visible_count));
2165 if is_selected {
2169 builder.set_expanded(self.visible.get());
2170 }
2171 if self.visible.get()
2175 && let Some(&panel_id) = self.side_panel_ids.borrow().get(&self.side)
2176 {
2177 builder.push_controlled(widget_id_to_node_id(panel_id));
2178 }
2179 }
2180
2181 fn children(&self) -> Vec<WidgetId> {
2182 self.root.into_iter().collect()
2183 }
2184}
2185
2186#[derive(Debug)]
2191struct DockOverflowRow {
2192 side: DockSide,
2193 index: usize,
2194 pos: usize,
2197 tab_id: DockTabId,
2198 label: LocalizedString,
2199 model: DockingModel,
2200 row_ids: RailItemIds,
2203 visible_count: Signal<usize>,
2204 focused: Signal<bool>,
2206 root: Option<WidgetId>,
2207}
2208
2209impl DockOverflowRow {
2210 #[allow(clippy::too_many_arguments)]
2211 fn new(
2212 side: DockSide,
2213 index: usize,
2214 pos: usize,
2215 tab_id: DockTabId,
2216 label: LocalizedString,
2217 model: DockingModel,
2218 row_ids: RailItemIds,
2219 visible_count: Signal<usize>,
2220 ) -> Self {
2221 Self {
2222 side,
2223 index,
2224 pos,
2225 tab_id,
2226 label,
2227 model,
2228 row_ids,
2229 visible_count,
2230 focused: Signal::new(false),
2231 root: None,
2232 }
2233 }
2234}
2235
2236impl Widget for DockOverflowRow {
2237 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
2238 let label = ctx.add(
2239 TextWidget::new(self.label.clone())
2240 .style(TextStyleRole::Body)
2241 .color(TextRole::Primary)
2242 .single_line(),
2243 );
2244 let spacer = ctx.add(Spacer::new());
2245 let row = ctx.add(
2246 HStack::new()
2247 .spacing(8.0)
2248 .add_child(label)
2249 .add_child(spacer),
2250 );
2251 let content = ctx.add(Padding::symmetric(6.0, 10.0).child_id(row));
2252
2253 let ring = self.focused.and(&ctx.focus_visible());
2257 let focus_ring_width = ctx.theme().shape.focus_ring_width;
2258 let bg_role: ColorProp = self
2259 .focused
2260 .map(|f| {
2261 if *f {
2262 SurfaceRole::Hover
2263 } else {
2264 SurfaceRole::Transparent
2265 }
2266 })
2267 .into();
2268 let border_color: ColorProp = ring
2269 .map(|f| {
2270 if *f {
2271 BorderRole::Focused
2272 } else {
2273 BorderRole::Transparent
2274 }
2275 })
2276 .into();
2277 let border_width = ring.map(move |f| if *f { focus_ring_width } else { 0.0 });
2278 let bg_rect = ctx.add(
2279 RectWidget::new()
2280 .background(bg_role)
2281 .border_color(border_color)
2282 .border_width(border_width)
2283 .corner_radius(CornerRadius::uniform(ICON_BUTTON_CORNER_RADIUS)),
2284 );
2285 let root = ctx.add(ZStack::new().add_child(bg_rect).add_child(content));
2286 self.root = Some(root);
2287
2288 let activate: Rc<dyn Fn(&mut EventContext)> = {
2291 let model = self.model.clone();
2292 let side = self.side;
2293 let idx = self.index;
2294 Rc::new(move |_ctx: &mut EventContext| {
2295 model.select_tab(side, idx);
2296 model.set_side_visible(side, true);
2297 })
2298 };
2299 let focused_sig = self.focused.clone();
2300 ctx.apply_self_handlers(
2301 HandlerSet::new()
2302 .on_tap({
2303 let activate = activate.clone();
2304 move |_e, ctx| activate(ctx)
2305 })
2306 .on_focus(move |gained, _ctx| focused_sig.set(gained))
2307 .on_key({
2308 let activate = activate.clone();
2309 let row_ids = self.row_ids.clone();
2310 let visible_count = self.visible_count.clone();
2311 let pos = self.pos;
2312 move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
2313 let WidgetEvent::KeyDown { key, .. } = event else {
2314 return EventResponse::Ignored;
2315 };
2316 let nav = match key {
2317 Key::ArrowUp | Key::ArrowLeft => RailNav::Prev,
2318 Key::ArrowDown | Key::ArrowRight => RailNav::Next,
2319 Key::Home => RailNav::First,
2320 Key::End => RailNav::Last,
2321 Key::Enter | Key::Space => {
2322 activate(ctx);
2323 return EventResponse::Handled;
2324 }
2325 _ => return EventResponse::Ignored,
2326 };
2327 if let Some(target) =
2328 overflow_focus_target(&row_ids, &visible_count, pos, nav)
2329 {
2330 ctx.request_focus(target);
2331 EventResponse::Handled
2332 } else {
2333 EventResponse::Ignored
2334 }
2335 }
2336 })
2337 .on_access_action({
2338 let activate = activate.clone();
2339 move |action: teksilo_core::accesskit::Action, ctx: &mut EventContext| {
2340 if action == teksilo_core::accesskit::Action::Click {
2341 activate(ctx);
2342 EventResponse::Handled
2343 } else {
2344 EventResponse::Ignored
2345 }
2346 }
2347 })
2348 .focusable(true)
2349 .cursor(CursorIcon::Pointer),
2350 );
2351 vec![root]
2352 }
2353
2354 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
2355 self.root
2356 .and_then(|id| ctx.child_size(id, proposal))
2357 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
2358 .into()
2359 }
2360
2361 fn place_children(
2362 &self,
2363 bounds: Rect,
2364 _proposal: SizeProposal,
2365 children: &mut [WidgetPlacement],
2366 _ctx: &LayoutContext,
2367 ) {
2368 for child in children.iter_mut() {
2369 child.origin = bounds.origin();
2370 child.size = bounds.size();
2371 }
2372 }
2373
2374 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2375 use teksilo_core::accesskit::{Action, Role};
2376 builder.set_role(Role::MenuItem);
2377 builder.set_name(self.label.resolve_now());
2378 builder.add_action(Action::Focus);
2379 builder.add_action(Action::Click);
2380 let count = self.visible_count.get();
2384 builder.set_position_in_set(self.pos.saturating_sub(count) + 1);
2385 builder.set_size_of_set(overflow_shown_count(&self.row_ids, &self.visible_count));
2386 }
2387
2388 fn children(&self) -> Vec<WidgetId> {
2389 self.root.into_iter().collect()
2390 }
2391}
2392
2393#[cfg(test)]
2394mod tests {
2395 use super::*;
2396
2397 #[test]
2398 fn rail_insertion_picks_the_gap_under_the_pointer() {
2399 let items = vec![
2403 (0usize, Rect::new(100.0, 100.0, 40.0, 40.0)),
2404 (1, Rect::new(100.0, 142.0, 40.0, 40.0)),
2405 (2, Rect::new(100.0, 184.0, 40.0, 40.0)),
2406 ];
2407 assert_eq!(
2408 rail_insertion(5.0, &items, 100.0, 300.0).0,
2409 0,
2410 "above all → front"
2411 );
2412 assert_eq!(
2413 rail_insertion(40.0, &items, 100.0, 300.0).0,
2414 1,
2415 "past item 0 → 1"
2416 );
2417 assert_eq!(
2418 rail_insertion(70.0, &items, 100.0, 300.0).0,
2419 2,
2420 "past item 1 → 2"
2421 );
2422 assert_eq!(
2423 rail_insertion(290.0, &items, 100.0, 300.0).0,
2424 3,
2425 "below all → end"
2426 );
2427 }
2428
2429 #[test]
2430 fn rail_insertion_on_empty_rail_is_front() {
2431 assert_eq!(rail_insertion(50.0, &[], 0.0, 100.0).0, 0);
2432 }
2433
2434 fn cap() -> RailCapacity {
2436 RailCapacity {
2437 height: 260.0,
2438 stride: 42.0,
2439 slots: 0,
2440 actions: 0,
2441 total: 8,
2442 has_overflow_trigger: false,
2443 }
2444 }
2445
2446 #[test]
2447 fn capacity_shows_everything_when_it_all_fits() {
2448 let c = RailCapacity { total: 4, ..cap() };
2449 assert_eq!(shown_capacity(c), 4, "no overflow ⇒ every item shows");
2450 }
2451
2452 #[test]
2453 fn capacity_clips_without_a_trigger_and_reserves_one_slot_with_one() {
2454 assert_eq!(
2455 shown_capacity(cap()),
2456 6,
2457 "no trigger ⇒ the surplus is clipped"
2458 );
2459 assert_eq!(
2460 shown_capacity(RailCapacity {
2461 has_overflow_trigger: true,
2462 ..cap()
2463 }),
2464 5,
2465 "the trigger itself costs one slot"
2466 );
2467 }
2468
2469 #[test]
2470 fn capacity_charges_actions_and_slots() {
2471 assert_eq!(
2474 shown_capacity(RailCapacity {
2475 actions: 3,
2476 ..cap()
2477 }),
2478 3,
2479 "three actions cost three activity slots (6 → 3)"
2480 );
2481 assert_eq!(
2482 shown_capacity(RailCapacity { slots: 2, ..cap() }),
2483 4,
2484 "top_slot + bottom_slot cost one stride each"
2485 );
2486 assert_eq!(
2487 shown_capacity(RailCapacity {
2488 slots: 2,
2489 actions: 3,
2490 has_overflow_trigger: true,
2491 ..cap()
2492 }),
2493 0,
2494 "a rail crowded past its height shows no activities, and never \
2495 underflows"
2496 );
2497 }
2498
2499 #[test]
2500 fn capacity_never_underflows_or_divides_by_zero() {
2501 assert_eq!(
2502 shown_capacity(RailCapacity {
2503 height: 0.0,
2504 has_overflow_trigger: true,
2505 ..cap()
2506 }),
2507 0,
2508 "a zero-height rail shows nothing rather than wrapping around"
2509 );
2510 assert_eq!(
2511 shown_capacity(RailCapacity {
2512 stride: 0.0,
2513 ..cap()
2514 }),
2515 8,
2516 "a degenerate stride falls back to showing everything, not a divide by zero"
2517 );
2518 }
2519
2520 fn wid(n: u64) -> WidgetId {
2523 slotmap::KeyData::from_ffi(n).into()
2524 }
2525
2526 fn ids(items: &[(usize, u64)]) -> RailItemIds {
2527 Rc::new(RefCell::new(
2528 items.iter().map(|(p, w)| (*p, wid(*w))).collect(),
2529 ))
2530 }
2531
2532 #[test]
2533 fn rail_focus_target_wraps_among_shown_items() {
2534 let item_ids = ids(&[(0, 10), (1, 11), (2, 12)]);
2535 let vc = Signal::new(3usize);
2536 assert_eq!(
2537 rail_focus_target(&item_ids, &vc, 0, RailNav::Next),
2538 Some(wid(11))
2539 );
2540 assert_eq!(
2541 rail_focus_target(&item_ids, &vc, 2, RailNav::Next),
2542 Some(wid(10)),
2543 "ArrowDown past the last item wraps to the first"
2544 );
2545 assert_eq!(
2546 rail_focus_target(&item_ids, &vc, 0, RailNav::Prev),
2547 Some(wid(12)),
2548 "ArrowUp before the first item wraps to the last"
2549 );
2550 assert_eq!(
2551 rail_focus_target(&item_ids, &vc, 1, RailNav::First),
2552 Some(wid(10))
2553 );
2554 assert_eq!(
2555 rail_focus_target(&item_ids, &vc, 1, RailNav::Last),
2556 Some(wid(12))
2557 );
2558 }
2559
2560 #[test]
2561 fn rail_focus_target_skips_overflowed_items() {
2562 let item_ids = ids(&[(0, 10), (1, 11), (2, 12)]);
2564 let vc = Signal::new(2usize);
2565 assert_eq!(shown_rail_count(&item_ids, &vc), 2);
2566 assert_eq!(
2567 rail_focus_target(&item_ids, &vc, 1, RailNav::Next),
2568 Some(wid(10)),
2569 "nav wraps within the two shown items, skipping the overflowed one"
2570 );
2571 }
2572
2573 #[test]
2574 fn overflow_helpers_target_the_parked_rows() {
2575 let row_ids = ids(&[(0, 10), (1, 11), (2, 12), (3, 13)]);
2577 let vc = Signal::new(2usize);
2578 assert_eq!(overflow_shown_count(&row_ids, &vc), 2);
2579 assert_eq!(
2580 overflow_focus_target(&row_ids, &vc, 2, RailNav::Next),
2581 Some(wid(13))
2582 );
2583 assert_eq!(
2584 overflow_focus_target(&row_ids, &vc, 3, RailNav::Next),
2585 Some(wid(12)),
2586 "nav wraps within the overflowed set"
2587 );
2588 assert_eq!(
2589 overflow_focus_target(&row_ids, &vc, 2, RailNav::Prev),
2590 Some(wid(13))
2591 );
2592 }
2593}