use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::rc::Rc;
use teksilo_canvas::{Canvas, Rect, SizeProposal};
use teksilo_core::accessibility::{AccessNodeBuilder, widget_id_to_node_id};
use teksilo_core::binding::BindingLevel;
use teksilo_core::build_context::BuildContext;
use teksilo_core::color_prop::ColorProp;
use teksilo_core::event::{EventResponse, Key, WidgetEvent};
use teksilo_core::gesture::DragPhase;
use teksilo_core::signal::{Prop, Signal};
use teksilo_core::widget::{
CursorIcon, EventContext, LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement,
};
use teksilo_core::widget_builder::HandlerSet;
use teksilo_core::widget_id::WidgetId;
use teksilo_core::{DragPayload, DropFeedback};
use teksilo_i18n::{LocalizedString, lit};
use teksilo_tokens::{BorderRole, CornerRadius, HAlignment, SurfaceRole, TextRole, TextStyleRole};
use crate::icon_button::{IconButton, IconButtonSize};
use crate::popover_widget::PopoverIconButton;
use crate::primitives::{
Center, FixedSize, HStack, IconWidget, Padding, RectWidget, Spacer, TextWidget, VStack, ZStack,
};
use crate::styles::recipe_icon_button_style::ICON_BUTTON_CORNER_RADIUS;
use crate::tool_box::RotatedLabel;
use super::context_menu::{DockMenuKind, activity_context_menu, background_menu};
use super::drag::{DockTabDragData, dropped_dock_tab, dropped_dock_widget};
use super::geometry::DockSide;
use super::model::{DockIconFactory, DockRailItemSize, DockTabId, DockingModel};
type RailItemBounds = Rc<RefCell<Vec<(usize, Rect)>>>;
type RailItemIds = Rc<RefCell<Vec<(usize, WidgetId)>>>;
pub type DockRailSlot = Rc<dyn Fn() -> Box<dyn Widget>>;
fn item_extent(size: IconButtonSize) -> f32 {
use crate::styles::recipe_icon_button_style::*;
match size {
IconButtonSize::Compact => ICON_BUTTON_SIZE_COMPACT,
IconButtonSize::Default => ICON_BUTTON_SIZE_DEFAULT,
IconButtonSize::Toolbar => ICON_BUTTON_SIZE_TOOLBAR,
IconButtonSize::Large => ICON_BUTTON_SIZE_LARGE,
IconButtonSize::Hero => ICON_BUTTON_SIZE_HERO,
}
}
fn item_glyph_size(size: IconButtonSize) -> f32 {
use crate::styles::recipe_icon_button_style::*;
match size {
IconButtonSize::Compact | IconButtonSize::Default => ICON_BUTTON_ICON_SIZE,
IconButtonSize::Toolbar => ICON_BUTTON_ICON_SIZE_TOOLBAR,
IconButtonSize::Large => ICON_BUTTON_ICON_SIZE_LARGE,
IconButtonSize::Hero => ICON_BUTTON_ICON_SIZE_HERO,
}
}
const RAIL_ITEM_SPACING: f32 = 2.0;
const RAIL_PADDING: f32 = 4.0;
const LABELED_TITLE_ALLOWANCE: f32 = 72.0;
const LABELED_TOP_MARGIN: f32 = 6.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct DockActionId(u64);
impl DockActionId {
pub const fn named(name: &str) -> Self {
let bytes = name.as_bytes();
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
let mut i = 0;
while i < bytes.len() {
hash ^= bytes[i] as u64;
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
i += 1;
}
Self(hash)
}
pub const fn from_raw(v: u64) -> Self {
Self(v)
}
pub const fn raw(self) -> u64 {
self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DockActionPlacement {
Start,
End,
Pinned,
}
#[derive(Clone)]
pub struct DockAction {
pub(crate) id: DockActionId,
pub(crate) placement: DockActionPlacement,
pub(crate) label: LocalizedString,
pub(crate) icon: DockIconFactory,
pub(crate) tooltip: Option<LocalizedString>,
pub(crate) enabled: Prop<bool>,
pub(crate) toggled: Option<Signal<bool>>,
pub(crate) on_activate: Rc<dyn Fn(&mut EventContext)>,
}
impl std::fmt::Debug for DockAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DockAction")
.field("id", &self.id)
.field("placement", &self.placement)
.finish()
}
}
impl DockAction {
pub fn new(
id: DockActionId,
label: impl Into<LocalizedString>,
icon: impl Fn() -> IconWidget + 'static,
on_activate: impl Fn(&mut EventContext) + 'static,
) -> Self {
Self {
id,
placement: DockActionPlacement::End,
label: label.into(),
icon: Rc::new(icon),
tooltip: None,
enabled: Prop::Static(true),
toggled: None,
on_activate: Rc::new(on_activate),
}
}
pub fn placement(mut self, placement: DockActionPlacement) -> Self {
self.placement = placement;
self
}
pub fn tooltip(mut self, tooltip: impl Into<LocalizedString>) -> Self {
self.tooltip = Some(tooltip.into());
self
}
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
self.enabled = enabled.into();
self
}
pub fn toggled(mut self, state: Signal<bool>) -> Self {
self.toggled = Some(state);
self
}
pub fn id(&self) -> DockActionId {
self.id
}
}
#[derive(Clone)]
pub struct DockRail {
pub(crate) side: DockSide,
pub(crate) size: IconButtonSize,
pub(crate) background: Option<ColorProp>,
pub(crate) divider: Option<ColorProp>,
pub(crate) top_slot: Option<DockRailSlot>,
pub(crate) bottom_slot: Option<DockRailSlot>,
pub(crate) leading_slot: Option<DockRailSlot>,
pub(crate) trailing_slot: Option<DockRailSlot>,
pub(crate) actions: Vec<DockAction>,
pub(crate) overflow_icon: Option<DockIconFactory>,
}
impl std::fmt::Debug for DockRail {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DockRail")
.field("side", &self.side)
.field("size", &self.size)
.finish()
}
}
impl DockRail {
pub fn new(side: DockSide) -> Self {
Self {
side,
size: IconButtonSize::Large,
background: None,
divider: None,
top_slot: None,
bottom_slot: None,
leading_slot: None,
trailing_slot: None,
actions: Vec::new(),
overflow_icon: None,
}
}
pub fn size(mut self, size: IconButtonSize) -> Self {
self.size = size;
self
}
pub fn background(mut self, color: impl Into<ColorProp>) -> Self {
self.background = Some(color.into());
self
}
pub fn divider(mut self) -> Self {
self.divider = Some(BorderRole::Divider.into());
self
}
pub fn divider_color(mut self, color: impl Into<ColorProp>) -> Self {
self.divider = Some(color.into());
self
}
pub fn top_slot<W: Widget + 'static>(mut self, f: impl Fn() -> W + 'static) -> Self {
self.top_slot = Some(Rc::new(move || Box::new(f()) as Box<dyn Widget>));
self
}
pub fn bottom_slot<W: Widget + 'static>(mut self, f: impl Fn() -> W + 'static) -> Self {
self.bottom_slot = Some(Rc::new(move || Box::new(f()) as Box<dyn Widget>));
self
}
pub fn leading_slot<W: Widget + 'static>(mut self, f: impl Fn() -> W + 'static) -> Self {
self.leading_slot = Some(Rc::new(move || Box::new(f()) as Box<dyn Widget>));
self
}
pub fn trailing_slot<W: Widget + 'static>(mut self, f: impl Fn() -> W + 'static) -> Self {
self.trailing_slot = Some(Rc::new(move || Box::new(f()) as Box<dyn Widget>));
self
}
pub fn action(mut self, action: DockAction) -> Self {
debug_assert!(
!self.actions.iter().any(|a| a.id == action.id),
"duplicate DockActionId {:?} on the {:?} rail — ids must be unique \
per side (two `DockAction`s declared with the same id, or an \
FNV-1a collision between two `DockActionId::named` values)",
action.id,
self.side,
);
self.actions.push(action);
self
}
pub fn overflow_icon(mut self, f: impl Fn() -> IconWidget + 'static) -> Self {
self.overflow_icon = Some(Rc::new(f));
self
}
pub(crate) fn actions_at(&self, placement: DockActionPlacement) -> Vec<DockAction> {
self.actions
.iter()
.filter(|a| a.placement == placement)
.cloned()
.collect()
}
pub(crate) fn side(&self) -> DockSide {
self.side
}
pub(crate) fn effective_thickness(&self, mode: DockRailItemSize) -> f32 {
let size = if matches!(mode, DockRailItemSize::Compact) {
IconButtonSize::Default
} else {
self.size
};
item_extent(size) + RAIL_PADDING * 2.0
}
}
pub(crate) struct DockActivityBar {
side: DockSide,
model: DockingModel,
config: DockRail,
visible_count: Signal<usize>,
item_count: usize,
item_bounds: RailItemBounds,
item_ids: RailItemIds,
self_bounds: Rc<Cell<Rect>>,
side_panel_ids: Rc<RefCell<HashMap<DockSide, WidgetId>>>,
drop_indicator: Signal<Option<f32>>,
root: Option<WidgetId>,
}
impl std::fmt::Debug for DockActivityBar {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DockActivityBar")
.field("side", &self.side)
.finish()
}
}
impl DockActivityBar {
pub(crate) fn new(
side: DockSide,
model: DockingModel,
config: DockRail,
side_panel_ids: Rc<RefCell<HashMap<DockSide, WidgetId>>>,
) -> Self {
Self {
side,
model,
config,
visible_count: Signal::new(usize::MAX),
item_count: 0,
item_bounds: Rc::new(RefCell::new(Vec::new())),
item_ids: Rc::new(RefCell::new(Vec::new())),
self_bounds: Rc::new(Cell::new(Rect::ZERO)),
side_panel_ids,
drop_indicator: Signal::new(None),
root: None,
}
}
fn effective_item_size(&self) -> IconButtonSize {
match self.model.side_rail_size(self.side) {
DockRailItemSize::Compact => IconButtonSize::Default,
DockRailItemSize::Default | DockRailItemSize::Labeled => self.config.size,
}
}
fn effective_item_extent(&self) -> f32 {
item_extent(self.effective_item_size())
}
fn item_stride(&self) -> f32 {
let mut s = self.effective_item_extent() + RAIL_ITEM_SPACING;
if self.model.side_rail_size(self.side).shows_label() {
s += LABELED_TITLE_ALLOWANCE;
}
s
}
fn build_action_group(
&self,
ctx: &mut BuildContext,
placement: DockActionPlacement,
) -> Option<WidgetId> {
let actions = self.config.actions_at(placement);
if actions.is_empty() {
return None;
}
Some(ctx.add(DockRailActionGroup::new(
self.side,
placement,
actions,
self.effective_item_extent(),
item_glyph_size(self.effective_item_size()),
self.model.side_rail_size(self.side).shows_label(),
)))
}
}
impl Widget for DockActivityBar {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
let self_id = ctx.self_id();
self.model.rail_size_signal(self.side).bind_to(
self_id,
ctx.binding_registry(),
BindingLevel::Rebuild,
);
let bg_color = self
.config
.background
.clone()
.unwrap_or_else(|| SurfaceRole::Sunken.into());
let bg = ctx.add(RectWidget::new().background(bg_color));
let selected = self.model.side_selected_tab_signal(self.side);
let visible = self.model.side_visible_signal(self.side);
let tabs = self.model.side_tabs(self.side);
let extent = self.effective_item_extent();
let glyph = item_glyph_size(self.effective_item_size());
let labeled = self.model.side_rail_size(self.side).shows_label();
let visible_count = self.visible_count.clone();
self.item_bounds.borrow_mut().clear();
self.item_ids.borrow_mut().clear();
let mut model_indices: Vec<usize> = Vec::with_capacity(tabs.len());
let mut items: Vec<WidgetId> = Vec::with_capacity(tabs.len());
let mut pos = 0usize;
for (model_i, tab) in tabs.iter().enumerate() {
if tab.hidden {
continue;
}
let p = pos;
pos += 1;
model_indices.push(model_i);
let icon = self.model.activity_icon(tab);
let label = self.model.activity_label(tab);
let id = ctx.add(DockRailItem::new(
self.side,
model_i,
p,
tab.id,
icon,
label,
extent,
glyph,
labeled,
selected.clone(),
visible.clone(),
self.model.clone(),
self.item_bounds.clone(),
self.item_ids.clone(),
self.visible_count.clone(),
self.side_panel_ids.clone(),
));
self.item_ids.borrow_mut().push((p, id));
ctx.visible_when(id, visible_count.map(move |c| p < *c));
items.push(id);
}
self.item_count = pos;
let mut items_stack = VStack::new().spacing(RAIL_ITEM_SPACING);
for id in &items {
items_stack = items_stack.add_child(*id);
}
let items_stack = ctx.add(items_stack);
let tab_list = ctx.add(DockRailTabList::new(self.side, items_stack));
let mut column = VStack::new().spacing(RAIL_ITEM_SPACING);
if let Some(top) = &self.config.top_slot {
column = column.add_child(ctx.add_boxed((top)()));
}
if let Some(group) = self.build_action_group(ctx, DockActionPlacement::Start) {
column = column.add_child(group);
}
column = column.add_child(tab_list);
if let Some(of_icon) = &self.config.overflow_icon {
let total = pos;
let trigger = IconButton::new((of_icon)())
.size(self.effective_item_size())
.tooltip(lit!("More panels"));
let overflow = ctx.add(
PopoverIconButton::new(trigger)
.content(DockOverflowMenu::new(
self.side,
self.model.clone(),
visible_count.clone(),
))
.placement(teksilo_core::overlay::OverlayPlacement::TrailingEdge),
);
ctx.visible_when(overflow, visible_count.map(move |c| *c < total));
column = column.add_child(overflow);
}
if let Some(group) = self.build_action_group(ctx, DockActionPlacement::End) {
column = column.add_child(group);
}
let spacer = ctx.add(Spacer::new());
column = column.add_child(spacer);
if let Some(group) = self.build_action_group(ctx, DockActionPlacement::Pinned) {
column = column.add_child(group);
}
if let Some(bottom) = &self.config.bottom_slot {
let b = ctx.add_boxed((bottom)());
column = column.add_child(b);
}
let column_id = ctx.add(column);
let padded = ctx.add(Padding::uniform(RAIL_PADDING).child_id(column_id));
let indicator = ctx.add(RailDropIndicator::new(self.drop_indicator.clone()));
let mut stack = ZStack::new()
.add_child(bg)
.add_child(padded)
.add_child(indicator);
if let Some(color) = self.config.divider.clone() {
stack = stack.add_child(ctx.add(RailEdgeDivider {
side: self.side,
color,
}));
}
let root = ctx.add(stack);
self.root = Some(root);
let menu_model = self.model.clone();
let menu_side = self.side;
let side = self.side;
let model = self.model.clone();
let item_bounds_hover = self.item_bounds.clone();
let item_bounds_drop = self.item_bounds.clone();
let self_bounds_hover = self.self_bounds.clone();
let self_bounds_drop = self.self_bounds.clone();
let indicator_hover = self.drop_indicator.clone();
let indicator_leave = self.drop_indicator.clone();
let indicator_drop = self.drop_indicator.clone();
let visible_count_hover = self.visible_count.clone();
let visible_count_drop = self.visible_count.clone();
let model_indices_hover = model_indices.clone();
let model_indices_drop = model_indices;
ctx.apply_self_handlers(
HandlerSet::new()
.context_menu(move |_pos, _ctx| {
Some(Box::new(background_menu(
&menu_model,
menu_side,
DockMenuKind::Rail,
)))
})
.on_drag_hover(move |payload, pos, _ctx| {
if dropped_dock_tab(payload).is_none() && dropped_dock_widget(payload).is_none()
{
indicator_hover.set(None);
return DropFeedback::NoFeedback;
}
let bar = self_bounds_hover.get();
let shown = shown_items(
&item_bounds_hover,
&model_indices_hover,
&visible_count_hover,
);
let (_, line_y) = rail_insertion(pos.y, &shown, bar.y, bar.height);
indicator_hover.set(Some(line_y));
DropFeedback::InsertionLine {
y: line_y,
width: bar.width,
}
})
.on_drag_leave(move |_ctx| indicator_leave.set(None))
.on_drop(move |payload, pos, ctx| {
indicator_drop.set(None);
if !model.is_side_enabled(side) {
return false;
}
let bar = self_bounds_drop.get();
let shown =
shown_items(&item_bounds_drop, &model_indices_drop, &visible_count_drop);
let (vpos, _) = rail_insertion(pos.y, &shown, bar.y, bar.height);
let at = model_indices_drop
.get(vpos)
.copied()
.unwrap_or_else(|| model.side_append_index(side));
if let Some(tab_id) = dropped_dock_tab(&payload) {
model.move_tab(tab_id, side, at);
model.set_side_visible(side, true);
ctx.request_accessibility_update();
true
} else if let Some(dock_id) = dropped_dock_widget(&payload) {
model.promote_to_tab(dock_id, side, at);
model.set_side_visible(side, true);
ctx.request_accessibility_update();
true
} else {
false
}
}),
);
vec![root]
}
fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
self.root
.and_then(|id| ctx.child_size(id, proposal))
.unwrap_or_else(|| proposal.resolve(0.0, 0.0))
.into()
}
fn place_children(
&self,
bounds: Rect,
_proposal: SizeProposal,
children: &mut [WidgetPlacement],
_ctx: &LayoutContext,
) {
self.self_bounds.set(bounds);
for child in children.iter_mut() {
child.origin = bounds.origin();
child.size = bounds.size();
}
let stride = self.item_stride();
if stride <= 0.0 {
return;
}
let new_visible = shown_capacity(RailCapacity {
height: bounds.height,
stride,
slots: usize::from(self.config.top_slot.is_some())
+ usize::from(self.config.bottom_slot.is_some()),
actions: self.config.actions.len(),
total: self.item_count,
has_overflow_trigger: self.config.overflow_icon.is_some(),
});
if self.visible_count.get() != new_visible {
self.visible_count.set(new_visible);
}
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
}
fn children(&self) -> Vec<WidgetId> {
self.root.into_iter().collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct RailCapacity {
height: f32,
stride: f32,
slots: usize,
actions: usize,
total: usize,
has_overflow_trigger: bool,
}
fn shown_capacity(c: RailCapacity) -> usize {
if c.stride <= 0.0 {
return c.total;
}
let reserve = RAIL_PADDING * 2.0 + (c.slots + c.actions) as f32 * c.stride;
let avail = (c.height - reserve).max(0.0);
let fit = (avail / c.stride).floor() as usize;
if c.total <= fit {
c.total
} else if c.has_overflow_trigger {
fit.saturating_sub(1)
} else {
fit
}
}
fn shown_items(
bounds: &RailItemBounds,
model_indices: &[usize],
visible_count: &Signal<usize>,
) -> Vec<(usize, Rect)> {
let shown = model_indices.len().min(visible_count.get());
let mut out: Vec<(usize, Rect)> = bounds
.borrow()
.iter()
.filter(|(p, _)| *p < shown)
.copied()
.collect();
out.sort_by_key(|(p, _)| *p);
out
}
enum RailNav {
Prev,
Next,
First,
Last,
}
fn shown_rail_count(item_ids: &RailItemIds, visible_count: &Signal<usize>) -> usize {
let count = visible_count.get();
item_ids.borrow().iter().filter(|(p, _)| *p < count).count()
}
fn rail_focus_target(
item_ids: &RailItemIds,
visible_count: &Signal<usize>,
current_pos: usize,
nav: RailNav,
) -> Option<WidgetId> {
let count = visible_count.get();
let mut shown: Vec<(usize, WidgetId)> = item_ids
.borrow()
.iter()
.filter(|(p, _)| *p < count)
.copied()
.collect();
shown.sort_by_key(|(p, _)| *p);
if shown.is_empty() {
return None;
}
let cur = shown.iter().position(|(p, _)| *p == current_pos)?;
let target = match nav {
RailNav::Prev => (cur + shown.len() - 1) % shown.len(),
RailNav::Next => (cur + 1) % shown.len(),
RailNav::First => 0,
RailNav::Last => shown.len() - 1,
};
Some(shown[target].1)
}
fn overflow_shown_count(row_ids: &RailItemIds, visible_count: &Signal<usize>) -> usize {
let count = visible_count.get();
row_ids.borrow().iter().filter(|(p, _)| *p >= count).count()
}
fn overflow_focus_target(
row_ids: &RailItemIds,
visible_count: &Signal<usize>,
current_pos: usize,
nav: RailNav,
) -> Option<WidgetId> {
let count = visible_count.get();
let mut shown: Vec<(usize, WidgetId)> = row_ids
.borrow()
.iter()
.filter(|(p, _)| *p >= count)
.copied()
.collect();
shown.sort_by_key(|(p, _)| *p);
if shown.is_empty() {
return None;
}
let cur = shown.iter().position(|(p, _)| *p == current_pos)?;
let target = match nav {
RailNav::Prev => (cur + shown.len() - 1) % shown.len(),
RailNav::Next => (cur + 1) % shown.len(),
RailNav::First => 0,
RailNav::Last => shown.len() - 1,
};
Some(shown[target].1)
}
fn rail_insertion(
local_y: f32,
shown: &[(usize, Rect)],
bar_origin_y: f32,
bar_height: f32,
) -> (usize, f32) {
if shown.is_empty() {
return (0, (RAIL_PADDING).min(bar_height));
}
let mut vpos = shown.len();
for (i, (_, b)) in shown.iter().enumerate() {
let center = (b.y + b.height * 0.5) - bar_origin_y;
if local_y < center {
vpos = i;
break;
}
}
let line_y = if vpos == 0 {
let top = shown[0].1.y - bar_origin_y;
(top - RAIL_ITEM_SPACING * 0.5).max(0.0)
} else if vpos >= shown.len() {
let last = &shown[shown.len() - 1].1;
(last.y + last.height - bar_origin_y + RAIL_ITEM_SPACING * 0.5).min(bar_height)
} else {
let prev = &shown[vpos - 1].1;
let next = &shown[vpos].1;
let prev_bottom = prev.y + prev.height - bar_origin_y;
let next_top = next.y - bar_origin_y;
(prev_bottom + next_top) * 0.5
};
(vpos, line_y.clamp(0.0, bar_height))
}
#[derive(Debug)]
pub(crate) struct DockRailTabList {
side: DockSide,
stack: WidgetId,
}
impl DockRailTabList {
fn new(side: DockSide, stack: WidgetId) -> Self {
Self { side, stack }
}
}
impl Widget for DockRailTabList {
fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
ctx.child_size(self.stack, proposal)
.unwrap_or_else(|| proposal.resolve(0.0, 0.0))
.into()
}
fn place_children(
&self,
bounds: Rect,
_proposal: SizeProposal,
children: &mut [WidgetPlacement],
_ctx: &LayoutContext,
) {
for child in children.iter_mut() {
child.origin = bounds.origin();
child.size = bounds.size();
}
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
use teksilo_core::accesskit::{Orientation as A11yOrientation, Role};
builder.set_role(Role::TabList);
builder.set_name(super::a11y::rail_label(self.side).resolve_now());
builder.set_orientation(A11yOrientation::Vertical);
}
fn children(&self) -> Vec<WidgetId> {
vec![self.stack]
}
}
pub(crate) struct DockRailActionGroup {
side: DockSide,
placement: DockActionPlacement,
actions: Vec<DockAction>,
extent: f32,
glyph: f32,
labeled: bool,
roving: Signal<usize>,
item_ids: Rc<RefCell<Vec<WidgetId>>>,
root: Option<WidgetId>,
}
impl std::fmt::Debug for DockRailActionGroup {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DockRailActionGroup")
.field("side", &self.side)
.field("placement", &self.placement)
.field("actions", &self.actions.len())
.finish()
}
}
impl DockRailActionGroup {
fn new(
side: DockSide,
placement: DockActionPlacement,
actions: Vec<DockAction>,
extent: f32,
glyph: f32,
labeled: bool,
) -> Self {
Self {
side,
placement,
actions,
extent,
glyph,
labeled,
roving: Signal::new(0),
item_ids: Rc::new(RefCell::new(Vec::new())),
root: None,
}
}
}
impl Widget for DockRailActionGroup {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
self.item_ids.borrow_mut().clear();
let mut stack = VStack::new().spacing(RAIL_ITEM_SPACING);
let mut ids = Vec::with_capacity(self.actions.len());
for (i, action) in self.actions.iter().enumerate() {
let id = ctx.add(DockRailActionItem::new(
action.clone(),
i,
self.extent,
self.glyph,
self.labeled,
self.roving.clone(),
self.item_ids.clone(),
));
ids.push(id);
stack = stack.add_child(id);
}
*self.item_ids.borrow_mut() = ids;
if self.roving.get() >= self.actions.len() {
self.roving.set(0);
}
let root = ctx.add(stack);
self.root = Some(root);
vec![root]
}
fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
self.root
.and_then(|id| ctx.child_size(id, proposal))
.unwrap_or_else(|| proposal.resolve(0.0, 0.0))
.into()
}
fn place_children(
&self,
bounds: Rect,
_proposal: SizeProposal,
children: &mut [WidgetPlacement],
_ctx: &LayoutContext,
) {
for child in children.iter_mut() {
child.origin = bounds.origin();
child.size = bounds.size();
}
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
use teksilo_core::accesskit::{Orientation as A11yOrientation, Role};
builder.set_role(Role::Toolbar);
builder.set_name(super::a11y::rail_actions_label(self.side, self.placement).resolve_now());
builder.set_orientation(A11yOrientation::Vertical);
}
fn children(&self) -> Vec<WidgetId> {
self.root.into_iter().collect()
}
}
struct DockRailActionItem {
action: DockAction,
index: usize,
extent: f32,
glyph: f32,
labeled: bool,
roving: Signal<usize>,
siblings: Rc<RefCell<Vec<WidgetId>>>,
focused: Signal<bool>,
root: Option<WidgetId>,
}
impl std::fmt::Debug for DockRailActionItem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DockRailActionItem")
.field("index", &self.index)
.finish()
}
}
impl DockRailActionItem {
fn new(
action: DockAction,
index: usize,
extent: f32,
glyph: f32,
labeled: bool,
roving: Signal<usize>,
siblings: Rc<RefCell<Vec<WidgetId>>>,
) -> Self {
Self {
action,
index,
extent,
glyph,
labeled,
roving,
siblings,
focused: Signal::new(false),
root: None,
}
}
}
impl Widget for DockRailActionItem {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
let self_id = ctx.self_id();
let enabled = self.action.enabled.as_signal();
enabled.bind_to(self_id, ctx.binding_registry(), BindingLevel::RepaintOnly);
let toggled = self
.action
.toggled
.clone()
.unwrap_or_else(|| Signal::new(false));
toggled.bind_to(self_id, ctx.binding_registry(), BindingLevel::RepaintOnly);
let bg = toggled.zip(&ctx.window_active_signal()).map(|(t, win)| {
if *t {
if *win {
SurfaceRole::Selected
} else {
SurfaceRole::SelectedInactive
}
} else {
SurfaceRole::Transparent
}
});
let ring = self.focused.and(&ctx.focus_visible());
let focus_ring_width = ctx.theme().shape.focus_ring_width;
let border_color: ColorProp = ring
.map(|f| {
if *f {
BorderRole::Focused
} else {
BorderRole::Transparent
}
})
.into();
let border_width = ring.map(move |f| if *f { focus_ring_width } else { 0.0 });
let bg_rect = ctx.add(
RectWidget::new()
.background(bg)
.border_color(border_color)
.border_width(border_width)
.corner_radius(CornerRadius::uniform(ICON_BUTTON_CORNER_RADIUS)),
);
let glyph_color: ColorProp = enabled
.map(|e| {
if *e {
TextRole::Primary
} else {
TextRole::Disabled
}
})
.into();
let icon = ctx.add(
(self.action.icon)()
.icon_size(self.glyph)
.color(glyph_color.clone()),
);
let centered = ctx.add(Center::new().child_id(icon));
let icon_box = ctx.add(
FixedSize::new()
.width(self.extent)
.height(self.extent)
.child_id(centered),
);
let content = if self.labeled {
let label = ctx.add(RotatedLabel::new(
self.action.label.clone(),
Signal::new(TextRole::Secondary),
));
let stack = ctx.add(
VStack::new()
.alignment(HAlignment::Center)
.spacing(2.0)
.add_child(label)
.add_child(icon_box),
);
ctx.add(Padding::new(LABELED_TOP_MARGIN, 0.0, 0.0, 0.0).child_id(stack))
} else {
icon_box
};
let root = ctx.add(ZStack::new().add_child(bg_rect).add_child(content));
self.root = Some(root);
if !self.labeled {
let text = self
.action
.tooltip
.clone()
.unwrap_or_else(|| self.action.label.clone());
let delay = ctx.theme().motion.tooltip_delay;
crate::tooltip::attach_plain_tooltip_with_placement(
ctx,
root,
text,
delay,
crate::tooltip::TooltipPlacement::Side,
);
}
let activate: Rc<dyn Fn(&mut EventContext)> = {
let on_activate = self.action.on_activate.clone();
let enabled = enabled.clone();
let roving = self.roving.clone();
let index = self.index;
Rc::new(move |ctx: &mut EventContext| {
if !enabled.get() {
return;
}
roving.set(index);
(on_activate)(ctx);
})
};
let index = self.index;
ctx.set_tab_stop(self_id, self.roving.map(move |r| *r == index));
let focused_sig = self.focused.clone();
ctx.apply_self_handlers(
HandlerSet::new()
.on_tap({
let activate = activate.clone();
move |_e, ctx| activate(ctx)
})
.on_focus(move |gained, _ctx| focused_sig.set(gained))
.on_key({
let activate = activate.clone();
let siblings = self.siblings.clone();
let roving = self.roving.clone();
let index = self.index;
move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
let WidgetEvent::KeyDown { key, .. } = event else {
return EventResponse::Ignored;
};
let ids = siblings.borrow();
if ids.is_empty() {
return EventResponse::Ignored;
}
let next = match key {
Key::ArrowUp | Key::ArrowLeft => (index + ids.len() - 1) % ids.len(),
Key::ArrowDown | Key::ArrowRight => (index + 1) % ids.len(),
Key::Home => 0,
Key::End => ids.len() - 1,
Key::Enter | Key::Space => {
drop(ids);
activate(ctx);
return EventResponse::Handled;
}
_ => return EventResponse::Ignored,
};
let target = ids[next];
drop(ids);
roving.set(next);
ctx.request_focus(target);
EventResponse::Handled
}
})
.on_access_action({
let activate = activate.clone();
move |action: teksilo_core::accesskit::Action, ctx: &mut EventContext| {
if action == teksilo_core::accesskit::Action::Click {
activate(ctx);
EventResponse::Handled
} else {
EventResponse::Ignored
}
}
})
.focusable(true)
.cursor(CursorIcon::Pointer),
);
vec![root]
}
fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
self.root
.and_then(|id| ctx.child_size(id, proposal))
.unwrap_or_else(|| proposal.resolve(0.0, 0.0))
.into()
}
fn place_children(
&self,
bounds: Rect,
_proposal: SizeProposal,
children: &mut [WidgetPlacement],
_ctx: &LayoutContext,
) {
for child in children.iter_mut() {
child.origin = bounds.origin();
child.size = bounds.size();
}
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
use teksilo_core::accesskit::{Action, Role};
builder.set_role(Role::Button);
builder.set_name(self.action.label.resolve_now());
builder.add_action(Action::Focus);
if self.action.enabled.get() {
builder.add_action(Action::Click);
} else {
builder.set_disabled();
}
builder.set_position_in_set(self.index + 1);
builder.set_size_of_set(self.siblings.borrow().len());
if let Some(t) = &self.action.toggled {
builder.set_toggled(t.get());
}
}
fn children(&self) -> Vec<WidgetId> {
self.root.into_iter().collect()
}
}
struct RailDropIndicator {
y: Signal<Option<f32>>,
color: ColorProp,
}
impl std::fmt::Debug for RailDropIndicator {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RailDropIndicator").finish()
}
}
impl RailDropIndicator {
fn new(y: Signal<Option<f32>>) -> Self {
Self {
y,
color: ColorProp::from(BorderRole::Accent),
}
}
}
impl Widget for RailDropIndicator {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
self.y.bind_to(
ctx.self_id(),
ctx.binding_registry(),
BindingLevel::RepaintOnly,
);
ctx.apply_self_handlers(HandlerSet::new().event_pass_through(true));
vec![]
}
fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
proposal.resolve(0.0, 0.0).into()
}
fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
let Some(y) = self.y.get() else {
return;
};
let color = self.color.resolve(ctx.theme, true);
let t = 2.0;
let yy = bounds.y + y - t * 0.5;
let x = bounds.x + RAIL_PADDING;
let w = (bounds.width - RAIL_PADDING * 2.0).max(0.0);
canvas.fill_rect(Rect::new(x, yy, w, t), color);
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
builder.set_hidden();
}
}
struct RailEdgeDivider {
side: DockSide,
color: ColorProp,
}
impl std::fmt::Debug for RailEdgeDivider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RailEdgeDivider").finish()
}
}
impl Widget for RailEdgeDivider {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
ctx.apply_self_handlers(HandlerSet::new().event_pass_through(true));
vec![]
}
fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
proposal.resolve(0.0, 0.0).into()
}
fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
let rtl = matches!(
ctx.layout_direction,
teksilo_core::environment::LayoutDirection::RightToLeft
);
let content_on_right = match self.side {
DockSide::Trailing => rtl,
_ => !rtl,
};
let t = 1.0;
let x = if content_on_right {
bounds.x + bounds.width - t
} else {
bounds.x
};
let color = self.color.resolve(ctx.theme, true);
canvas.fill_rect(Rect::new(x, bounds.y, t, bounds.height), color);
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
builder.set_hidden();
}
}
#[derive(Debug)]
struct DockOverflowMenu {
side: DockSide,
model: DockingModel,
visible_count: Signal<usize>,
row_ids: RailItemIds,
root: Option<WidgetId>,
}
impl DockOverflowMenu {
fn new(side: DockSide, model: DockingModel, visible_count: Signal<usize>) -> Self {
Self {
side,
model,
visible_count,
row_ids: Rc::new(RefCell::new(Vec::new())),
root: None,
}
}
}
impl Widget for DockOverflowMenu {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
let tabs = self.model.side_tabs(self.side);
let mut column = VStack::new().spacing(2.0);
self.row_ids.borrow_mut().clear();
let mut pos = 0usize;
for (model_i, tab) in tabs.iter().enumerate() {
if tab.hidden {
continue;
}
let p = pos;
pos += 1;
let label = self.model.activity_label(tab);
let row = ctx.add(DockOverflowRow::new(
self.side,
model_i,
p,
tab.id,
label,
self.model.clone(),
self.row_ids.clone(),
self.visible_count.clone(),
));
self.row_ids.borrow_mut().push((p, row));
ctx.visible_when(row, self.visible_count.map(move |c| p >= *c));
column = column.add_child(row);
}
let column_id = ctx.add(column);
let root = ctx.add(Padding::uniform(4.0).child_id(column_id));
self.root = Some(root);
vec![root]
}
fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
self.root
.and_then(|id| ctx.child_size(id, proposal))
.unwrap_or_else(|| proposal.resolve(0.0, 0.0))
.into()
}
fn place_children(
&self,
bounds: Rect,
_proposal: SizeProposal,
children: &mut [WidgetPlacement],
_ctx: &LayoutContext,
) {
for child in children.iter_mut() {
child.origin = bounds.origin();
child.size = bounds.size();
}
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
use teksilo_core::accesskit::{Orientation, Role};
builder.set_role(Role::Menu);
builder.set_orientation(Orientation::Vertical);
}
fn children(&self) -> Vec<WidgetId> {
self.root.into_iter().collect()
}
}
struct DockRailItem {
side: DockSide,
index: usize,
pos: usize,
tab_id: DockTabId,
icon: Option<DockIconFactory>,
label: LocalizedString,
extent: f32,
glyph: f32,
labeled: bool,
selected: Signal<usize>,
visible: Signal<bool>,
model: DockingModel,
bounds_sink: RailItemBounds,
item_ids: RailItemIds,
visible_count: Signal<usize>,
side_panel_ids: Rc<RefCell<HashMap<DockSide, WidgetId>>>,
focused: Signal<bool>,
root: Option<WidgetId>,
}
impl std::fmt::Debug for DockRailItem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DockRailItem")
.field("index", &self.index)
.finish()
}
}
impl DockRailItem {
#[allow(clippy::too_many_arguments)]
fn new(
side: DockSide,
index: usize,
pos: usize,
tab_id: DockTabId,
icon: Option<DockIconFactory>,
label: LocalizedString,
extent: f32,
glyph: f32,
labeled: bool,
selected: Signal<usize>,
visible: Signal<bool>,
model: DockingModel,
bounds_sink: RailItemBounds,
item_ids: RailItemIds,
visible_count: Signal<usize>,
side_panel_ids: Rc<RefCell<HashMap<DockSide, WidgetId>>>,
) -> Self {
Self {
side,
index,
pos,
tab_id,
icon,
label,
extent,
glyph,
labeled,
selected,
visible,
model,
bounds_sink,
item_ids,
visible_count,
side_panel_ids,
focused: Signal::new(false),
root: None,
}
}
}
impl Widget for DockRailItem {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
let idx = self.index;
let active = self
.selected
.zip(&self.visible)
.map(move |(s, v)| *s == idx && *v);
let bg = active.zip(&ctx.window_active_signal()).map(|(a, win)| {
if *a {
if *win {
SurfaceRole::Selected
} else {
SurfaceRole::SelectedInactive
}
} else {
SurfaceRole::Transparent
}
});
let ring = self.focused.and(&ctx.focus_visible());
let focus_ring_width = ctx.theme().shape.focus_ring_width;
let border_color: ColorProp = ring
.map(|f| {
if *f {
BorderRole::Focused
} else {
BorderRole::Transparent
}
})
.into();
let border_width = ring.map(move |f| if *f { focus_ring_width } else { 0.0 });
let bg_rect = ctx.add(
RectWidget::new()
.background(bg)
.border_color(border_color)
.border_width(border_width)
.corner_radius(CornerRadius::uniform(ICON_BUTTON_CORNER_RADIUS)),
);
let glyph = if let Some(icon) = self.icon.take() {
ctx.add((icon)().icon_size(self.glyph))
} else {
let s = self.label.resolve_now();
let ch: String = s.chars().take(1).collect();
ctx.add(
TextWidget::new(lit!(ch))
.style(TextStyleRole::BodyBold)
.color(TextRole::Primary),
)
};
let centered = ctx.add(Center::new().child_id(glyph));
let icon_box = ctx.add(
FixedSize::new()
.width(self.extent)
.height(self.extent)
.child_id(centered),
);
let content = if self.labeled {
let label = ctx.add(RotatedLabel::new(
self.label.clone(),
Signal::new(TextRole::Secondary),
));
let stack = ctx.add(
VStack::new()
.alignment(HAlignment::Center)
.spacing(2.0)
.add_child(label)
.add_child(icon_box),
);
ctx.add(Padding::new(LABELED_TOP_MARGIN, 0.0, 0.0, 0.0).child_id(stack))
} else {
icon_box
};
let root = ctx.add(ZStack::new().add_child(bg_rect).add_child(content));
self.root = Some(root);
if !self.labeled {
let delay = ctx.theme().motion.tooltip_delay;
crate::tooltip::attach_plain_tooltip_with_placement(
ctx,
root,
self.label.clone(),
delay,
crate::tooltip::TooltipPlacement::Side,
);
}
let self_id = ctx.self_id();
let policy = self.model.policy();
let side = self.side;
let tab_id = self.tab_id;
let menu_model = self.model.clone();
let allow_collapse = policy.allow_side_collapse;
let activate: Rc<dyn Fn(&mut EventContext)> = {
let model = self.model.clone();
let selected = self.selected.clone();
let visible = self.visible.clone();
Rc::new(move |_ctx: &mut EventContext| {
if selected.get() == idx && visible.get() {
if allow_collapse {
model.set_side_visible(side, false);
}
} else {
model.select_tab(side, idx);
model.set_side_visible(side, true);
}
})
};
let focused_sig = self.focused.clone();
ctx.set_tab_stop(self_id, self.selected.map(move |s| *s == idx));
let mut handlers = HandlerSet::new()
.on_tap({
let activate = activate.clone();
move |_e, ctx| activate(ctx)
})
.on_focus(move |gained, _ctx| focused_sig.set(gained))
.on_key({
let activate = activate.clone();
let item_ids = self.item_ids.clone();
let visible_count = self.visible_count.clone();
let pos = self.pos;
move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
let WidgetEvent::KeyDown { key, .. } = event else {
return EventResponse::Ignored;
};
let nav = match key {
Key::ArrowUp | Key::ArrowLeft => RailNav::Prev,
Key::ArrowDown | Key::ArrowRight => RailNav::Next,
Key::Home => RailNav::First,
Key::End => RailNav::Last,
Key::Enter | Key::Space => {
activate(ctx);
return EventResponse::Handled;
}
_ => return EventResponse::Ignored,
};
if let Some(target) = rail_focus_target(&item_ids, &visible_count, pos, nav) {
ctx.request_focus(target);
EventResponse::Handled
} else {
EventResponse::Ignored
}
}
})
.on_access_action({
let activate = activate.clone();
move |action: teksilo_core::accesskit::Action, ctx: &mut EventContext| {
if action == teksilo_core::accesskit::Action::Click {
activate(ctx);
EventResponse::Handled
} else {
EventResponse::Ignored
}
}
});
if policy.allow_activity_drag {
handlers = handlers.on_drag(move |phase, ctx| {
if let DragPhase::Started { .. } = phase {
ctx.start_drag(
self_id,
DragPayload::typed(DockTabDragData {
tab_id,
source_side: side,
}),
);
}
});
}
handlers = handlers
.context_menu(move |_pos, _ctx| {
Some(Box::new(activity_context_menu(
&menu_model,
side,
tab_id,
DockMenuKind::Rail,
)))
})
.focusable(true)
.cursor(CursorIcon::Pointer);
ctx.apply_self_handlers(handlers);
vec![root]
}
fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
self.root
.and_then(|id| ctx.child_size(id, proposal))
.unwrap_or_else(|| proposal.resolve(0.0, 0.0))
.into()
}
fn place_children(
&self,
bounds: Rect,
_proposal: SizeProposal,
children: &mut [WidgetPlacement],
_ctx: &LayoutContext,
) {
{
let mut sink = self.bounds_sink.borrow_mut();
if let Some(slot) = sink.iter_mut().find(|(p, _)| *p == self.pos) {
slot.1 = bounds;
} else {
sink.push((self.pos, bounds));
}
}
for child in children.iter_mut() {
child.origin = bounds.origin();
child.size = bounds.size();
}
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
use teksilo_core::accesskit::{Action, Role};
builder.set_role(Role::Tab);
builder.set_name(self.label.resolve_now());
let is_selected = self.selected.get() == self.index;
builder.set_selected(is_selected && self.visible.get());
builder.add_action(Action::Focus);
builder.add_action(Action::Click);
builder.set_position_in_set(self.pos + 1);
builder.set_size_of_set(shown_rail_count(&self.item_ids, &self.visible_count));
if is_selected {
builder.set_expanded(self.visible.get());
}
if self.visible.get()
&& let Some(&panel_id) = self.side_panel_ids.borrow().get(&self.side)
{
builder.push_controlled(widget_id_to_node_id(panel_id));
}
}
fn children(&self) -> Vec<WidgetId> {
self.root.into_iter().collect()
}
}
#[derive(Debug)]
struct DockOverflowRow {
side: DockSide,
index: usize,
pos: usize,
tab_id: DockTabId,
label: LocalizedString,
model: DockingModel,
row_ids: RailItemIds,
visible_count: Signal<usize>,
focused: Signal<bool>,
root: Option<WidgetId>,
}
impl DockOverflowRow {
#[allow(clippy::too_many_arguments)]
fn new(
side: DockSide,
index: usize,
pos: usize,
tab_id: DockTabId,
label: LocalizedString,
model: DockingModel,
row_ids: RailItemIds,
visible_count: Signal<usize>,
) -> Self {
Self {
side,
index,
pos,
tab_id,
label,
model,
row_ids,
visible_count,
focused: Signal::new(false),
root: None,
}
}
}
impl Widget for DockOverflowRow {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
let label = ctx.add(
TextWidget::new(self.label.clone())
.style(TextStyleRole::Body)
.color(TextRole::Primary)
.single_line(),
);
let spacer = ctx.add(Spacer::new());
let row = ctx.add(
HStack::new()
.spacing(8.0)
.add_child(label)
.add_child(spacer),
);
let content = ctx.add(Padding::symmetric(6.0, 10.0).child_id(row));
let ring = self.focused.and(&ctx.focus_visible());
let focus_ring_width = ctx.theme().shape.focus_ring_width;
let bg_role: ColorProp = self
.focused
.map(|f| {
if *f {
SurfaceRole::Hover
} else {
SurfaceRole::Transparent
}
})
.into();
let border_color: ColorProp = ring
.map(|f| {
if *f {
BorderRole::Focused
} else {
BorderRole::Transparent
}
})
.into();
let border_width = ring.map(move |f| if *f { focus_ring_width } else { 0.0 });
let bg_rect = ctx.add(
RectWidget::new()
.background(bg_role)
.border_color(border_color)
.border_width(border_width)
.corner_radius(CornerRadius::uniform(ICON_BUTTON_CORNER_RADIUS)),
);
let root = ctx.add(ZStack::new().add_child(bg_rect).add_child(content));
self.root = Some(root);
let activate: Rc<dyn Fn(&mut EventContext)> = {
let model = self.model.clone();
let side = self.side;
let idx = self.index;
Rc::new(move |_ctx: &mut EventContext| {
model.select_tab(side, idx);
model.set_side_visible(side, true);
})
};
let focused_sig = self.focused.clone();
ctx.apply_self_handlers(
HandlerSet::new()
.on_tap({
let activate = activate.clone();
move |_e, ctx| activate(ctx)
})
.on_focus(move |gained, _ctx| focused_sig.set(gained))
.on_key({
let activate = activate.clone();
let row_ids = self.row_ids.clone();
let visible_count = self.visible_count.clone();
let pos = self.pos;
move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
let WidgetEvent::KeyDown { key, .. } = event else {
return EventResponse::Ignored;
};
let nav = match key {
Key::ArrowUp | Key::ArrowLeft => RailNav::Prev,
Key::ArrowDown | Key::ArrowRight => RailNav::Next,
Key::Home => RailNav::First,
Key::End => RailNav::Last,
Key::Enter | Key::Space => {
activate(ctx);
return EventResponse::Handled;
}
_ => return EventResponse::Ignored,
};
if let Some(target) =
overflow_focus_target(&row_ids, &visible_count, pos, nav)
{
ctx.request_focus(target);
EventResponse::Handled
} else {
EventResponse::Ignored
}
}
})
.on_access_action({
let activate = activate.clone();
move |action: teksilo_core::accesskit::Action, ctx: &mut EventContext| {
if action == teksilo_core::accesskit::Action::Click {
activate(ctx);
EventResponse::Handled
} else {
EventResponse::Ignored
}
}
})
.focusable(true)
.cursor(CursorIcon::Pointer),
);
vec![root]
}
fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
self.root
.and_then(|id| ctx.child_size(id, proposal))
.unwrap_or_else(|| proposal.resolve(0.0, 0.0))
.into()
}
fn place_children(
&self,
bounds: Rect,
_proposal: SizeProposal,
children: &mut [WidgetPlacement],
_ctx: &LayoutContext,
) {
for child in children.iter_mut() {
child.origin = bounds.origin();
child.size = bounds.size();
}
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
use teksilo_core::accesskit::{Action, Role};
builder.set_role(Role::MenuItem);
builder.set_name(self.label.resolve_now());
builder.add_action(Action::Focus);
builder.add_action(Action::Click);
let count = self.visible_count.get();
builder.set_position_in_set(self.pos.saturating_sub(count) + 1);
builder.set_size_of_set(overflow_shown_count(&self.row_ids, &self.visible_count));
}
fn children(&self) -> Vec<WidgetId> {
self.root.into_iter().collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rail_insertion_picks_the_gap_under_the_pointer() {
let items = vec![
(0usize, Rect::new(100.0, 100.0, 40.0, 40.0)),
(1, Rect::new(100.0, 142.0, 40.0, 40.0)),
(2, Rect::new(100.0, 184.0, 40.0, 40.0)),
];
assert_eq!(
rail_insertion(5.0, &items, 100.0, 300.0).0,
0,
"above all → front"
);
assert_eq!(
rail_insertion(40.0, &items, 100.0, 300.0).0,
1,
"past item 0 → 1"
);
assert_eq!(
rail_insertion(70.0, &items, 100.0, 300.0).0,
2,
"past item 1 → 2"
);
assert_eq!(
rail_insertion(290.0, &items, 100.0, 300.0).0,
3,
"below all → end"
);
}
#[test]
fn rail_insertion_on_empty_rail_is_front() {
assert_eq!(rail_insertion(50.0, &[], 0.0, 100.0).0, 0);
}
fn cap() -> RailCapacity {
RailCapacity {
height: 260.0,
stride: 42.0,
slots: 0,
actions: 0,
total: 8,
has_overflow_trigger: false,
}
}
#[test]
fn capacity_shows_everything_when_it_all_fits() {
let c = RailCapacity { total: 4, ..cap() };
assert_eq!(shown_capacity(c), 4, "no overflow ⇒ every item shows");
}
#[test]
fn capacity_clips_without_a_trigger_and_reserves_one_slot_with_one() {
assert_eq!(
shown_capacity(cap()),
6,
"no trigger ⇒ the surplus is clipped"
);
assert_eq!(
shown_capacity(RailCapacity {
has_overflow_trigger: true,
..cap()
}),
5,
"the trigger itself costs one slot"
);
}
#[test]
fn capacity_charges_actions_and_slots() {
assert_eq!(
shown_capacity(RailCapacity {
actions: 3,
..cap()
}),
3,
"three actions cost three activity slots (6 → 3)"
);
assert_eq!(
shown_capacity(RailCapacity { slots: 2, ..cap() }),
4,
"top_slot + bottom_slot cost one stride each"
);
assert_eq!(
shown_capacity(RailCapacity {
slots: 2,
actions: 3,
has_overflow_trigger: true,
..cap()
}),
0,
"a rail crowded past its height shows no activities, and never \
underflows"
);
}
#[test]
fn capacity_never_underflows_or_divides_by_zero() {
assert_eq!(
shown_capacity(RailCapacity {
height: 0.0,
has_overflow_trigger: true,
..cap()
}),
0,
"a zero-height rail shows nothing rather than wrapping around"
);
assert_eq!(
shown_capacity(RailCapacity {
stride: 0.0,
..cap()
}),
8,
"a degenerate stride falls back to showing everything, not a divide by zero"
);
}
fn wid(n: u64) -> WidgetId {
slotmap::KeyData::from_ffi(n).into()
}
fn ids(items: &[(usize, u64)]) -> RailItemIds {
Rc::new(RefCell::new(
items.iter().map(|(p, w)| (*p, wid(*w))).collect(),
))
}
#[test]
fn rail_focus_target_wraps_among_shown_items() {
let item_ids = ids(&[(0, 10), (1, 11), (2, 12)]);
let vc = Signal::new(3usize);
assert_eq!(
rail_focus_target(&item_ids, &vc, 0, RailNav::Next),
Some(wid(11))
);
assert_eq!(
rail_focus_target(&item_ids, &vc, 2, RailNav::Next),
Some(wid(10)),
"ArrowDown past the last item wraps to the first"
);
assert_eq!(
rail_focus_target(&item_ids, &vc, 0, RailNav::Prev),
Some(wid(12)),
"ArrowUp before the first item wraps to the last"
);
assert_eq!(
rail_focus_target(&item_ids, &vc, 1, RailNav::First),
Some(wid(10))
);
assert_eq!(
rail_focus_target(&item_ids, &vc, 1, RailNav::Last),
Some(wid(12))
);
}
#[test]
fn rail_focus_target_skips_overflowed_items() {
let item_ids = ids(&[(0, 10), (1, 11), (2, 12)]);
let vc = Signal::new(2usize);
assert_eq!(shown_rail_count(&item_ids, &vc), 2);
assert_eq!(
rail_focus_target(&item_ids, &vc, 1, RailNav::Next),
Some(wid(10)),
"nav wraps within the two shown items, skipping the overflowed one"
);
}
#[test]
fn overflow_helpers_target_the_parked_rows() {
let row_ids = ids(&[(0, 10), (1, 11), (2, 12), (3, 13)]);
let vc = Signal::new(2usize);
assert_eq!(overflow_shown_count(&row_ids, &vc), 2);
assert_eq!(
overflow_focus_target(&row_ids, &vc, 2, RailNav::Next),
Some(wid(13))
);
assert_eq!(
overflow_focus_target(&row_ids, &vc, 3, RailNav::Next),
Some(wid(12)),
"nav wraps within the overflowed set"
);
assert_eq!(
overflow_focus_target(&row_ids, &vc, 2, RailNav::Prev),
Some(wid(13))
);
}
}