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 shown_tabs = shown_rail_count(&self.item_ids, &self.visible_count);
692 let tab_list = ctx.add(DockRailTabList::new(self.side, items_stack, shown_tabs));
693
694 let mut column = VStack::new().spacing(RAIL_ITEM_SPACING);
696 if let Some(top) = &self.config.top_slot {
697 column = column.add_child(ctx.add_boxed((top)()));
698 }
699 if let Some(group) = self.build_action_group(ctx, DockActionPlacement::Start) {
700 column = column.add_child(group);
701 }
702 column = column.add_child(tab_list);
703 if let Some(of_icon) = &self.config.overflow_icon {
706 let total = pos;
707 let trigger = IconButton::new((of_icon)())
708 .size(self.effective_item_size())
709 .tooltip(lit!("More panels"));
710 let overflow = ctx.add(
711 PopoverIconButton::new(trigger)
712 .content(DockOverflowMenu::new(
713 self.side,
714 self.model.clone(),
715 visible_count.clone(),
716 ))
717 .placement(teksilo_core::overlay::OverlayPlacement::TrailingEdge),
718 );
719 ctx.visible_when(overflow, visible_count.map(move |c| *c < total));
720 column = column.add_child(overflow);
721 }
722 if let Some(group) = self.build_action_group(ctx, DockActionPlacement::End) {
723 column = column.add_child(group);
724 }
725 let spacer = ctx.add(Spacer::new());
726 column = column.add_child(spacer);
727 if let Some(group) = self.build_action_group(ctx, DockActionPlacement::Pinned) {
728 column = column.add_child(group);
729 }
730 if let Some(bottom) = &self.config.bottom_slot {
731 let b = ctx.add_boxed((bottom)());
732 column = column.add_child(b);
733 }
734
735 let column_id = ctx.add(column);
736 let padded = ctx.add(Padding::uniform(RAIL_PADDING).child_id(column_id));
737 let indicator = ctx.add(RailDropIndicator::new(self.drop_indicator.clone()));
740 let mut stack = ZStack::new()
741 .add_child(bg)
742 .add_child(padded)
743 .add_child(indicator);
744 if let Some(color) = self.config.divider.clone() {
747 stack = stack.add_child(ctx.add(RailEdgeDivider {
748 side: self.side,
749 color,
750 }));
751 }
752 let root = ctx.add(stack);
753 self.root = Some(root);
754
755 let menu_model = self.model.clone();
759 let menu_side = self.side;
760 let side = self.side;
767 let model = self.model.clone();
768 let item_bounds_hover = self.item_bounds.clone();
769 let item_bounds_drop = self.item_bounds.clone();
770 let self_bounds_hover = self.self_bounds.clone();
771 let self_bounds_drop = self.self_bounds.clone();
772 let indicator_hover = self.drop_indicator.clone();
773 let indicator_leave = self.drop_indicator.clone();
774 let indicator_drop = self.drop_indicator.clone();
775 let visible_count_hover = self.visible_count.clone();
776 let visible_count_drop = self.visible_count.clone();
777 let model_indices_hover = model_indices.clone();
778 let model_indices_drop = model_indices;
779 ctx.apply_self_handlers(
780 HandlerSet::new()
781 .context_menu(move |_pos, _ctx| {
782 Some(Box::new(background_menu(
783 &menu_model,
784 menu_side,
785 DockMenuKind::Rail,
786 )))
787 })
788 .on_drag_hover(move |payload, pos, _ctx| {
789 if dropped_dock_tab(payload).is_none() && dropped_dock_widget(payload).is_none()
790 {
791 indicator_hover.set(None);
792 return DropFeedback::NoFeedback;
793 }
794 let bar = self_bounds_hover.get();
795 let shown = shown_items(
796 &item_bounds_hover,
797 &model_indices_hover,
798 &visible_count_hover,
799 );
800 let (_, line_y) = rail_insertion(pos.y, &shown, bar.y, bar.height);
801 indicator_hover.set(Some(line_y));
802 DropFeedback::InsertionLine {
803 y: line_y,
804 width: bar.width,
805 }
806 })
807 .on_drag_leave(move |_ctx| indicator_leave.set(None))
808 .on_drop(move |payload, pos, ctx| {
809 indicator_drop.set(None);
810 if !model.is_side_enabled(side) {
815 return false;
816 }
817 let bar = self_bounds_drop.get();
818 let shown =
819 shown_items(&item_bounds_drop, &model_indices_drop, &visible_count_drop);
820 let (vpos, _) = rail_insertion(pos.y, &shown, bar.y, bar.height);
821 let at = model_indices_drop
825 .get(vpos)
826 .copied()
827 .unwrap_or_else(|| model.side_append_index(side));
828 if let Some(tab_id) = dropped_dock_tab(&payload) {
829 model.move_tab(tab_id, side, at);
830 model.set_side_visible(side, true);
831 ctx.request_accessibility_update();
832 true
833 } else if let Some(dock_id) = dropped_dock_widget(&payload) {
834 model.promote_to_tab(dock_id, side, at);
835 model.set_side_visible(side, true);
836 ctx.request_accessibility_update();
837 true
838 } else {
839 false
840 }
841 }),
842 );
843
844 vec![root]
845 }
846
847 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
848 self.root
849 .and_then(|id| ctx.child_size(id, proposal))
850 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
851 .into()
852 }
853
854 fn place_children(
855 &self,
856 bounds: Rect,
857 _proposal: SizeProposal,
858 children: &mut [WidgetPlacement],
859 _ctx: &LayoutContext,
860 ) {
861 self.self_bounds.set(bounds);
864 for child in children.iter_mut() {
865 child.origin = bounds.origin();
866 child.size = bounds.size();
867 }
868
869 let stride = self.item_stride();
874 if stride <= 0.0 {
875 return;
876 }
877 let new_visible = shown_capacity(RailCapacity {
878 height: bounds.height,
879 stride,
880 slots: usize::from(self.config.top_slot.is_some())
881 + usize::from(self.config.bottom_slot.is_some()),
882 actions: self.config.actions.len(),
883 total: self.item_count,
884 has_overflow_trigger: self.config.overflow_icon.is_some(),
885 });
886 if self.visible_count.get() != new_visible {
887 self.visible_count.set(new_visible);
888 }
889 }
890
891 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
892 builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
901 }
902
903 fn children(&self) -> Vec<WidgetId> {
904 self.root.into_iter().collect()
905 }
906}
907
908#[derive(Debug, Clone, Copy, PartialEq)]
910struct RailCapacity {
911 height: f32,
913 stride: f32,
916 slots: usize,
921 actions: usize,
925 total: usize,
927 has_overflow_trigger: bool,
930}
931
932fn shown_capacity(c: RailCapacity) -> usize {
940 if c.stride <= 0.0 {
941 return c.total;
942 }
943 let reserve = RAIL_PADDING * 2.0 + (c.slots + c.actions) as f32 * c.stride;
944 let avail = (c.height - reserve).max(0.0);
945 let fit = (avail / c.stride).floor() as usize;
946 if c.total <= fit {
947 c.total
948 } else if c.has_overflow_trigger {
949 fit.saturating_sub(1)
951 } else {
952 fit
953 }
954}
955
956fn shown_items(
960 bounds: &RailItemBounds,
961 model_indices: &[usize],
962 visible_count: &Signal<usize>,
963) -> Vec<(usize, Rect)> {
964 let shown = model_indices.len().min(visible_count.get());
965 let mut out: Vec<(usize, Rect)> = bounds
966 .borrow()
967 .iter()
968 .filter(|(p, _)| *p < shown)
969 .copied()
970 .collect();
971 out.sort_by_key(|(p, _)| *p);
972 out
973}
974
975enum RailNav {
977 Prev,
978 Next,
979 First,
980 Last,
981}
982
983fn shown_rail_count(item_ids: &RailItemIds, visible_count: &Signal<usize>) -> usize {
989 let count = visible_count.get();
990 item_ids.borrow().iter().filter(|(p, _)| *p < count).count()
991}
992
993fn rail_focus_target(
1000 item_ids: &RailItemIds,
1001 visible_count: &Signal<usize>,
1002 current_pos: usize,
1003 nav: RailNav,
1004) -> Option<WidgetId> {
1005 let count = visible_count.get();
1006 let mut shown: Vec<(usize, WidgetId)> = item_ids
1007 .borrow()
1008 .iter()
1009 .filter(|(p, _)| *p < count)
1010 .copied()
1011 .collect();
1012 shown.sort_by_key(|(p, _)| *p);
1013 if shown.is_empty() {
1014 return None;
1015 }
1016 let cur = shown.iter().position(|(p, _)| *p == current_pos)?;
1017 let target = match nav {
1018 RailNav::Prev => (cur + shown.len() - 1) % shown.len(),
1019 RailNav::Next => (cur + 1) % shown.len(),
1020 RailNav::First => 0,
1021 RailNav::Last => shown.len() - 1,
1022 };
1023 Some(shown[target].1)
1024}
1025
1026fn overflow_shown_count(row_ids: &RailItemIds, visible_count: &Signal<usize>) -> usize {
1031 let count = visible_count.get();
1032 row_ids.borrow().iter().filter(|(p, _)| *p >= count).count()
1033}
1034
1035fn overflow_focus_target(
1039 row_ids: &RailItemIds,
1040 visible_count: &Signal<usize>,
1041 current_pos: usize,
1042 nav: RailNav,
1043) -> Option<WidgetId> {
1044 let count = visible_count.get();
1045 let mut shown: Vec<(usize, WidgetId)> = row_ids
1046 .borrow()
1047 .iter()
1048 .filter(|(p, _)| *p >= count)
1049 .copied()
1050 .collect();
1051 shown.sort_by_key(|(p, _)| *p);
1052 if shown.is_empty() {
1053 return None;
1054 }
1055 let cur = shown.iter().position(|(p, _)| *p == current_pos)?;
1056 let target = match nav {
1057 RailNav::Prev => (cur + shown.len() - 1) % shown.len(),
1058 RailNav::Next => (cur + 1) % shown.len(),
1059 RailNav::First => 0,
1060 RailNav::Last => shown.len() - 1,
1061 };
1062 Some(shown[target].1)
1063}
1064
1065fn rail_insertion(
1069 local_y: f32,
1070 shown: &[(usize, Rect)],
1071 bar_origin_y: f32,
1072 bar_height: f32,
1073) -> (usize, f32) {
1074 if shown.is_empty() {
1075 return (0, (RAIL_PADDING).min(bar_height));
1076 }
1077 let mut vpos = shown.len();
1080 for (i, (_, b)) in shown.iter().enumerate() {
1081 let center = (b.y + b.height * 0.5) - bar_origin_y;
1082 if local_y < center {
1083 vpos = i;
1084 break;
1085 }
1086 }
1087 let line_y = if vpos == 0 {
1088 let top = shown[0].1.y - bar_origin_y;
1089 (top - RAIL_ITEM_SPACING * 0.5).max(0.0)
1090 } else if vpos >= shown.len() {
1091 let last = &shown[shown.len() - 1].1;
1092 (last.y + last.height - bar_origin_y + RAIL_ITEM_SPACING * 0.5).min(bar_height)
1093 } else {
1094 let prev = &shown[vpos - 1].1;
1095 let next = &shown[vpos].1;
1096 let prev_bottom = prev.y + prev.height - bar_origin_y;
1097 let next_top = next.y - bar_origin_y;
1098 (prev_bottom + next_top) * 0.5
1099 };
1100 (vpos, line_y.clamp(0.0, bar_height))
1101}
1102
1103#[derive(Debug)]
1119pub(crate) struct DockRailTabList {
1120 side: DockSide,
1121 stack: WidgetId,
1122 shown: usize,
1125}
1126
1127impl DockRailTabList {
1128 fn new(side: DockSide, stack: WidgetId, shown: usize) -> Self {
1129 Self { side, stack, shown }
1130 }
1131}
1132
1133impl Widget for DockRailTabList {
1134 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
1135 ctx.child_size(self.stack, proposal)
1136 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
1137 .into()
1138 }
1139
1140 fn place_children(
1141 &self,
1142 bounds: Rect,
1143 _proposal: SizeProposal,
1144 children: &mut [WidgetPlacement],
1145 _ctx: &LayoutContext,
1146 ) {
1147 for child in children.iter_mut() {
1148 child.origin = bounds.origin();
1149 child.size = bounds.size();
1150 }
1151 }
1152
1153 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1154 use teksilo_core::accesskit::{Orientation as A11yOrientation, Role};
1155 builder.set_role(Role::TabList);
1156 builder.set_name(super::a11y::rail_label(self.side).resolve_now());
1157 builder.set_orientation(A11yOrientation::Vertical);
1158 if self.shown > 0 {
1163 builder.set_size_of_set(self.shown);
1164 }
1165 }
1166
1167 fn children(&self) -> Vec<WidgetId> {
1168 vec![self.stack]
1169 }
1170}
1171
1172pub(crate) struct DockRailActionGroup {
1190 side: DockSide,
1191 placement: DockActionPlacement,
1192 actions: Vec<DockAction>,
1193 extent: f32,
1194 glyph: f32,
1195 labeled: bool,
1196 roving: Signal<usize>,
1200 item_ids: Rc<RefCell<Vec<WidgetId>>>,
1201 root: Option<WidgetId>,
1202}
1203
1204impl std::fmt::Debug for DockRailActionGroup {
1205 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1206 f.debug_struct("DockRailActionGroup")
1207 .field("side", &self.side)
1208 .field("placement", &self.placement)
1209 .field("actions", &self.actions.len())
1210 .finish()
1211 }
1212}
1213
1214impl DockRailActionGroup {
1215 fn new(
1216 side: DockSide,
1217 placement: DockActionPlacement,
1218 actions: Vec<DockAction>,
1219 extent: f32,
1220 glyph: f32,
1221 labeled: bool,
1222 ) -> Self {
1223 Self {
1224 side,
1225 placement,
1226 actions,
1227 extent,
1228 glyph,
1229 labeled,
1230 roving: Signal::new(0),
1231 item_ids: Rc::new(RefCell::new(Vec::new())),
1232 root: None,
1233 }
1234 }
1235}
1236
1237impl Widget for DockRailActionGroup {
1238 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1239 self.item_ids.borrow_mut().clear();
1240 let mut stack = VStack::new().spacing(RAIL_ITEM_SPACING);
1241 let mut ids = Vec::with_capacity(self.actions.len());
1242 for (i, action) in self.actions.iter().enumerate() {
1243 let id = ctx.add(DockRailActionItem::new(
1244 action.clone(),
1245 i,
1246 self.extent,
1247 self.glyph,
1248 self.labeled,
1249 self.roving.clone(),
1250 self.item_ids.clone(),
1251 ));
1252 ids.push(id);
1253 stack = stack.add_child(id);
1254 }
1255 *self.item_ids.borrow_mut() = ids;
1256 if self.roving.get() >= self.actions.len() {
1260 self.roving.set(0);
1261 }
1262 let root = ctx.add(stack);
1263 self.root = Some(root);
1264 vec![root]
1265 }
1266
1267 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
1268 self.root
1269 .and_then(|id| ctx.child_size(id, proposal))
1270 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
1271 .into()
1272 }
1273
1274 fn place_children(
1275 &self,
1276 bounds: Rect,
1277 _proposal: SizeProposal,
1278 children: &mut [WidgetPlacement],
1279 _ctx: &LayoutContext,
1280 ) {
1281 for child in children.iter_mut() {
1282 child.origin = bounds.origin();
1283 child.size = bounds.size();
1284 }
1285 }
1286
1287 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1288 use teksilo_core::accesskit::{Orientation as A11yOrientation, Role};
1289 builder.set_role(Role::Toolbar);
1290 builder.set_name(super::a11y::rail_actions_label(self.side, self.placement).resolve_now());
1291 builder.set_orientation(A11yOrientation::Vertical);
1292 let count = self.item_ids.borrow().len();
1298 if count > 0 {
1299 builder.set_size_of_set(count);
1300 }
1301 }
1302
1303 fn children(&self) -> Vec<WidgetId> {
1304 self.root.into_iter().collect()
1305 }
1306}
1307
1308struct DockRailActionItem {
1323 action: DockAction,
1324 index: usize,
1325 extent: f32,
1326 glyph: f32,
1327 labeled: bool,
1328 roving: Signal<usize>,
1329 siblings: Rc<RefCell<Vec<WidgetId>>>,
1330 focused: Signal<bool>,
1331 root: Option<WidgetId>,
1332}
1333
1334impl std::fmt::Debug for DockRailActionItem {
1335 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1336 f.debug_struct("DockRailActionItem")
1337 .field("index", &self.index)
1338 .finish()
1339 }
1340}
1341
1342impl DockRailActionItem {
1343 fn new(
1344 action: DockAction,
1345 index: usize,
1346 extent: f32,
1347 glyph: f32,
1348 labeled: bool,
1349 roving: Signal<usize>,
1350 siblings: Rc<RefCell<Vec<WidgetId>>>,
1351 ) -> Self {
1352 Self {
1353 action,
1354 index,
1355 extent,
1356 glyph,
1357 labeled,
1358 roving,
1359 siblings,
1360 focused: Signal::new(false),
1361 root: None,
1362 }
1363 }
1364}
1365
1366impl Widget for DockRailActionItem {
1367 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1368 let self_id = ctx.self_id();
1369 let enabled = self.action.enabled.as_signal();
1370 enabled.bind_to(self_id, ctx.binding_registry(), BindingLevel::RepaintOnly);
1371
1372 let toggled = self
1376 .action
1377 .toggled
1378 .clone()
1379 .unwrap_or_else(|| Signal::new(false));
1380 toggled.bind_to(self_id, ctx.binding_registry(), BindingLevel::RepaintOnly);
1381 let bg = toggled.zip(&ctx.window_active_signal()).map(|(t, win)| {
1382 if *t {
1383 if *win {
1384 SurfaceRole::Selected
1385 } else {
1386 SurfaceRole::SelectedInactive
1387 }
1388 } else {
1389 SurfaceRole::Transparent
1390 }
1391 });
1392 let ring = self.focused.and(&ctx.focus_visible());
1393 let focus_ring_width = ctx.theme().shape.focus_ring_width;
1394 let border_color: ColorProp = ring
1395 .map(|f| {
1396 if *f {
1397 BorderRole::Focused
1398 } else {
1399 BorderRole::Transparent
1400 }
1401 })
1402 .into();
1403 let border_width = ring.map(move |f| if *f { focus_ring_width } else { 0.0 });
1404 let bg_rect = ctx.add(
1405 RectWidget::new()
1406 .background(bg)
1407 .border_color(border_color)
1408 .border_width(border_width)
1409 .corner_radius(CornerRadius::uniform(ICON_BUTTON_CORNER_RADIUS)),
1410 );
1411
1412 let glyph_color: ColorProp = enabled
1413 .map(|e| {
1414 if *e {
1415 TextRole::Primary
1416 } else {
1417 TextRole::Disabled
1418 }
1419 })
1420 .into();
1421 let icon = ctx.add(
1422 (self.action.icon)()
1423 .icon_size(self.glyph)
1424 .color(glyph_color.clone()),
1425 );
1426 let centered = ctx.add(Center::new().child_id(icon));
1427 let icon_box = ctx.add(
1428 FixedSize::new()
1429 .width(self.extent)
1430 .height(self.extent)
1431 .child_id(centered),
1432 );
1433
1434 let content = if self.labeled {
1435 let label = ctx.add(RotatedLabel::new(
1436 self.action.label.clone(),
1437 Signal::new(TextRole::Secondary),
1438 ));
1439 let stack = ctx.add(
1440 VStack::new()
1441 .alignment(HAlignment::Center)
1442 .spacing(2.0)
1443 .add_child(label)
1444 .add_child(icon_box),
1445 );
1446 ctx.add(Padding::new(LABELED_TOP_MARGIN, 0.0, 0.0, 0.0).child_id(stack))
1447 } else {
1448 icon_box
1449 };
1450 let root = ctx.add(ZStack::new().add_child(bg_rect).add_child(content));
1451 self.root = Some(root);
1452
1453 if !self.labeled {
1454 let text = self
1457 .action
1458 .tooltip
1459 .clone()
1460 .unwrap_or_else(|| self.action.label.clone());
1461 let delay = ctx.theme().motion.tooltip_delay;
1462 crate::tooltip::attach_plain_tooltip_with_placement(
1463 ctx,
1464 root,
1465 text,
1466 delay,
1467 crate::tooltip::TooltipPlacement::Side,
1468 );
1469 }
1470
1471 let activate: Rc<dyn Fn(&mut EventContext)> = {
1474 let on_activate = self.action.on_activate.clone();
1475 let enabled = enabled.clone();
1476 let roving = self.roving.clone();
1477 let index = self.index;
1478 Rc::new(move |ctx: &mut EventContext| {
1479 if !enabled.get() {
1480 return;
1481 }
1482 roving.set(index);
1483 (on_activate)(ctx);
1484 })
1485 };
1486
1487 let index = self.index;
1489 ctx.set_tab_stop(self_id, self.roving.map(move |r| *r == index));
1490 let focused_sig = self.focused.clone();
1506 ctx.apply_self_handlers(
1507 HandlerSet::new()
1508 .on_tap({
1509 let activate = activate.clone();
1510 move |_e, ctx| activate(ctx)
1511 })
1512 .on_focus(move |gained, _ctx| focused_sig.set(gained))
1513 .on_key({
1514 let activate = activate.clone();
1515 let siblings = self.siblings.clone();
1516 let roving = self.roving.clone();
1517 let index = self.index;
1518 move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
1519 let WidgetEvent::KeyDown { key, .. } = event else {
1520 return EventResponse::Ignored;
1521 };
1522 let ids = siblings.borrow();
1523 if ids.is_empty() {
1524 return EventResponse::Ignored;
1525 }
1526 let next = match key {
1527 Key::ArrowUp | Key::ArrowLeft => (index + ids.len() - 1) % ids.len(),
1528 Key::ArrowDown | Key::ArrowRight => (index + 1) % ids.len(),
1529 Key::Home => 0,
1530 Key::End => ids.len() - 1,
1531 Key::Enter | Key::Space => {
1532 drop(ids);
1533 activate(ctx);
1534 return EventResponse::Handled;
1535 }
1536 _ => return EventResponse::Ignored,
1537 };
1538 let target = ids[next];
1539 drop(ids);
1540 roving.set(next);
1541 ctx.request_focus(target);
1542 EventResponse::Handled
1543 }
1544 })
1545 .on_access_action({
1546 let activate = activate.clone();
1547 move |action: teksilo_core::accesskit::Action, ctx: &mut EventContext| {
1548 if action == teksilo_core::accesskit::Action::Click {
1549 activate(ctx);
1550 EventResponse::Handled
1551 } else {
1552 EventResponse::Ignored
1553 }
1554 }
1555 })
1556 .focusable(true)
1557 .cursor(CursorIcon::Pointer),
1558 );
1559 vec![root]
1560 }
1561
1562 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
1563 self.root
1564 .and_then(|id| ctx.child_size(id, proposal))
1565 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
1566 .into()
1567 }
1568
1569 fn place_children(
1570 &self,
1571 bounds: Rect,
1572 _proposal: SizeProposal,
1573 children: &mut [WidgetPlacement],
1574 _ctx: &LayoutContext,
1575 ) {
1576 for child in children.iter_mut() {
1577 child.origin = bounds.origin();
1578 child.size = bounds.size();
1579 }
1580 }
1581
1582 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1583 use teksilo_core::accesskit::{Action, Role};
1584 builder.set_role(Role::Button);
1587 builder.set_name(self.action.label.resolve_now());
1588 builder.add_action(Action::Focus);
1589 if self.action.enabled.get() {
1593 builder.add_action(Action::Click);
1594 } else {
1595 builder.set_disabled();
1596 }
1597 builder.set_position_in_set(self.index + 1);
1598 if let Some(t) = &self.action.toggled {
1601 builder.set_toggled(t.get());
1602 }
1603 }
1604
1605 fn children(&self) -> Vec<WidgetId> {
1606 self.root.into_iter().collect()
1607 }
1608}
1609
1610struct RailDropIndicator {
1620 y: Signal<Option<f32>>,
1621 color: ColorProp,
1622}
1623
1624impl std::fmt::Debug for RailDropIndicator {
1625 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1626 f.debug_struct("RailDropIndicator").finish()
1627 }
1628}
1629
1630impl RailDropIndicator {
1631 fn new(y: Signal<Option<f32>>) -> Self {
1632 Self {
1633 y,
1634 color: ColorProp::from(BorderRole::Accent),
1635 }
1636 }
1637}
1638
1639impl Widget for RailDropIndicator {
1640 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1641 self.y.bind_to(
1642 ctx.self_id(),
1643 ctx.binding_registry(),
1644 BindingLevel::RepaintOnly,
1645 );
1646 ctx.apply_self_handlers(HandlerSet::new().event_pass_through(true));
1647 vec![]
1648 }
1649
1650 fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
1651 proposal.resolve(0.0, 0.0).into()
1652 }
1653
1654 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
1655 let Some(y) = self.y.get() else {
1656 return;
1657 };
1658 let color = self.color.resolve(ctx.theme, true);
1659 let t = 2.0;
1660 let yy = bounds.y + y - t * 0.5;
1661 let x = bounds.x + RAIL_PADDING;
1664 let w = (bounds.width - RAIL_PADDING * 2.0).max(0.0);
1665 canvas.fill_rect(Rect::new(x, yy, w, t), color);
1666 }
1667
1668 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1669 builder.set_hidden();
1670 }
1671}
1672
1673struct RailEdgeDivider {
1684 side: DockSide,
1685 color: ColorProp,
1686}
1687
1688impl std::fmt::Debug for RailEdgeDivider {
1689 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1690 f.debug_struct("RailEdgeDivider").finish()
1691 }
1692}
1693
1694impl Widget for RailEdgeDivider {
1695 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1696 ctx.apply_self_handlers(HandlerSet::new().event_pass_through(true));
1697 vec![]
1698 }
1699
1700 fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
1701 proposal.resolve(0.0, 0.0).into()
1702 }
1703
1704 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
1705 let rtl = matches!(
1706 ctx.layout_direction,
1707 teksilo_core::environment::LayoutDirection::RightToLeft
1708 );
1709 let content_on_right = match self.side {
1714 DockSide::Trailing => rtl,
1715 _ => !rtl,
1716 };
1717 let t = 1.0;
1718 let x = if content_on_right {
1719 bounds.x + bounds.width - t
1720 } else {
1721 bounds.x
1722 };
1723 let color = self.color.resolve(ctx.theme, true);
1724 canvas.fill_rect(Rect::new(x, bounds.y, t, bounds.height), color);
1725 }
1726
1727 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1728 builder.set_hidden();
1729 }
1730}
1731
1732#[derive(Debug)]
1740struct DockOverflowMenu {
1741 side: DockSide,
1742 model: DockingModel,
1743 visible_count: Signal<usize>,
1744 row_ids: RailItemIds,
1747 root: Option<WidgetId>,
1748}
1749
1750impl DockOverflowMenu {
1751 fn new(side: DockSide, model: DockingModel, visible_count: Signal<usize>) -> Self {
1752 Self {
1753 side,
1754 model,
1755 visible_count,
1756 row_ids: Rc::new(RefCell::new(Vec::new())),
1757 root: None,
1758 }
1759 }
1760}
1761
1762impl Widget for DockOverflowMenu {
1763 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1764 let tabs = self.model.side_tabs(self.side);
1765 let mut column = VStack::new().spacing(2.0);
1766 self.row_ids.borrow_mut().clear();
1767 let mut pos = 0usize;
1771 for (model_i, tab) in tabs.iter().enumerate() {
1772 if tab.hidden {
1773 continue;
1774 }
1775 let p = pos;
1776 pos += 1;
1777 let label = self.model.activity_label(tab);
1778 let row = ctx.add(DockOverflowRow::new(
1779 self.side,
1780 model_i,
1781 p,
1782 tab.id,
1783 label,
1784 self.model.clone(),
1785 self.row_ids.clone(),
1786 self.visible_count.clone(),
1787 ));
1788 self.row_ids.borrow_mut().push((p, row));
1789 ctx.visible_when(row, self.visible_count.map(move |c| p >= *c));
1790 column = column.add_child(row);
1791 }
1792 let column_id = ctx.add(column);
1793 let root = ctx.add(Padding::uniform(4.0).child_id(column_id));
1794 self.root = Some(root);
1795 vec![root]
1796 }
1797
1798 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
1799 self.root
1800 .and_then(|id| ctx.child_size(id, proposal))
1801 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
1802 .into()
1803 }
1804
1805 fn place_children(
1806 &self,
1807 bounds: Rect,
1808 _proposal: SizeProposal,
1809 children: &mut [WidgetPlacement],
1810 _ctx: &LayoutContext,
1811 ) {
1812 for child in children.iter_mut() {
1813 child.origin = bounds.origin();
1814 child.size = bounds.size();
1815 }
1816 }
1817
1818 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1819 use teksilo_core::accesskit::{Orientation, Role};
1820 builder.set_role(Role::Menu);
1821 builder.set_orientation(Orientation::Vertical);
1822 let shown = overflow_shown_count(&self.row_ids, &self.visible_count);
1827 if shown > 0 {
1828 builder.set_size_of_set(shown);
1829 }
1830 }
1831
1832 fn children(&self) -> Vec<WidgetId> {
1833 self.root.into_iter().collect()
1834 }
1835}
1836
1837struct DockRailItem {
1842 side: DockSide,
1843 index: usize,
1844 pos: usize,
1847 tab_id: DockTabId,
1848 icon: Option<DockIconFactory>,
1849 label: LocalizedString,
1850 extent: f32,
1851 glyph: f32,
1854 labeled: bool,
1857 selected: Signal<usize>,
1858 visible: Signal<bool>,
1859 model: DockingModel,
1860 bounds_sink: RailItemBounds,
1863 item_ids: RailItemIds,
1866 visible_count: Signal<usize>,
1867 side_panel_ids: Rc<RefCell<HashMap<DockSide, WidgetId>>>,
1869 focused: Signal<bool>,
1872 root: Option<WidgetId>,
1873}
1874
1875impl std::fmt::Debug for DockRailItem {
1876 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1877 f.debug_struct("DockRailItem")
1878 .field("index", &self.index)
1879 .finish()
1880 }
1881}
1882
1883impl DockRailItem {
1884 #[allow(clippy::too_many_arguments)]
1885 fn new(
1886 side: DockSide,
1887 index: usize,
1888 pos: usize,
1889 tab_id: DockTabId,
1890 icon: Option<DockIconFactory>,
1891 label: LocalizedString,
1892 extent: f32,
1893 glyph: f32,
1894 labeled: bool,
1895 selected: Signal<usize>,
1896 visible: Signal<bool>,
1897 model: DockingModel,
1898 bounds_sink: RailItemBounds,
1899 item_ids: RailItemIds,
1900 visible_count: Signal<usize>,
1901 side_panel_ids: Rc<RefCell<HashMap<DockSide, WidgetId>>>,
1902 ) -> Self {
1903 Self {
1904 side,
1905 index,
1906 pos,
1907 tab_id,
1908 icon,
1909 label,
1910 extent,
1911 glyph,
1912 labeled,
1913 selected,
1914 visible,
1915 model,
1916 bounds_sink,
1917 item_ids,
1918 visible_count,
1919 side_panel_ids,
1920 focused: Signal::new(false),
1921 root: None,
1922 }
1923 }
1924}
1925
1926impl Widget for DockRailItem {
1927 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1928 let idx = self.index;
1929 let active = self
1930 .selected
1931 .zip(&self.visible)
1932 .map(move |(s, v)| *s == idx && *v);
1933 let bg = active.zip(&ctx.window_active_signal()).map(|(a, win)| {
1945 if *a {
1946 if *win {
1947 SurfaceRole::Selected
1948 } else {
1949 SurfaceRole::SelectedInactive
1950 }
1951 } else {
1952 SurfaceRole::Transparent
1953 }
1954 });
1955 let ring = self.focused.and(&ctx.focus_visible());
1960 let focus_ring_width = ctx.theme().shape.focus_ring_width;
1961 let border_color: ColorProp = ring
1962 .map(|f| {
1963 if *f {
1964 BorderRole::Focused
1965 } else {
1966 BorderRole::Transparent
1967 }
1968 })
1969 .into();
1970 let border_width = ring.map(move |f| if *f { focus_ring_width } else { 0.0 });
1971 let bg_rect = ctx.add(
1974 RectWidget::new()
1975 .background(bg)
1976 .border_color(border_color)
1977 .border_width(border_width)
1978 .corner_radius(CornerRadius::uniform(ICON_BUTTON_CORNER_RADIUS)),
1979 );
1980
1981 let glyph = if let Some(icon) = self.icon.take() {
1982 ctx.add((icon)().icon_size(self.glyph))
1986 } else {
1987 let s = self.label.resolve_now();
1988 let ch: String = s.chars().take(1).collect();
1989 ctx.add(
1990 TextWidget::new(lit!(ch))
1991 .style(TextStyleRole::BodyBold)
1992 .color(TextRole::Primary),
1993 )
1994 };
1995 let centered = ctx.add(Center::new().child_id(glyph));
1996 let icon_box = ctx.add(
1997 FixedSize::new()
1998 .width(self.extent)
1999 .height(self.extent)
2000 .child_id(centered),
2001 );
2002
2003 let content = if self.labeled {
2007 let label = ctx.add(RotatedLabel::new(
2008 self.label.clone(),
2009 Signal::new(TextRole::Secondary),
2010 ));
2011 let stack = ctx.add(
2012 VStack::new()
2013 .alignment(HAlignment::Center)
2014 .spacing(2.0)
2015 .add_child(label)
2016 .add_child(icon_box),
2017 );
2018 ctx.add(Padding::new(LABELED_TOP_MARGIN, 0.0, 0.0, 0.0).child_id(stack))
2021 } else {
2022 icon_box
2023 };
2024 let root = ctx.add(ZStack::new().add_child(bg_rect).add_child(content));
2025 self.root = Some(root);
2026
2027 if !self.labeled {
2028 let delay = ctx.theme().motion.tooltip_delay;
2032 crate::tooltip::attach_plain_tooltip_with_placement(
2033 ctx,
2034 root,
2035 self.label.clone(),
2036 delay,
2037 crate::tooltip::TooltipPlacement::Side,
2038 );
2039 }
2040
2041 let self_id = ctx.self_id();
2042 let policy = self.model.policy();
2043 let side = self.side;
2044 let tab_id = self.tab_id;
2045 let menu_model = self.model.clone();
2046 let allow_collapse = policy.allow_side_collapse;
2047
2048 let activate: Rc<dyn Fn(&mut EventContext)> = {
2054 let model = self.model.clone();
2055 let selected = self.selected.clone();
2056 let visible = self.visible.clone();
2057 Rc::new(move |_ctx: &mut EventContext| {
2058 if selected.get() == idx && visible.get() {
2059 if allow_collapse {
2060 model.set_side_visible(side, false);
2061 }
2062 } else {
2063 model.select_tab(side, idx);
2064 model.set_side_visible(side, true);
2065 }
2066 })
2067 };
2068
2069 let focused_sig = self.focused.clone();
2071 ctx.set_tab_stop(self_id, self.selected.map(move |s| *s == idx));
2075
2076 let mut handlers = HandlerSet::new()
2077 .on_tap({
2078 let activate = activate.clone();
2079 move |_e, ctx| activate(ctx)
2080 })
2081 .on_focus(move |gained, _ctx| focused_sig.set(gained))
2082 .on_key({
2083 let activate = activate.clone();
2084 let item_ids = self.item_ids.clone();
2085 let visible_count = self.visible_count.clone();
2086 let pos = self.pos;
2087 move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
2088 let WidgetEvent::KeyDown { key, .. } = event else {
2089 return EventResponse::Ignored;
2090 };
2091 let nav = match key {
2092 Key::ArrowUp | Key::ArrowLeft => RailNav::Prev,
2093 Key::ArrowDown | Key::ArrowRight => RailNav::Next,
2094 Key::Home => RailNav::First,
2095 Key::End => RailNav::Last,
2096 Key::Enter | Key::Space => {
2097 activate(ctx);
2100 return EventResponse::Handled;
2101 }
2102 _ => return EventResponse::Ignored,
2103 };
2104 if let Some(target) = rail_focus_target(&item_ids, &visible_count, pos, nav) {
2105 ctx.request_focus(target);
2106 EventResponse::Handled
2107 } else {
2108 EventResponse::Ignored
2109 }
2110 }
2111 })
2112 .on_access_action({
2113 let activate = activate.clone();
2114 move |action: teksilo_core::accesskit::Action, ctx: &mut EventContext| {
2115 if action == teksilo_core::accesskit::Action::Click {
2116 activate(ctx);
2117 EventResponse::Handled
2118 } else {
2119 EventResponse::Ignored
2120 }
2121 }
2122 });
2123 if policy.allow_activity_drag {
2125 handlers = handlers.on_drag(move |phase, ctx| {
2126 if let DragPhase::Started { .. } = phase {
2127 ctx.start_drag(
2128 self_id,
2129 DragPayload::typed(DockTabDragData {
2130 tab_id,
2131 source_side: side,
2132 }),
2133 );
2134 }
2135 });
2136 }
2137 handlers = handlers
2138 .context_menu(move |_pos, _ctx| {
2139 Some(Box::new(activity_context_menu(
2140 &menu_model,
2141 side,
2142 tab_id,
2143 DockMenuKind::Rail,
2144 )))
2145 })
2146 .focusable(true)
2147 .cursor(CursorIcon::Pointer);
2148 ctx.apply_self_handlers(handlers);
2149 vec![root]
2150 }
2151
2152 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
2153 self.root
2154 .and_then(|id| ctx.child_size(id, proposal))
2155 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
2156 .into()
2157 }
2158
2159 fn place_children(
2160 &self,
2161 bounds: Rect,
2162 _proposal: SizeProposal,
2163 children: &mut [WidgetPlacement],
2164 _ctx: &LayoutContext,
2165 ) {
2166 {
2169 let mut sink = self.bounds_sink.borrow_mut();
2170 if let Some(slot) = sink.iter_mut().find(|(p, _)| *p == self.pos) {
2171 slot.1 = bounds;
2172 } else {
2173 sink.push((self.pos, bounds));
2174 }
2175 }
2176 for child in children.iter_mut() {
2177 child.origin = bounds.origin();
2178 child.size = bounds.size();
2179 }
2180 }
2181
2182 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2183 use teksilo_core::accesskit::{Action, Role};
2184 builder.set_role(Role::Tab);
2185 builder.set_name(self.label.resolve_now());
2186 let is_selected = self.selected.get() == self.index;
2187 builder.set_selected(is_selected && self.visible.get());
2188 builder.add_action(Action::Focus);
2189 builder.add_action(Action::Click);
2190 builder.set_position_in_set(self.pos + 1);
2195 if is_selected {
2200 builder.set_expanded(self.visible.get());
2201 }
2202 if self.visible.get()
2206 && let Some(&panel_id) = self.side_panel_ids.borrow().get(&self.side)
2207 {
2208 builder.push_controlled(widget_id_to_node_id(panel_id));
2209 }
2210 }
2211
2212 fn children(&self) -> Vec<WidgetId> {
2213 self.root.into_iter().collect()
2214 }
2215}
2216
2217#[derive(Debug)]
2222struct DockOverflowRow {
2223 side: DockSide,
2224 index: usize,
2225 pos: usize,
2228 tab_id: DockTabId,
2229 label: LocalizedString,
2230 model: DockingModel,
2231 row_ids: RailItemIds,
2234 visible_count: Signal<usize>,
2235 focused: Signal<bool>,
2237 root: Option<WidgetId>,
2238}
2239
2240impl DockOverflowRow {
2241 #[allow(clippy::too_many_arguments)]
2242 fn new(
2243 side: DockSide,
2244 index: usize,
2245 pos: usize,
2246 tab_id: DockTabId,
2247 label: LocalizedString,
2248 model: DockingModel,
2249 row_ids: RailItemIds,
2250 visible_count: Signal<usize>,
2251 ) -> Self {
2252 Self {
2253 side,
2254 index,
2255 pos,
2256 tab_id,
2257 label,
2258 model,
2259 row_ids,
2260 visible_count,
2261 focused: Signal::new(false),
2262 root: None,
2263 }
2264 }
2265}
2266
2267impl Widget for DockOverflowRow {
2268 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
2269 let label = ctx.add(
2270 TextWidget::new(self.label.clone())
2271 .style(TextStyleRole::Body)
2272 .color(TextRole::Primary)
2273 .single_line(),
2274 );
2275 let spacer = ctx.add(Spacer::new());
2276 let row = ctx.add(
2277 HStack::new()
2278 .spacing(8.0)
2279 .add_child(label)
2280 .add_child(spacer),
2281 );
2282 let content = ctx.add(Padding::symmetric(6.0, 10.0).child_id(row));
2283
2284 let ring = self.focused.and(&ctx.focus_visible());
2288 let focus_ring_width = ctx.theme().shape.focus_ring_width;
2289 let bg_role: ColorProp = self
2290 .focused
2291 .map(|f| {
2292 if *f {
2293 SurfaceRole::Hover
2294 } else {
2295 SurfaceRole::Transparent
2296 }
2297 })
2298 .into();
2299 let border_color: ColorProp = ring
2300 .map(|f| {
2301 if *f {
2302 BorderRole::Focused
2303 } else {
2304 BorderRole::Transparent
2305 }
2306 })
2307 .into();
2308 let border_width = ring.map(move |f| if *f { focus_ring_width } else { 0.0 });
2309 let bg_rect = ctx.add(
2310 RectWidget::new()
2311 .background(bg_role)
2312 .border_color(border_color)
2313 .border_width(border_width)
2314 .corner_radius(CornerRadius::uniform(ICON_BUTTON_CORNER_RADIUS)),
2315 );
2316 let root = ctx.add(ZStack::new().add_child(bg_rect).add_child(content));
2317 self.root = Some(root);
2318
2319 let activate: Rc<dyn Fn(&mut EventContext)> = {
2322 let model = self.model.clone();
2323 let side = self.side;
2324 let idx = self.index;
2325 Rc::new(move |_ctx: &mut EventContext| {
2326 model.select_tab(side, idx);
2327 model.set_side_visible(side, true);
2328 })
2329 };
2330 let focused_sig = self.focused.clone();
2331 ctx.apply_self_handlers(
2332 HandlerSet::new()
2333 .on_tap({
2334 let activate = activate.clone();
2335 move |_e, ctx| activate(ctx)
2336 })
2337 .on_focus(move |gained, _ctx| focused_sig.set(gained))
2338 .on_key({
2339 let activate = activate.clone();
2340 let row_ids = self.row_ids.clone();
2341 let visible_count = self.visible_count.clone();
2342 let pos = self.pos;
2343 move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
2344 let WidgetEvent::KeyDown { key, .. } = event else {
2345 return EventResponse::Ignored;
2346 };
2347 let nav = match key {
2348 Key::ArrowUp | Key::ArrowLeft => RailNav::Prev,
2349 Key::ArrowDown | Key::ArrowRight => RailNav::Next,
2350 Key::Home => RailNav::First,
2351 Key::End => RailNav::Last,
2352 Key::Enter | Key::Space => {
2353 activate(ctx);
2354 return EventResponse::Handled;
2355 }
2356 _ => return EventResponse::Ignored,
2357 };
2358 if let Some(target) =
2359 overflow_focus_target(&row_ids, &visible_count, pos, nav)
2360 {
2361 ctx.request_focus(target);
2362 EventResponse::Handled
2363 } else {
2364 EventResponse::Ignored
2365 }
2366 }
2367 })
2368 .on_access_action({
2369 let activate = activate.clone();
2370 move |action: teksilo_core::accesskit::Action, ctx: &mut EventContext| {
2371 if action == teksilo_core::accesskit::Action::Click {
2372 activate(ctx);
2373 EventResponse::Handled
2374 } else {
2375 EventResponse::Ignored
2376 }
2377 }
2378 })
2379 .focusable(true)
2380 .cursor(CursorIcon::Pointer),
2381 );
2382 vec![root]
2383 }
2384
2385 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
2386 self.root
2387 .and_then(|id| ctx.child_size(id, proposal))
2388 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
2389 .into()
2390 }
2391
2392 fn place_children(
2393 &self,
2394 bounds: Rect,
2395 _proposal: SizeProposal,
2396 children: &mut [WidgetPlacement],
2397 _ctx: &LayoutContext,
2398 ) {
2399 for child in children.iter_mut() {
2400 child.origin = bounds.origin();
2401 child.size = bounds.size();
2402 }
2403 }
2404
2405 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2406 use teksilo_core::accesskit::{Action, Role};
2407 builder.set_role(Role::MenuItem);
2408 builder.set_name(self.label.resolve_now());
2409 builder.add_action(Action::Focus);
2410 builder.add_action(Action::Click);
2411 let count = self.visible_count.get();
2415 builder.set_position_in_set(self.pos.saturating_sub(count) + 1);
2416 }
2418
2419 fn children(&self) -> Vec<WidgetId> {
2420 self.root.into_iter().collect()
2421 }
2422}
2423
2424#[cfg(test)]
2425mod tests {
2426 use super::*;
2427
2428 #[test]
2429 fn rail_insertion_picks_the_gap_under_the_pointer() {
2430 let items = vec![
2434 (0usize, Rect::new(100.0, 100.0, 40.0, 40.0)),
2435 (1, Rect::new(100.0, 142.0, 40.0, 40.0)),
2436 (2, Rect::new(100.0, 184.0, 40.0, 40.0)),
2437 ];
2438 assert_eq!(
2439 rail_insertion(5.0, &items, 100.0, 300.0).0,
2440 0,
2441 "above all → front"
2442 );
2443 assert_eq!(
2444 rail_insertion(40.0, &items, 100.0, 300.0).0,
2445 1,
2446 "past item 0 → 1"
2447 );
2448 assert_eq!(
2449 rail_insertion(70.0, &items, 100.0, 300.0).0,
2450 2,
2451 "past item 1 → 2"
2452 );
2453 assert_eq!(
2454 rail_insertion(290.0, &items, 100.0, 300.0).0,
2455 3,
2456 "below all → end"
2457 );
2458 }
2459
2460 #[test]
2461 fn rail_insertion_on_empty_rail_is_front() {
2462 assert_eq!(rail_insertion(50.0, &[], 0.0, 100.0).0, 0);
2463 }
2464
2465 fn cap() -> RailCapacity {
2467 RailCapacity {
2468 height: 260.0,
2469 stride: 42.0,
2470 slots: 0,
2471 actions: 0,
2472 total: 8,
2473 has_overflow_trigger: false,
2474 }
2475 }
2476
2477 #[test]
2478 fn capacity_shows_everything_when_it_all_fits() {
2479 let c = RailCapacity { total: 4, ..cap() };
2480 assert_eq!(shown_capacity(c), 4, "no overflow ⇒ every item shows");
2481 }
2482
2483 #[test]
2484 fn capacity_clips_without_a_trigger_and_reserves_one_slot_with_one() {
2485 assert_eq!(
2486 shown_capacity(cap()),
2487 6,
2488 "no trigger ⇒ the surplus is clipped"
2489 );
2490 assert_eq!(
2491 shown_capacity(RailCapacity {
2492 has_overflow_trigger: true,
2493 ..cap()
2494 }),
2495 5,
2496 "the trigger itself costs one slot"
2497 );
2498 }
2499
2500 #[test]
2501 fn capacity_charges_actions_and_slots() {
2502 assert_eq!(
2505 shown_capacity(RailCapacity {
2506 actions: 3,
2507 ..cap()
2508 }),
2509 3,
2510 "three actions cost three activity slots (6 → 3)"
2511 );
2512 assert_eq!(
2513 shown_capacity(RailCapacity { slots: 2, ..cap() }),
2514 4,
2515 "top_slot + bottom_slot cost one stride each"
2516 );
2517 assert_eq!(
2518 shown_capacity(RailCapacity {
2519 slots: 2,
2520 actions: 3,
2521 has_overflow_trigger: true,
2522 ..cap()
2523 }),
2524 0,
2525 "a rail crowded past its height shows no activities, and never \
2526 underflows"
2527 );
2528 }
2529
2530 #[test]
2531 fn capacity_never_underflows_or_divides_by_zero() {
2532 assert_eq!(
2533 shown_capacity(RailCapacity {
2534 height: 0.0,
2535 has_overflow_trigger: true,
2536 ..cap()
2537 }),
2538 0,
2539 "a zero-height rail shows nothing rather than wrapping around"
2540 );
2541 assert_eq!(
2542 shown_capacity(RailCapacity {
2543 stride: 0.0,
2544 ..cap()
2545 }),
2546 8,
2547 "a degenerate stride falls back to showing everything, not a divide by zero"
2548 );
2549 }
2550
2551 fn wid(n: u64) -> WidgetId {
2554 slotmap::KeyData::from_ffi(n).into()
2555 }
2556
2557 fn ids(items: &[(usize, u64)]) -> RailItemIds {
2558 Rc::new(RefCell::new(
2559 items.iter().map(|(p, w)| (*p, wid(*w))).collect(),
2560 ))
2561 }
2562
2563 #[test]
2564 fn rail_focus_target_wraps_among_shown_items() {
2565 let item_ids = ids(&[(0, 10), (1, 11), (2, 12)]);
2566 let vc = Signal::new(3usize);
2567 assert_eq!(
2568 rail_focus_target(&item_ids, &vc, 0, RailNav::Next),
2569 Some(wid(11))
2570 );
2571 assert_eq!(
2572 rail_focus_target(&item_ids, &vc, 2, RailNav::Next),
2573 Some(wid(10)),
2574 "ArrowDown past the last item wraps to the first"
2575 );
2576 assert_eq!(
2577 rail_focus_target(&item_ids, &vc, 0, RailNav::Prev),
2578 Some(wid(12)),
2579 "ArrowUp before the first item wraps to the last"
2580 );
2581 assert_eq!(
2582 rail_focus_target(&item_ids, &vc, 1, RailNav::First),
2583 Some(wid(10))
2584 );
2585 assert_eq!(
2586 rail_focus_target(&item_ids, &vc, 1, RailNav::Last),
2587 Some(wid(12))
2588 );
2589 }
2590
2591 #[test]
2592 fn rail_focus_target_skips_overflowed_items() {
2593 let item_ids = ids(&[(0, 10), (1, 11), (2, 12)]);
2595 let vc = Signal::new(2usize);
2596 assert_eq!(shown_rail_count(&item_ids, &vc), 2);
2597 assert_eq!(
2598 rail_focus_target(&item_ids, &vc, 1, RailNav::Next),
2599 Some(wid(10)),
2600 "nav wraps within the two shown items, skipping the overflowed one"
2601 );
2602 }
2603
2604 #[test]
2605 fn overflow_helpers_target_the_parked_rows() {
2606 let row_ids = ids(&[(0, 10), (1, 11), (2, 12), (3, 13)]);
2608 let vc = Signal::new(2usize);
2609 assert_eq!(overflow_shown_count(&row_ids, &vc), 2);
2610 assert_eq!(
2611 overflow_focus_target(&row_ids, &vc, 2, RailNav::Next),
2612 Some(wid(13))
2613 );
2614 assert_eq!(
2615 overflow_focus_target(&row_ids, &vc, 3, RailNav::Next),
2616 Some(wid(12)),
2617 "nav wraps within the overflowed set"
2618 );
2619 assert_eq!(
2620 overflow_focus_target(&row_ids, &vc, 2, RailNav::Prev),
2621 Some(wid(13))
2622 );
2623 }
2624}