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_core::build_context::BuildContext;
19use teksilo_core::signal::Signal;
20use teksilo_core::styles::{SplitButtonStyle, SplitButtonStyleConfig};
21use teksilo_core::widget_id::WidgetId;
22use teksilo_tokens::{BorderRole, CornerRadius, SurfaceRole};
23
24use crate::button::{ButtonVariant, InteractionState};
25use crate::primitives::{MinSize, RectWidget, ZStack};
26use crate::split_button::{
27    SPLIT_BUTTON_BORDER_WIDTH, SPLIT_BUTTON_CHEVRON_WIDTH, SPLIT_BUTTON_CORNER_RADIUS,
28    SPLIT_BUTTON_DIVIDER_WIDTH, SPLIT_BUTTON_HEIGHT, SPLIT_BUTTON_MIN_WIDTH, SplitButtonFamily,
29    classify,
30};
31
32/// IntUI default `SplitButtonStyle`. Resolves the frame background / border
33/// from the variant × interaction state. Apps retheme by installing a custom
34/// impl per-call (`SplitButton::style(...)`) or theme-wide
35/// (`theme.style_slots.split_button = Some(Rc::new(...))`).
36#[derive(Debug, Default, Clone, Copy)]
37pub struct RecipeSplitButtonStyle;
38
39impl SplitButtonStyle for RecipeSplitButtonStyle {
40    fn make_body(&self, cfg: &SplitButtonStyleConfig, ctx: &mut BuildContext) -> WidgetId {
41        let variant = cfg.variant;
42
43        // Collapse the live interaction bools back into the single state the
44        // role tables key on. Priority mirrors the widget's `interaction`
45        // enum (which carries exactly one of these at a time): pressed >
46        // focused > hovered > idle. `is_disabled` is deliberately not folded
47        // in — the frame doesn't dim on disable (leaves handle that).
48        let state: Signal<InteractionState> = cfg
49            .is_pressed
50            .zip3(&cfg.is_focused, &cfg.is_hovered)
51            .map(|(pressed, focused, hovered)| {
52                if *pressed {
53                    InteractionState::Pressed
54                } else if *focused {
55                    InteractionState::Focused
56                } else if *hovered {
57                    InteractionState::Hovered
58                } else {
59                    InteractionState::Idle
60                }
61            });
62
63        let bg_role = state.map(move |s| resolve_bg_role(variant, *s));
64        let border_role = state.map(move |s| resolve_border_role(variant, *s));
65
66        let normal_bw = SPLIT_BUTTON_BORDER_WIDTH;
67        let focus_bw = ctx.theme().shape.focus_ring_width;
68        let border_width =
69            state.map(move |s| resolve_border_width(variant, *s, normal_bw, focus_bw));
70
71        // Shared frame (single RectWidget behind the content row).
72        let bg_id = ctx.add(
73            RectWidget::new()
74                .background(bg_role)
75                .border_color(border_role)
76                .border_width(border_width)
77                .corner_radius(CornerRadius::uniform(SPLIT_BUTTON_CORNER_RADIUS)),
78        );
79
80        let frame_id = ctx.add(ZStack::new().add_child(bg_id).add_child(cfg.content));
81
82        // Enforce the overall minimum: main min_width + divider + chevron.
83        let total_min_width =
84            SPLIT_BUTTON_MIN_WIDTH + SPLIT_BUTTON_DIVIDER_WIDTH + SPLIT_BUTTON_CHEVRON_WIDTH;
85        ctx.add(MinSize::new(total_min_width, SPLIT_BUTTON_HEIGHT).child_id(frame_id))
86    }
87}
88
89// --- Color resolution (variant × state × theme) ---
90//
91// Mirrors `Button::resolve_bg` / `resolve_border` so a Button and a
92// SplitButton with the same variant look identical. The `classify` bucketing
93// is shared with the widget's `resolve_text_role` (it lives in `split_button`
94// so text and frame stay in lockstep).
95
96fn resolve_bg_role(variant: ButtonVariant, state: InteractionState) -> SurfaceRole {
97    match (classify(variant), state) {
98        (SplitButtonFamily::FilledLike, InteractionState::Disabled) => SurfaceRole::AccentDisabled,
99        (SplitButtonFamily::FilledLike, InteractionState::Pressed) => SurfaceRole::AccentPressed,
100        (SplitButtonFamily::FilledLike, InteractionState::Hovered) => SurfaceRole::AccentHover,
101        (SplitButtonFamily::FilledLike, _) => SurfaceRole::Accent,
102
103        (SplitButtonFamily::PlainLike, InteractionState::Pressed) => SurfaceRole::Pressed,
104        (SplitButtonFamily::PlainLike, InteractionState::Hovered) => SurfaceRole::Hover,
105        (SplitButtonFamily::PlainLike, _) => SurfaceRole::Main,
106
107        (SplitButtonFamily::GhostLike, InteractionState::Pressed) => SurfaceRole::Pressed,
108        (SplitButtonFamily::GhostLike, InteractionState::Hovered) => SurfaceRole::Hover,
109        (SplitButtonFamily::GhostLike, _) => SurfaceRole::Transparent,
110    }
111}
112
113fn resolve_border_role(variant: ButtonVariant, state: InteractionState) -> BorderRole {
114    if state == InteractionState::Focused {
115        return BorderRole::Focused;
116    }
117    match classify(variant) {
118        SplitButtonFamily::FilledLike | SplitButtonFamily::GhostLike => BorderRole::Transparent,
119        SplitButtonFamily::PlainLike => match state {
120            InteractionState::Hovered | InteractionState::Pressed => BorderRole::Strong,
121            _ => BorderRole::Default,
122        },
123    }
124}
125
126/// Border width for the SplitButton frame: thickens to the theme's
127/// `focus_ring_width` on focus, rests at the variant's normal width otherwise.
128fn resolve_border_width(
129    variant: ButtonVariant,
130    state: InteractionState,
131    normal_bw: f32,
132    focus_bw: f32,
133) -> f32 {
134    if state == InteractionState::Focused {
135        return focus_bw;
136    }
137    match classify(variant) {
138        SplitButtonFamily::FilledLike | SplitButtonFamily::GhostLike => 0.0,
139        SplitButtonFamily::PlainLike => normal_bw,
140    }
141}