1use teksilo_canvas::{Canvas, Rect, SizeProposal};
36use teksilo_core::accessibility::AccessNodeBuilder;
37use teksilo_core::binding::BindingLevel;
38use teksilo_core::build_context::BuildContext;
39use teksilo_core::focus::FocusOrigin;
40use teksilo_core::signal::Signal;
41use teksilo_core::styles::{SplitterStyle, SplitterStyleConfig};
42use teksilo_core::widget::{LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement};
43use teksilo_core::widget_id::WidgetId;
44use teksilo_tokens::{InputTokens, Orientation};
45
46pub const SPLITTER_DIVIDER_LINE_THICKNESS: f32 = 1.0;
48
49const HOVER_DWELL_DELAY_FRAC: f32 = 0.75;
53
54#[derive(Debug, Clone, Copy, PartialEq)]
56pub struct SplitterRecipe {
57 pub divider_line_thickness: f32,
59}
60
61impl SplitterRecipe {
62 pub fn for_tokens(_tokens: &InputTokens) -> Self {
73 Self {
74 divider_line_thickness: SPLITTER_DIVIDER_LINE_THICKNESS,
75 }
76 }
77}
78
79impl Default for SplitterRecipe {
80 fn default() -> Self {
81 Self::for_tokens(&InputTokens::default())
82 }
83}
84
85#[derive(Debug, Default, Clone, Copy)]
88pub struct RecipeSplitterStyle {
89 pub recipe: SplitterRecipe,
90}
91
92impl RecipeSplitterStyle {
93 pub fn new(recipe: SplitterRecipe) -> Self {
94 Self { recipe }
95 }
96
97 pub fn for_tokens(tokens: &InputTokens) -> Self {
104 Self {
105 recipe: SplitterRecipe::for_tokens(tokens),
106 }
107 }
108}
109
110impl SplitterStyle for RecipeSplitterStyle {
111 fn make_handle(&self, cfg: &SplitterStyleConfig, ctx: &mut BuildContext) -> WidgetId {
112 ctx.add(SplitterHandleBody {
113 orientation: cfg.orientation,
114 is_dragging: cfg.is_dragging.clone(),
115 is_disabled: cfg.is_disabled.clone(),
116 focus_origin: cfg.focus_origin.clone(),
117 hover_progress: cfg.hover_progress.clone(),
118 recipe: self.recipe,
119 })
120 }
121}
122
123struct SplitterHandleBody {
125 orientation: Orientation,
126 is_dragging: Signal<bool>,
127 is_disabled: Signal<bool>,
128 focus_origin: Signal<Option<FocusOrigin>>,
129 hover_progress: Signal<f32>,
130 recipe: SplitterRecipe,
131}
132
133impl std::fmt::Debug for SplitterHandleBody {
134 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135 f.debug_struct("SplitterHandleBody")
136 .field("orientation", &self.orientation)
137 .finish()
138 }
139}
140
141impl Widget for SplitterHandleBody {
142 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
143 let id = ctx.self_id();
144 let registry = ctx.binding_registry();
145 self.is_dragging
146 .bind_to(id, registry, BindingLevel::RepaintOnly);
147 self.is_disabled
148 .bind_to(id, registry, BindingLevel::RepaintOnly);
149 self.focus_origin
150 .bind_to(id, registry, BindingLevel::RepaintOnly);
151 self.hover_progress
152 .bind_to(id, registry, BindingLevel::RepaintOnly);
153 vec![]
154 }
155
156 fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
157 proposal.resolve(0.0, 0.0).into()
159 }
160
161 fn place_children(
162 &self,
163 _bounds: Rect,
164 _proposal: SizeProposal,
165 _children: &mut [WidgetPlacement],
166 _ctx: &LayoutContext,
167 ) {
168 }
169
170 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
171 let colors = &ctx.theme.colors;
172 let enabled = !self.is_disabled.get();
173
174 let line_thickness = self.recipe.divider_line_thickness.max(1.0);
175 let focus_thickness = (line_thickness * 3.0).max(line_thickness + 2.0);
176
177 let line_rect = |thickness: f32| match self.orientation {
178 Orientation::Horizontal => Rect::new(
180 bounds.x + (bounds.width - thickness) / 2.0,
181 bounds.y,
182 thickness,
183 bounds.height,
184 ),
185 Orientation::Vertical => Rect::new(
186 bounds.x,
187 bounds.y + (bounds.height - thickness) / 2.0,
188 bounds.width,
189 thickness,
190 ),
191 };
192
193 canvas.fill_rect(line_rect(line_thickness), colors.border);
195
196 let focus_alpha = if !enabled {
199 0.0
200 } else if self.focus_origin.get() == Some(FocusOrigin::Keyboard) || self.is_dragging.get() {
201 1.0
202 } else {
203 let p = self.hover_progress.get();
204 ((p - HOVER_DWELL_DELAY_FRAC) / (1.0 - HOVER_DWELL_DELAY_FRAC)).clamp(0.0, 1.0)
205 };
206
207 if focus_alpha > 0.0 {
208 canvas.fill_rect(
209 line_rect(focus_thickness),
210 colors.focus_ring.with_alpha(focus_alpha),
211 );
212 }
213 }
214
215 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
216 builder.set_hidden();
218 }
219}
220
221#[cfg(test)]
222mod tests {
223 use std::rc::Rc;
224
225 use teksilo_canvas::{Size, SizeProposal};
226 use teksilo_core::widget::{LayoutContext, LayoutResponse, Widget};
227 use teksilo_core::widget_tree::WidgetTree;
228 use teksilo_tokens::{Orientation, TargetDensity};
229
230 use crate::splitter::{PaneDescriptor, SPLITTER_GUTTER_THICKNESS, Splitter, SplitterModel};
231
232 #[derive(Debug)]
233 struct FixedLeaf(f32, f32);
234 impl Widget for FixedLeaf {
235 fn layout_response(&self, _p: SizeProposal, _c: &LayoutContext) -> LayoutResponse {
236 Size::new(self.0, self.1).into()
237 }
238 }
239
240 fn splitter_tree(density: TargetDensity) -> (WidgetTree, teksilo_core::styles::Theme) {
241 let theme = teksilo_core::presets::intui::light().with_density(density);
242 let avail = 400.0 - SPLITTER_GUTTER_THICKNESS;
243 let model = SplitterModel::from_panes(
244 vec![
245 PaneDescriptor::new()
246 .size(avail * 0.5)
247 .min_size(0.0)
248 .stretch(0.0),
249 PaneDescriptor::new()
250 .size(avail * 0.5)
251 .min_size(0.0)
252 .stretch(0.0),
253 ],
254 Orientation::Horizontal,
255 );
256 let mut tree = WidgetTree::new()
257 .with_theme(theme.clone())
258 .with_text_backend(Rc::new(std::cell::RefCell::new(
259 teksilo_canvas::MockTextBackend::new(),
260 )));
261 tree.add(
262 Splitter::new(model)
263 .pane(FixedLeaf(100.0, 40.0))
264 .pane(FixedLeaf(100.0, 40.0)),
265 );
266 tree.layout(SizeProposal::exact(400.0, 200.0));
267 (tree, theme)
268 }
269
270 #[test]
276 fn the_resting_line_is_painted_with_no_interaction_at_all() {
277 let (mut tree, theme) = splitter_tree(TargetDensity::Compact);
278 let frame = tree.render();
279 let border = theme.colors.border;
280 let painted = frame.decorations.iter().any(|d| {
281 let [x, _y, w, h] = d.rect;
282 (w - super::SPLITTER_DIVIDER_LINE_THICKNESS).abs() < 0.01
285 && h > 100.0
286 && x > 190.0
287 && x < 210.0
288 && (d.color[0] - border.r()).abs() < 0.001
289 && (d.color[1] - border.g()).abs() < 0.001
290 && (d.color[2] - border.b()).abs() < 0.001
291 && d.color[3] > 0.0
292 });
293 assert!(
294 painted,
295 "the resting divider must be painted with nothing hovering it"
296 );
297 }
298
299 #[test]
303 fn the_resting_line_is_a_hairline_at_every_density() {
304 for density in [
305 TargetDensity::Compact,
306 TargetDensity::Comfortable,
307 TargetDensity::Touch,
308 ] {
309 let recipe = super::SplitterRecipe::for_tokens(
310 &teksilo_tokens::InputTokens::for_density(density),
311 );
312 assert_eq!(
313 recipe.divider_line_thickness,
314 super::SPLITTER_DIVIDER_LINE_THICKNESS,
315 "{density:?}"
316 );
317 }
318 }
319}