Skip to main content

teksilo_widgets/styles/
recipe_combo_box_style.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Default `ComboBoxStyle` impl driven by paint-recipe data.
5//!
6//! `RecipeComboBoxStyle` ships the IntUI trigger look: a panel-bg
7//! rectangle with theme-driven border, a label / Spacer / vertical
8//! divider / chevron row, and the standard "border thickens on focus"
9//! convention. The actual dropdown popup is owned by the widget — the
10//! style only paints the trigger.
11//!
12//! The trigger composes:
13//!
14//! ```text
15//! ZStack {
16//!   RectWidget(bg_role, border_role, border_width, corner_radius)
17//!   Padding(horizontal/2, horizontal) {
18//!     HStack(spacing=8) {
19//!       <selected_label>          ← from cfg
20//!       Spacer
21//!       Divider                   ← FixedSize(border_width × 0.6h, fill)
22//!       Chevron (icon, 12 px)
23//!     }
24//!   }
25//! }
26//! ```
27//!
28//! All four interaction signals (`is_open`, `is_hovered`, `is_focused`,
29//! `is_disabled`) feed into derived role signals that drive the bg /
30//! border / text recolouring. Apps that want a different look write
31//! their own `impl ComboBoxStyle` block — the trait surface is a single
32//! `WidgetId` return so they can compose anything.
33
34use teksilo_core::build_context::BuildContext;
35use teksilo_core::styles::{ComboBoxStyle, ComboBoxStyleConfig, ComboBoxVariant};
36use teksilo_core::widget_id::WidgetId;
37use teksilo_tokens::{BorderRole, CornerRadius, SurfaceRole};
38
39use crate::primitives::{FixedSize, HStack, IconWidget, Padding, RectWidget, Spacer, ZStack};
40
41// IntUI design tokens for ComboBox. The recipe owns its own dimensions.
42pub const COMBO_BOX_HEIGHT: f32 = 28.0;
43pub const COMBO_BOX_PADDING_HORIZONTAL: f32 = 9.0;
44pub const COMBO_BOX_ARROW_COLUMN_WIDTH: f32 = 23.0;
45pub const COMBO_BOX_CORNER_RADIUS: f32 = 4.0;
46
47/// Configurable dimensions for [`RecipeComboBoxStyle`].
48#[derive(Debug, Clone, Copy, PartialEq)]
49pub struct ComboBoxRecipe {
50    pub height: f32,
51    pub padding_horizontal: f32,
52    pub arrow_column_width: f32,
53    pub corner_radius: f32,
54}
55
56impl Default for ComboBoxRecipe {
57    fn default() -> Self {
58        Self {
59            height: COMBO_BOX_HEIGHT,
60            padding_horizontal: COMBO_BOX_PADDING_HORIZONTAL,
61            arrow_column_width: COMBO_BOX_ARROW_COLUMN_WIDTH,
62            corner_radius: COMBO_BOX_CORNER_RADIUS,
63        }
64    }
65}
66
67/// Default `ComboBoxStyle` shipped with Teksilo.
68#[derive(Debug, Default, Clone, Copy)]
69pub struct RecipeComboBoxStyle {
70    pub recipe: ComboBoxRecipe,
71}
72
73impl RecipeComboBoxStyle {
74    pub fn new(recipe: ComboBoxRecipe) -> Self {
75        Self { recipe }
76    }
77}
78
79impl ComboBoxStyle for RecipeComboBoxStyle {
80    fn make_body(&self, cfg: &ComboBoxStyleConfig, ctx: &mut BuildContext) -> WidgetId {
81        let theme = ctx.theme();
82        let border_width = theme.shape.border_width;
83        let focus_ring_width = theme.shape.focus_ring_width;
84        let height = self.recipe.height;
85        let divider_height = height * 0.6;
86        let padding_h = self.recipe.padding_horizontal;
87        let corner_radius = self.recipe.corner_radius;
88
89        // Plain variant — no chrome at all. Hand the label back
90        // wrapped only in the min-height enforcement; callers using
91        // this variant are responsible for any surrounding visuals.
92        if matches!(cfg.variant, ComboBoxVariant::Plain) {
93            let row_id = build_inner_row(ctx, cfg.selected_label, border_width, divider_height);
94            let padded_id =
95                ctx.add(Padding::symmetric(padding_h * 0.5, padding_h).child_id(row_id));
96            return ctx.add(crate::primitives::MinSize::new(0.0, height).child_id(padded_id));
97        }
98
99        // Derived role signals. Roles encode "what this colour means";
100        // the theme maps them to concrete colours at paint time, so the
101        // result follows theme switches reactively.
102        //
103        // bg: Filled variants always use the Hover surface (their idle
104        // and hover states share the same tinted fill — Material 3
105        // convention). Outlined / Underline use Main idle, Hover when
106        // open or hovered. AccentDisabled overrides everything when
107        // disabled.
108        let variant = cfg.variant;
109        let bg_role = cfg.is_open.zip3(&cfg.is_hovered, &cfg.is_disabled).map(
110            move |(open, hovered, disabled)| {
111                if *disabled {
112                    // Neutral inert grey — NOT `AccentDisabled`, which is a
113                    // washed-out *accent* (pale cyan in IntUI) and belongs on
114                    // accent-filled controls like a Filled Button. A ComboBox
115                    // is a neutral field and must grey out like its SpinBox /
116                    // TextInput neighbours on the same form.
117                    SurfaceRole::Disabled
118                } else if matches!(variant, ComboBoxVariant::Filled) {
119                    SurfaceRole::Hover
120                } else if *open || *hovered {
121                    SurfaceRole::Hover
122                } else {
123                    SurfaceRole::Main
124                }
125            },
126        );
127
128        // border: thicker accent ring on focus, dimmed on disabled,
129        // default border in any other state. IntUI doesn't paint a
130        // separate focus ring around combo boxes — the border itself
131        // is the focus indicator. Filled has no border at all.
132        let border_role = cfg
133            .is_focused
134            .zip(&cfg.is_disabled)
135            .map(move |(focused, disabled)| {
136                if matches!(variant, ComboBoxVariant::Filled) {
137                    // The Filled variant never paints a border; the
138                    // role we return here is ignored because
139                    // border_width is forced to 0 below.
140                    BorderRole::Default
141                } else if *disabled {
142                    BorderRole::Disabled
143                } else if *focused {
144                    BorderRole::Focused
145                } else {
146                    BorderRole::Default
147                }
148            });
149
150        let border_width_signal = cfg.is_focused.map(move |focused| match variant {
151            ComboBoxVariant::Filled => 0.0,
152            _ => {
153                if *focused {
154                    focus_ring_width
155                } else {
156                    border_width
157                }
158            }
159        });
160
161        let row_id = build_inner_row(ctx, cfg.selected_label, border_width, divider_height);
162        let padding_id = ctx.add(Padding::symmetric(padding_h * 0.5, padding_h).child_id(row_id));
163
164        let bg = RectWidget::new()
165            .background(bg_role)
166            .border_color(border_role)
167            .border_width(border_width_signal)
168            .corner_radius(CornerRadius::uniform(corner_radius));
169        let bg_id = ctx.add(bg);
170
171        let visual_id = ctx.add(ZStack::new().add_child(bg_id).add_child(padding_id));
172        ctx.add(crate::primitives::MinSize::new(0.0, height).child_id(visual_id))
173    }
174}
175
176/// Build the trigger's inner row: `[selected_label | Spacer |
177/// vertical divider | chevron icon]`. Shared between every variant —
178/// only the surrounding chrome (bg / border / corner radius) varies.
179fn build_inner_row(
180    ctx: &mut BuildContext,
181    selected_label: WidgetId,
182    border_width: f32,
183    divider_height: f32,
184) -> WidgetId {
185    let divider_fill_id = ctx.add(RectWidget::new().background(BorderRole::Default));
186    let divider_id = ctx.add(
187        FixedSize::new()
188            .width(border_width)
189            .height(divider_height)
190            .child_id(divider_fill_id),
191    );
192
193    // Chevron colour: `text_primary` at 50 % alpha. No role captures
194    // this blend, so we derive a `Signal<Color>` off `theme_signal`
195    // directly.
196    let chevron_color = ctx
197        .theme_signal()
198        .map(|t| t.colors.text_primary.with_alpha(0.5));
199    let chevron = IconWidget::chevron_down(12.0).color(chevron_color);
200    let chevron_id = ctx.add(chevron);
201
202    ctx.add(
203        HStack::new()
204            .spacing(8.0)
205            .add_child(selected_label)
206            .child(Spacer::new())
207            .add_child(divider_id)
208            .add_child(chevron_id),
209    )
210}