use std::cell::Cell;
use std::rc::Rc;
use std::time::Duration;
use teksilo_canvas::{Point, Rect, Size, SizeProposal};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::accesskit::{Action, Orientation as A11yOrientation, Role};
use teksilo_core::binding::BindingLevel;
use teksilo_core::build_context::BuildContext;
use teksilo_core::event::{EventResponse, Key, PointerButton, WidgetEvent};
use teksilo_core::focus::FocusOrigin;
use teksilo_core::signal::Signal;
use teksilo_core::styles::{SharedSplitterStyle, SplitterStyleConfig};
use teksilo_core::widget::{
CursorIcon, EventContext, LayoutContext, LayoutResponse, Widget, WidgetPlacement,
};
use teksilo_core::widget_builder::HandlerSet;
use teksilo_core::widget_id::WidgetId;
use teksilo_tokens::{Easing, Orientation};
use super::model::SplitterModel;
const HOVER_DWELL_TOTAL: Duration = Duration::from_millis(400);
const HOVER_FADE_OUT: Duration = Duration::from_millis(120);
pub(super) struct SplitterHandleConfig {
pub model: SplitterModel,
pub index: usize,
pub enabled: bool,
pub gutter_thickness: f32,
pub style: SharedSplitterStyle,
pub container_bounds: Rc<Cell<Rect>>,
pub is_rtl: Rc<Cell<bool>>,
pub layout_sizes: Rc<std::cell::RefCell<Vec<f32>>>,
pub layout_gutters: Rc<std::cell::RefCell<Vec<f32>>>,
pub active: Signal<bool>,
}
pub(super) struct SplitterHandle {
model: SplitterModel,
index: usize,
enabled: bool,
gutter_thickness: f32,
style: SharedSplitterStyle,
container_bounds: Rc<Cell<Rect>>,
is_rtl: Rc<Cell<bool>>,
layout_sizes: Rc<std::cell::RefCell<Vec<f32>>>,
layout_gutters: Rc<std::cell::RefCell<Vec<f32>>>,
active: Signal<bool>,
self_bounds: Rc<Cell<Rect>>,
is_hovered: Signal<bool>,
is_dragging: Signal<bool>,
focus_origin: Signal<Option<FocusOrigin>>,
hover_progress: Signal<f32>,
body_id: Option<WidgetId>,
}
impl SplitterHandle {
pub(super) fn new(config: SplitterHandleConfig) -> Self {
Self {
model: config.model,
index: config.index,
enabled: config.enabled,
gutter_thickness: config.gutter_thickness,
style: config.style,
container_bounds: config.container_bounds,
is_rtl: config.is_rtl,
layout_sizes: config.layout_sizes,
layout_gutters: config.layout_gutters,
active: config.active,
self_bounds: Rc::new(Cell::new(Rect::ZERO)),
is_hovered: Signal::new(false),
is_dragging: Signal::new(false),
focus_origin: Signal::new(None),
hover_progress: Signal::new_animated(0.0),
body_id: None,
}
}
}
impl std::fmt::Debug for SplitterHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SplitterHandle")
.field("index", &self.index)
.field("enabled", &self.enabled)
.finish()
}
}
fn container_main(
p: Point,
self_bounds: Rect,
container: Rect,
orientation: Orientation,
rtl: bool,
) -> f32 {
match orientation {
Orientation::Horizontal => {
let window_x = p.x + self_bounds.x;
if rtl {
container.x + container.width - window_x
} else {
window_x - container.x
}
}
Orientation::Vertical => p.y + self_bounds.y - container.y,
}
}
fn geometry(sizes: &[f32], gutters: &[f32], index: usize) -> Option<(f32, f32, f32, f32)> {
if index + 1 >= sizes.len() {
return None;
}
let take = index.min(gutters.len());
let pre: f32 = sizes[..index].iter().sum::<f32>() + gutters[..take].iter().sum::<f32>();
let gut = gutters.get(index).copied().unwrap_or(0.0);
Some((pre, sizes[index], sizes[index + 1], gut))
}
fn collapse_target(model: &SplitterModel, i: usize) -> Option<usize> {
let j = i + 1;
if model.is_collapsed(i) && model.is_collapsible(i) {
Some(i)
} else if model.is_collapsed(j) && model.is_collapsible(j) {
Some(j)
} else if model.is_collapsible(i) {
Some(i)
} else if model.is_collapsible(j) {
Some(j)
} else {
None
}
}
impl Widget for SplitterHandle {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
let self_id = ctx.self_id();
ctx.register_animated_signal(&self.hover_progress);
let enabled = self.enabled;
let orientation = self.model.orientation();
let resize_cursor = match orientation {
Orientation::Horizontal => CursorIcon::ColResize,
Orientation::Vertical => CursorIcon::RowResize,
};
let cfg = SplitterStyleConfig {
orientation,
is_hovered: self.is_hovered.clone(),
is_dragging: self.is_dragging.clone(),
is_disabled: Signal::new(!enabled),
focus_origin: self.focus_origin.clone(),
hover_progress: self.hover_progress.clone(),
};
let body_id = self.style.make_handle(&cfg, ctx);
self.body_id = Some(body_id);
let index = self.index;
let gutter = self.gutter_thickness;
let model = self.model.clone();
let container_bounds = self.container_bounds.clone();
let is_rtl = self.is_rtl.clone();
let layout_sizes = self.layout_sizes.clone();
let layout_gutters = self.layout_gutters.clone();
let drag_offset = Rc::new(Cell::new(0.0_f32));
let drag_pre = Rc::new(Cell::new(0.0_f32));
let drag_pair = Rc::new(Cell::new(0.0_f32));
let drag_gutter = Rc::new(Cell::new(gutter));
let mut handlers = HandlerSet::new().focusable(enabled).cursor(if enabled {
resize_cursor
} else {
CursorIcon::Default
});
{
let model = model.clone();
let container_bounds = container_bounds.clone();
let is_rtl = is_rtl.clone();
let layout_sizes = layout_sizes.clone();
let layout_gutters = layout_gutters.clone();
let self_bounds = self.self_bounds.clone();
let is_dragging = self.is_dragging.clone();
let focus_origin = self.focus_origin.clone();
let drag_offset = drag_offset.clone();
let drag_pre = drag_pre.clone();
let drag_pair = drag_pair.clone();
let drag_gutter = drag_gutter.clone();
let expanding_i = Rc::new(Cell::new(false));
let expanding_j = Rc::new(Cell::new(false));
handlers = handlers.on_pointer_event(move |event, ctx: &mut EventContext| {
if !enabled {
return EventResponse::Ignored;
}
match event {
WidgetEvent::PointerDown {
position, button, ..
} => {
if *button != PointerButton::Primary {
return EventResponse::Ignored;
}
let sizes = layout_sizes.borrow().clone();
let gutters = layout_gutters.borrow().clone();
let Some((pre, size_i, size_ip1, gut)) = geometry(&sizes, &gutters, index)
else {
return EventResponse::Ignored;
};
let container = container_bounds.get();
let rtl = is_rtl.get();
let p_main = container_main(
*position,
self_bounds.get(),
container,
orientation,
rtl,
);
let handle_center = pre + size_i + gut / 2.0;
drag_offset.set(p_main - handle_center);
drag_pre.set(pre);
drag_pair.set(size_i + size_ip1);
drag_gutter.set(gut);
expanding_i.set(false);
expanding_j.set(false);
is_dragging.set(true);
focus_origin.set(Some(FocusOrigin::Pointer));
ctx.capture_pointer();
ctx.request_focus(self_id);
EventResponse::Ignored
}
WidgetEvent::PointerMove { position } => {
if !is_dragging.get() {
return EventResponse::Ignored;
}
let container = container_bounds.get();
let rtl = is_rtl.get();
let p_main = container_main(
*position,
self_bounds.get(),
container,
orientation,
rtl,
);
let pre = drag_pre.get();
let pair = drag_pair.get();
let raw_i = p_main - drag_offset.get() - drag_gutter.get() / 2.0 - pre;
let i = index;
let j = index + 1;
let min_i = model.min_size(i);
let min_j = model.min_size(j);
let snap = model.snap_offset();
let restore_out = (snap * 0.6).max(12.0);
let collapse_back = (snap * 0.2).max(4.0);
let raw_j = pair - raw_i;
if model.is_collapsible(i) {
if model.is_collapsed(i) {
if raw_i > restore_out {
model.set_collapsed_immediate(i, false);
expanding_i.set(true);
} else {
return EventResponse::Handled;
}
} else if expanding_i.get() {
if raw_i >= min_i {
expanding_i.set(false); } else if raw_i < collapse_back {
expanding_i.set(false);
model.set_collapsed_immediate(i, true);
model.set_pair_sizes(i, 0.0, pair);
is_dragging.set(false);
ctx.release_pointer();
return EventResponse::Handled;
}
} else if raw_i < min_i - snap {
model.set_collapsed_immediate(i, true);
model.set_pair_sizes(i, 0.0, pair);
is_dragging.set(false);
ctx.release_pointer();
return EventResponse::Handled;
}
}
if model.is_collapsible(j) {
if model.is_collapsed(j) {
if raw_j > restore_out {
model.set_collapsed_immediate(j, false);
expanding_j.set(true);
} else {
return EventResponse::Handled;
}
} else if expanding_j.get() {
if raw_j >= min_j {
expanding_j.set(false);
} else if raw_j < collapse_back {
expanding_j.set(false);
model.set_collapsed_immediate(j, true);
model.set_pair_sizes(i, pair, 0.0);
is_dragging.set(false);
ctx.release_pointer();
return EventResponse::Handled;
}
} else if raw_j < min_j - snap {
model.set_collapsed_immediate(j, true);
model.set_pair_sizes(i, pair, 0.0);
is_dragging.set(false);
ctx.release_pointer();
return EventResponse::Handled;
}
}
let lo = 0.0_f32;
let hi = pair;
let new_i = raw_i.clamp(lo, hi);
model.set_pair_sizes(i, new_i, pair - new_i);
EventResponse::Handled
}
WidgetEvent::PointerUp { .. } => {
if is_dragging.get() {
is_dragging.set(false);
ctx.release_pointer();
}
EventResponse::Ignored
}
_ => EventResponse::Ignored,
}
});
}
{
let model = model.clone();
handlers = handlers.on_double_tap(move |_event, _ctx| {
if !enabled {
return;
}
if let Some(t) = collapse_target(&model, index) {
model.toggle_collapsed(t);
}
});
}
{
let is_hovered = self.is_hovered.clone();
let hover_progress = self.hover_progress.clone();
handlers = handlers.on_hover(move |entered, _ctx| {
if !enabled {
is_hovered.set(false);
hover_progress.animate_to(0.0, HOVER_FADE_OUT, Easing::Linear);
return;
}
is_hovered.set(entered);
if entered {
hover_progress.animate_to(1.0, HOVER_DWELL_TOTAL, Easing::Linear);
} else {
hover_progress.animate_to(0.0, HOVER_FADE_OUT, Easing::Linear);
}
});
}
{
let focus_origin = self.focus_origin.clone();
let hovered = self.is_hovered.clone();
handlers = handlers.on_focus(move |gained, _ctx| {
if gained {
let origin = if hovered.get() {
FocusOrigin::Pointer
} else {
FocusOrigin::Keyboard
};
focus_origin.set(Some(origin));
} else {
focus_origin.set(None);
}
});
}
{
let model = model.clone();
let layout_sizes = layout_sizes.clone();
let layout_gutters = layout_gutters.clone();
let focus_origin = self.focus_origin.clone();
handlers = handlers.on_key(move |event, _ctx| {
if !enabled {
return EventResponse::Ignored;
}
let WidgetEvent::KeyDown { key, .. } = event else {
return EventResponse::Ignored;
};
if matches!(key, Key::Enter) {
if let Some(t) = collapse_target(&model, index) {
model.toggle_collapsed(t);
return EventResponse::Handled;
}
return EventResponse::Ignored;
}
let sizes = layout_sizes.borrow().clone();
let gutters = layout_gutters.borrow().clone();
let Some((_pre, size_i, size_ip1, _gut)) = geometry(&sizes, &gutters, index) else {
return EventResponse::Ignored;
};
let i = index;
let j = index + 1;
let pair = size_i + size_ip1;
let lo = if model.is_collapsed(i) {
0.0
} else {
model.min_size(i)
};
let hi = if model.is_collapsed(j) {
pair
} else {
pair - model.min_size(j)
};
let step = model.keyboard_step_px();
let grow = match (orientation, key) {
(Orientation::Horizontal, Key::ArrowRight) => Some(true),
(Orientation::Horizontal, Key::ArrowLeft) => Some(false),
(Orientation::Vertical, Key::ArrowDown) => Some(true),
(Orientation::Vertical, Key::ArrowUp) => Some(false),
(_, Key::Home) => {
commit_resize(&model, i, lo, pair);
focus_origin.set(Some(FocusOrigin::Keyboard));
return EventResponse::Handled;
}
(_, Key::End) => {
commit_resize(&model, i, hi, pair);
focus_origin.set(Some(FocusOrigin::Keyboard));
return EventResponse::Handled;
}
_ => None,
};
let Some(grow) = grow else {
return EventResponse::Ignored;
};
let target = if grow { size_i + step } else { size_i - step };
let new_i = if lo > hi {
pair * 0.5
} else {
target.clamp(lo, hi)
};
commit_resize(&model, i, new_i, pair);
focus_origin.set(Some(FocusOrigin::Keyboard));
EventResponse::Handled
});
}
{
let model = model.clone();
let layout_sizes = layout_sizes.clone();
let layout_gutters = layout_gutters.clone();
handlers = handlers.on_access_action(move |action, _ctx| {
if !enabled {
return EventResponse::Ignored;
}
match action {
Action::Increment | Action::Decrement => {
let sizes = layout_sizes.borrow().clone();
let gutters = layout_gutters.borrow().clone();
let Some((_pre, size_i, size_ip1, _gut)) =
geometry(&sizes, &gutters, index)
else {
return EventResponse::Ignored;
};
let i = index;
let j = index + 1;
let pair = size_i + size_ip1;
let lo = if model.is_collapsed(i) {
0.0
} else {
model.min_size(i)
};
let hi = if model.is_collapsed(j) {
pair
} else {
pair - model.min_size(j)
};
let step = model.keyboard_step_px();
let delta = if matches!(action, Action::Increment) {
step
} else {
-step
};
let new_i = if lo > hi {
pair * 0.5
} else {
(size_i + delta).clamp(lo, hi)
};
commit_resize(&model, i, new_i, pair);
EventResponse::Handled
}
Action::Collapse => {
if let Some(t) = collapse_target(&model, index) {
model.set_collapsed(t, true);
return EventResponse::Handled;
}
EventResponse::Ignored
}
Action::Expand => {
if let Some(t) = collapse_target(&model, index) {
model.set_collapsed(t, false);
return EventResponse::Handled;
}
EventResponse::Ignored
}
_ => EventResponse::Ignored,
}
});
}
ctx.apply_self_handlers(handlers);
let registry = ctx.binding_registry();
self.hover_progress
.bind_to(self_id, registry, BindingLevel::RepaintOnly);
vec![body_id]
}
fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
match self.model.orientation() {
Orientation::Horizontal => Size::new(
self.gutter_thickness,
proposal.height.unwrap_or(self.gutter_thickness),
),
Orientation::Vertical => Size::new(
proposal.width.unwrap_or(self.gutter_thickness),
self.gutter_thickness,
),
}
.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();
}
}
fn children(&self) -> Vec<WidgetId> {
self.body_id.into_iter().collect()
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
if !self.active.get() {
builder.set_hidden();
return;
}
builder.set_role(Role::Splitter);
builder.set_name(teksilo_i18n::tr_widget!(a11y_splitter_divider_name()).resolve_now());
let sizes = self.layout_sizes.borrow();
let gutters = self.layout_gutters.borrow();
let (size_i, pair) = match geometry(&sizes, &gutters, self.index) {
Some((_pre, a, b, _g)) => (a, a + b),
None => (0.0, 0.0),
};
drop(sizes);
drop(gutters);
let frac = if pair > 0.0 { size_i / pair } else { 0.5 };
builder.set_numeric_value((frac * 100.0) as f64);
builder.set_min_numeric_value(0.0);
builder.set_max_numeric_value(100.0);
builder.set_value(format!("{:.0}%", frac * 100.0));
if pair > 0.0 {
builder.set_numeric_value_step((self.model.keyboard_step_px() / pair * 100.0) as f64);
}
let handle_orientation = match self.model.orientation() {
Orientation::Horizontal => A11yOrientation::Vertical,
Orientation::Vertical => A11yOrientation::Horizontal,
};
builder.set_orientation(handle_orientation);
let i = self.index;
let j = self.index + 1;
let collapsible_neighbor = if self.model.is_collapsible(i) {
Some(i)
} else if self.model.is_collapsible(j) {
Some(j)
} else {
None
};
if let Some(n) = collapsible_neighbor {
builder.set_expanded(!self.model.is_collapsed(n));
}
if !self.enabled {
builder.set_disabled();
} else {
builder.add_action(Action::Focus);
builder.add_action(Action::Increment);
builder.add_action(Action::Decrement);
if collapsible_neighbor.is_some() {
builder.add_action(Action::Collapse);
builder.add_action(Action::Expand);
}
}
}
}
fn commit_resize(model: &SplitterModel, i: usize, new_i: f32, pair: f32) {
let clamped = new_i.clamp(0.0, pair);
model.set_pair_sizes(i, clamped, pair - clamped);
}