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. The hit area is the full gutter
10//! width; the cursor change is what signals grabbability.
11//!
12//! The visual body is a small private leaf (`SplitterHandleBody`) — same
13//! "leaf body" choice as `RecipeSliderStyle`. Custom `SplitterStyle`
14//! impls compose their own body instead.
15
16use teksilo_canvas::{Canvas, Rect, SizeProposal};
17use teksilo_core::accessibility::AccessNodeBuilder;
18use teksilo_core::binding::BindingLevel;
19use teksilo_core::build_context::BuildContext;
20use teksilo_core::focus::FocusOrigin;
21use teksilo_core::signal::Signal;
22use teksilo_core::styles::{SplitterStyle, SplitterStyleConfig};
23use teksilo_core::widget::{LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement};
24use teksilo_core::widget_id::WidgetId;
25use teksilo_tokens::Orientation;
26
27/// Thickness of the always-present resting divider line, in dp.
28pub const SPLITTER_DIVIDER_LINE_THICKNESS: f32 = 1.0;
29
30/// Fraction of the hover-dwell animation spent fully transparent before
31/// the focus line fades in (300 ms hold within the 400 ms dwell). Maps
32/// the handle's linear `hover_progress` 0→1 onto a delayed alpha ramp.
33const HOVER_DWELL_DELAY_FRAC: f32 = 0.75;
34
35/// Configurable dimensions for [`RecipeSplitterStyle`].
36#[derive(Debug, Clone, Copy, PartialEq)]
37pub struct SplitterRecipe {
38    /// Thickness of the always-present resting divider line, in dp.
39    pub divider_line_thickness: f32,
40}
41
42impl Default for SplitterRecipe {
43    fn default() -> Self {
44        Self {
45            divider_line_thickness: SPLITTER_DIVIDER_LINE_THICKNESS,
46        }
47    }
48}
49
50/// Default `SplitterStyle` shipped with Teksilo. Colors come from
51/// `theme.colors.{border, focus_ring}`.
52#[derive(Debug, Default, Clone, Copy)]
53pub struct RecipeSplitterStyle {
54    pub recipe: SplitterRecipe,
55}
56
57impl RecipeSplitterStyle {
58    pub fn new(recipe: SplitterRecipe) -> Self {
59        Self { recipe }
60    }
61}
62
63impl SplitterStyle for RecipeSplitterStyle {
64    fn make_handle(&self, cfg: &SplitterStyleConfig, ctx: &mut BuildContext) -> WidgetId {
65        ctx.add(SplitterHandleBody {
66            orientation: cfg.orientation,
67            is_dragging: cfg.is_dragging.clone(),
68            is_disabled: cfg.is_disabled.clone(),
69            focus_origin: cfg.focus_origin.clone(),
70            hover_progress: cfg.hover_progress.clone(),
71            recipe: self.recipe,
72        })
73    }
74}
75
76/// Internal leaf that paints the divider line + focus indicator.
77struct SplitterHandleBody {
78    orientation: Orientation,
79    is_dragging: Signal<bool>,
80    is_disabled: Signal<bool>,
81    focus_origin: Signal<Option<FocusOrigin>>,
82    hover_progress: Signal<f32>,
83    recipe: SplitterRecipe,
84}
85
86impl std::fmt::Debug for SplitterHandleBody {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        f.debug_struct("SplitterHandleBody")
89            .field("orientation", &self.orientation)
90            .finish()
91    }
92}
93
94impl Widget for SplitterHandleBody {
95    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
96        let id = ctx.self_id();
97        let registry = ctx.binding_registry();
98        self.is_dragging
99            .bind_to(id, registry, BindingLevel::RepaintOnly);
100        self.is_disabled
101            .bind_to(id, registry, BindingLevel::RepaintOnly);
102        self.focus_origin
103            .bind_to(id, registry, BindingLevel::RepaintOnly);
104        self.hover_progress
105            .bind_to(id, registry, BindingLevel::RepaintOnly);
106        vec![]
107    }
108
109    fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
110        // The host handle assigns exact bounds; just resolve the proposal.
111        proposal.resolve(0.0, 0.0).into()
112    }
113
114    fn place_children(
115        &self,
116        _bounds: Rect,
117        _proposal: SizeProposal,
118        _children: &mut [WidgetPlacement],
119        _ctx: &LayoutContext,
120    ) {
121    }
122
123    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
124        let colors = &ctx.theme.colors;
125        let enabled = !self.is_disabled.get();
126
127        let line_thickness = self.recipe.divider_line_thickness.max(1.0);
128        let focus_thickness = (line_thickness * 3.0).max(line_thickness + 2.0);
129
130        let line_rect = |thickness: f32| match self.orientation {
131            // Horizontal splitter → vertical handle bar (line runs down).
132            Orientation::Horizontal => Rect::new(
133                bounds.x + (bounds.width - thickness) / 2.0,
134                bounds.y,
135                thickness,
136                bounds.height,
137            ),
138            Orientation::Vertical => Rect::new(
139                bounds.x,
140                bounds.y + (bounds.height - thickness) / 2.0,
141                bounds.width,
142                thickness,
143            ),
144        };
145
146        // Resting line — always present.
147        canvas.fill_rect(line_rect(line_thickness), colors.border);
148
149        // Focus indicator: instant on keyboard focus / drag, hover-dwell
150        // fade-in otherwise.
151        let focus_alpha = if !enabled {
152            0.0
153        } else if self.focus_origin.get() == Some(FocusOrigin::Keyboard) || self.is_dragging.get() {
154            1.0
155        } else {
156            let p = self.hover_progress.get();
157            ((p - HOVER_DWELL_DELAY_FRAC) / (1.0 - HOVER_DWELL_DELAY_FRAC)).clamp(0.0, 1.0)
158        };
159
160        if focus_alpha > 0.0 {
161            canvas.fill_rect(
162                line_rect(focus_thickness),
163                colors.focus_ring.with_alpha(focus_alpha),
164            );
165        }
166    }
167
168    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
169        // Presentational — the SplitterHandle owns the Role::Splitter node.
170        builder.set_hidden();
171    }
172}