Skip to main content

teksilo_widgets/styles/
recipe_split_button_style.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Default `SplitButtonStyle` impl for `SplitButton`.
5//!
6//! Owns the shared frame chrome (background fill, border, corner radius,
7//! overall min size) and wraps the pre-built interactive `content` row
8//! handed in via [`SplitButtonStyleConfig`]. Mirrors `RecipeButtonStyle`:
9//! the widget keeps text-colour resolution and event wiring; the style only
10//! frames the content.
11//!
12//! Background / border roles are resolved from the live interaction state
13//! exactly as the widget did before the Tier-3 migration, so the default
14//! render is unchanged. Disabled appearance is handled by the leaves' own
15//! paint (`effective_enabled`), so the frame is intentionally *not* dimmed
16//! here — `cfg.is_disabled` is available for custom styles that want to.
17
18use teksilo_canvas::Size;
19use teksilo_core::build_context::BuildContext;
20use teksilo_core::signal::Signal;
21use teksilo_core::styles::density::density_min_size;
22use teksilo_core::styles::{SplitButtonStyle, SplitButtonStyleConfig};
23use teksilo_core::widget_id::WidgetId;
24use teksilo_tokens::{BorderRole, CornerRadius, InputTokens, SurfaceRole, TargetAxes};
25
26use crate::button::{ButtonVariant, InteractionState};
27use crate::primitives::{MinSize, RectWidget, ZStack};
28use crate::split_button::{
29    SPLIT_BUTTON_BORDER_WIDTH, SPLIT_BUTTON_CHEVRON_WIDTH, SPLIT_BUTTON_CORNER_RADIUS,
30    SPLIT_BUTTON_DIVIDER_WIDTH, SPLIT_BUTTON_HEIGHT, SPLIT_BUTTON_MIN_WIDTH, SplitButtonFamily,
31    classify,
32};
33
34/// IntUI default `SplitButtonStyle`. Resolves the frame background / border
35/// from the variant × interaction state. Apps retheme by installing a custom
36/// impl per-call (`SplitButton::style(...)`) or theme-wide
37/// (`theme.style_slots.split_button = Some(Rc::new(...))`).
38#[derive(Debug, Default, Clone, Copy)]
39pub struct RecipeSplitButtonStyle;
40
41impl RecipeSplitButtonStyle {
42    /// This style resolved against a density's [`InputTokens`], as
43    /// `RecipeSplitButtonStyle::for_tokens(&ctx.theme().input)` at the widget's own
44    /// build site.
45    ///
46    /// The style is a unit struct: it borrows the `SPLIT_BUTTON_*`
47    /// dimensions from the widget module, and resolves them per density
48    /// inside [`make_body`](SplitButtonStyle::make_body) from
49    /// `ctx.theme().input`, so there is nothing to bake here.
50    pub fn for_tokens(_tokens: &InputTokens) -> Self {
51        Self
52    }
53}
54
55impl SplitButtonStyle for RecipeSplitButtonStyle {
56    fn make_body(&self, cfg: &SplitButtonStyleConfig, ctx: &mut BuildContext) -> WidgetId {
57        let variant = cfg.variant;
58
59        // Collapse the live interaction bools back into the single state the
60        // role tables key on. Priority mirrors the widget's `interaction`
61        // enum (which carries exactly one of these at a time): pressed >
62        // focused > hovered > idle. `is_disabled` is deliberately not folded
63        // in — the frame doesn't dim on disable (leaves handle that).
64        let state: Signal<InteractionState> = cfg
65            .is_pressed
66            .zip3(&cfg.is_focused, &cfg.is_hovered)
67            .map(|(pressed, focused, hovered)| {
68                if *pressed {
69                    InteractionState::Pressed
70                } else if *focused {
71                    InteractionState::Focused
72                } else if *hovered {
73                    InteractionState::Hovered
74                } else {
75                    InteractionState::Idle
76                }
77            });
78
79        let bg_role = state.map(move |s| resolve_bg_role(variant, *s));
80        let border_role = state.map(move |s| resolve_border_role(variant, *s));
81
82        let normal_bw = SPLIT_BUTTON_BORDER_WIDTH;
83        let focus_bw = ctx.theme().shape.focus_ring_width;
84        let border_width =
85            state.map(move |s| resolve_border_width(variant, *s, normal_bw, focus_bw));
86
87        // Shared frame (single RectWidget behind the content row).
88        let bg_id = ctx.add(
89            RectWidget::new()
90                .background(bg_role)
91                .border_color(border_role)
92                .border_width(border_width)
93                .corner_radius(CornerRadius::uniform(SPLIT_BUTTON_CORNER_RADIUS)),
94        );
95
96        let frame_id = ctx.add(ZStack::new().child(bg_id).child(cfg.content));
97
98        // Enforce the overall minimum: main min_width + divider + chevron.
99        // Only the height is a target floor — the width is the sum of three
100        // painted extents, and the chevron's own 22 dp zone reaches its floor
101        // through `partition_targets`, not by growing.
102        let total_min_width =
103            SPLIT_BUTTON_MIN_WIDTH + SPLIT_BUTTON_DIVIDER_WIDTH + SPLIT_BUTTON_CHEVRON_WIDTH;
104        let min = density_min_size(
105            Size::new(total_min_width, SPLIT_BUTTON_HEIGHT),
106            TargetAxes::HEIGHT,
107            &ctx.theme().input,
108        );
109        ctx.add(MinSize::new(min.width, min.height).child(frame_id))
110    }
111}
112
113// --- Color resolution (variant × state × theme) ---
114//
115// Mirrors `Button::resolve_bg` / `resolve_border` so a Button and a
116// SplitButton with the same variant look identical. The `classify` bucketing
117// is shared with the widget's `resolve_text_role` (it lives in `split_button`
118// so text and frame stay in lockstep).
119
120fn resolve_bg_role(variant: ButtonVariant, state: InteractionState) -> SurfaceRole {
121    match (classify(variant), state) {
122        (SplitButtonFamily::FilledLike, InteractionState::Disabled) => SurfaceRole::AccentDisabled,
123        (SplitButtonFamily::FilledLike, InteractionState::Pressed) => SurfaceRole::AccentPressed,
124        (SplitButtonFamily::FilledLike, InteractionState::Hovered) => SurfaceRole::AccentHover,
125        (SplitButtonFamily::FilledLike, _) => SurfaceRole::Accent,
126
127        (SplitButtonFamily::PlainLike, InteractionState::Pressed) => SurfaceRole::Pressed,
128        (SplitButtonFamily::PlainLike, InteractionState::Hovered) => SurfaceRole::Hover,
129        (SplitButtonFamily::PlainLike, _) => SurfaceRole::Main,
130
131        (SplitButtonFamily::GhostLike, InteractionState::Pressed) => SurfaceRole::Pressed,
132        (SplitButtonFamily::GhostLike, InteractionState::Hovered) => SurfaceRole::Hover,
133        (SplitButtonFamily::GhostLike, _) => SurfaceRole::Transparent,
134    }
135}
136
137fn resolve_border_role(variant: ButtonVariant, state: InteractionState) -> BorderRole {
138    if state == InteractionState::Focused {
139        return BorderRole::Focused;
140    }
141    match classify(variant) {
142        SplitButtonFamily::FilledLike | SplitButtonFamily::GhostLike => BorderRole::Transparent,
143        SplitButtonFamily::PlainLike => match state {
144            InteractionState::Hovered | InteractionState::Pressed => BorderRole::Strong,
145            _ => BorderRole::Default,
146        },
147    }
148}
149
150/// Border width for the SplitButton frame: thickens to the theme's
151/// `focus_ring_width` on focus, rests at the variant's normal width otherwise.
152fn resolve_border_width(
153    variant: ButtonVariant,
154    state: InteractionState,
155    normal_bw: f32,
156    focus_bw: f32,
157) -> f32 {
158    if state == InteractionState::Focused {
159        return focus_bw;
160    }
161    match classify(variant) {
162        SplitButtonFamily::FilledLike | SplitButtonFamily::GhostLike => 0.0,
163        SplitButtonFamily::PlainLike => normal_bw,
164    }
165}