Skip to main content

teksilo_widgets/styles/
recipe_splitter_style.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Default `SplitterStyle` impl — the IntUI divider-handle look.
5//!
6//! Reproduces the old `SplitView` chrome: a thin static line at the
7//! gutter's center (so the divider never disappears), with a thicker
8//! focus-color line that cross-fades in on hover-dwell and snaps to full
9//! strength on keyboard focus or drag.
10//!
11//! The visual body is a small private leaf (`SplitterHandleBody`) — same
12//! "leaf body" choice as `RecipeSliderStyle`. Custom `SplitterStyle`
13//! impls compose their own body instead.
14//!
15//! # The resting line is unconditional, and that is load-bearing
16//!
17//! The divider a user has to aim at is painted whether or not anything is
18//! hovering it — `fill_rect(line_rect(line_thickness), colors.border)` runs
19//! before any state is consulted. The 400 ms hover dwell only fades in a
20//! *second*, focus-coloured line that keyboard focus and an active drag both
21//! show instantly.
22//!
23//! So the splitter owes a touch user no reveal: there is nothing hidden behind
24//! hover to reveal. What it owed was a **grab**, and that is
25//! [`SplitterHandle`](crate::splitter)'s `Widget::hit_outset` — a hit-only
26//! widening of the 6 dp gutter to the density's target size that leaves this
27//! paint untouched at every density. The cursor change over the gutter is a
28//! mouse affordance and stays exactly as it was.
29//!
30//! An earlier reading of the hover census had this style hiding the divider
31//! until hover and owing a touch reveal; the source says otherwise, and
32//! `the_resting_line_is_painted_with_no_interaction_at_all` below keeps it
33//! that way.
34
35use 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
46/// Thickness of the always-present resting divider line, in dp.
47pub const SPLITTER_DIVIDER_LINE_THICKNESS: f32 = 1.0;
48
49/// Fraction of the hover-dwell animation spent fully transparent before
50/// the focus line fades in (300 ms hold within the 400 ms dwell). Maps
51/// the handle's linear `hover_progress` 0→1 onto a delayed alpha ramp.
52const HOVER_DWELL_DELAY_FRAC: f32 = 0.75;
53
54/// Configurable dimensions for [`RecipeSplitterStyle`].
55#[derive(Debug, Clone, Copy, PartialEq)]
56pub struct SplitterRecipe {
57    /// Thickness of the always-present resting divider line, in dp.
58    pub divider_line_thickness: f32,
59}
60
61impl SplitterRecipe {
62    /// This recipe's dimensions resolved against a density's [`InputTokens`].
63    ///
64    /// [`Default`] is `for_tokens(&InputTokens::default())` — the Compact
65    /// ladder — so the shipped values below are the Compact column by
66    /// construction and cannot drift from it.
67    ///
68    /// Every dimension this recipe carries is a decoration (a corner radius, a
69    /// hairline, a glyph metric), so the parameter is unused: the density
70    /// ladder never moves any of them. It is taken all the same, so every
71    /// recipe is constructed the same way at its widget's build site.
72    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/// Default `SplitterStyle` shipped with Teksilo. Colors come from
86/// `theme.colors.{border, focus_ring}`.
87#[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    /// This style with every dimension resolved against a density's
98    /// [`InputTokens`], as `RecipeSplitterStyle::for_tokens(&ctx.theme().input)` at
99    /// the widget's own build site.
100    ///
101    /// [`Default`] is the `TargetDensity::Compact` projection, so a Compact
102    /// tree gets exactly the values this module documents.
103    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
123/// Internal leaf that paints the divider line + focus indicator.
124struct 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        // The host handle assigns exact bounds; just resolve the proposal.
158        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            // Horizontal splitter → vertical handle bar (line runs down).
179            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        // Resting line — always present.
194        canvas.fill_rect(line_rect(line_thickness), colors.border);
195
196        // Focus indicator: instant on keyboard focus / drag, hover-dwell
197        // fade-in otherwise.
198        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        // Presentational — the SplitterHandle owns the Role::Splitter node.
217        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    /// The correction this package was told not to undo: the divider is drawn
271    /// with **no** pointer anywhere near it, so touch is owed a grab and not a
272    /// reveal. Asserted against the rendered frame rather than against the
273    /// source, so a future refactor that moves the fill behind a hover check
274    /// fails here.
275    #[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            // The gutter's own hairline: one logical pixel wide, tall, and
283            // sitting at the divider between the two 197 dp panes.
284            (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    /// …and it stays exactly one hairline at every density: the divider is a
300    /// decoration, so the density ladder never grows it. What grows is the
301    /// hit band, which paints nothing.
302    #[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}