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, InputTokens, SurfaceRole, TargetRole};
38
39use crate::primitives::{FixedSize, HStack, IconWidget, Padding, RectWidget, Spacer, ZStack};
40use teksilo_core::styles::density::{dp, spacing};
41
42// IntUI design tokens for ComboBox. The recipe owns its own dimensions.
43pub const COMBO_BOX_HEIGHT: f32 = 28.0;
44pub const COMBO_BOX_PADDING_HORIZONTAL: f32 = 9.0;
45pub const COMBO_BOX_ARROW_COLUMN_WIDTH: f32 = 23.0;
46pub const COMBO_BOX_CORNER_RADIUS: f32 = 4.0;
47
48/// Configurable dimensions for [`RecipeComboBoxStyle`].
49#[derive(Debug, Clone, Copy, PartialEq)]
50pub struct ComboBoxRecipe {
51    pub height: f32,
52    pub padding_horizontal: f32,
53    pub arrow_column_width: f32,
54    pub corner_radius: f32,
55}
56
57impl ComboBoxRecipe {
58    /// This recipe's dimensions resolved against a density's [`InputTokens`].
59    ///
60    /// [`Default`] is `for_tokens(&InputTokens::default())` — the Compact
61    /// ladder — so the shipped values below are the Compact column by
62    /// construction and cannot drift from it.
63    pub fn for_tokens(tokens: &InputTokens) -> Self {
64        Self {
65            height: dp(COMBO_BOX_HEIGHT, TargetRole::Target, tokens),
66            padding_horizontal: spacing(COMBO_BOX_PADDING_HORIZONTAL, tokens),
67            arrow_column_width: COMBO_BOX_ARROW_COLUMN_WIDTH,
68            corner_radius: COMBO_BOX_CORNER_RADIUS,
69        }
70    }
71}
72
73impl Default for ComboBoxRecipe {
74    fn default() -> Self {
75        Self::for_tokens(&InputTokens::default())
76    }
77}
78
79/// Default `ComboBoxStyle` shipped with Teksilo.
80#[derive(Debug, Default, Clone, Copy)]
81pub struct RecipeComboBoxStyle {
82    pub recipe: ComboBoxRecipe,
83}
84
85impl RecipeComboBoxStyle {
86    pub fn new(recipe: ComboBoxRecipe) -> Self {
87        Self { recipe }
88    }
89
90    /// This style with every dimension resolved against a density's
91    /// [`InputTokens`], as `RecipeComboBoxStyle::for_tokens(&ctx.theme().input)` at
92    /// the widget's own build site.
93    ///
94    /// [`Default`] is the `TargetDensity::Compact` projection, so a Compact
95    /// tree gets exactly the values this module documents.
96    pub fn for_tokens(tokens: &InputTokens) -> Self {
97        Self {
98            recipe: ComboBoxRecipe::for_tokens(tokens),
99        }
100    }
101}
102
103impl ComboBoxStyle for RecipeComboBoxStyle {
104    fn make_body(&self, cfg: &ComboBoxStyleConfig, ctx: &mut BuildContext) -> WidgetId {
105        let theme = ctx.theme();
106        let border_width = theme.shape.border_width;
107        let focus_ring_width = theme.shape.focus_ring_width;
108        let height = self.recipe.height;
109        let divider_height = height * 0.6;
110        let padding_h = self.recipe.padding_horizontal;
111        let corner_radius = self.recipe.corner_radius;
112
113        // Plain variant — no chrome at all. Hand the label back
114        // wrapped only in the min-height enforcement; callers using
115        // this variant are responsible for any surrounding visuals.
116        if matches!(cfg.variant, ComboBoxVariant::Plain) {
117            let row_id = build_inner_row(ctx, cfg.selected_label, border_width, divider_height);
118            let padded_id = ctx.add(Padding::symmetric(padding_h * 0.5, padding_h).child(row_id));
119            return ctx.add(crate::primitives::MinSize::new(0.0, height).child(padded_id));
120        }
121
122        // Derived role signals. Roles encode "what this colour means";
123        // the theme maps them to concrete colours at paint time, so the
124        // result follows theme switches reactively.
125        //
126        // bg: Filled variants always use the Hover surface (their idle
127        // and hover states share the same tinted fill — Material 3
128        // convention). Outlined / Underline use Main idle, Hover when
129        // open or hovered. AccentDisabled overrides everything when
130        // disabled.
131        let variant = cfg.variant;
132        let bg_role = cfg.is_open.zip3(&cfg.is_hovered, &cfg.is_disabled).map(
133            move |(open, hovered, disabled)| {
134                if *disabled {
135                    // Neutral inert grey — NOT `AccentDisabled`, which is a
136                    // washed-out *accent* (pale cyan in IntUI) and belongs on
137                    // accent-filled controls like a Filled Button. A ComboBox
138                    // is a neutral field and must grey out like its SpinBox /
139                    // TextInput neighbours on the same form.
140                    SurfaceRole::Disabled
141                } else if matches!(variant, ComboBoxVariant::Filled) {
142                    SurfaceRole::Hover
143                } else if *open || *hovered {
144                    SurfaceRole::Hover
145                } else {
146                    SurfaceRole::Main
147                }
148            },
149        );
150
151        // border: thicker accent ring on focus, dimmed on disabled,
152        // default border in any other state. IntUI doesn't paint a
153        // separate focus ring around combo boxes — the border itself
154        // is the focus indicator. Filled has no border at all.
155        let border_role = cfg
156            .is_focused
157            .zip(&cfg.is_disabled)
158            .map(move |(focused, disabled)| {
159                if matches!(variant, ComboBoxVariant::Filled) {
160                    // The Filled variant never paints a border; the
161                    // role we return here is ignored because
162                    // border_width is forced to 0 below.
163                    BorderRole::Default
164                } else if *disabled {
165                    BorderRole::Disabled
166                } else if *focused {
167                    BorderRole::Focused
168                } else {
169                    BorderRole::Default
170                }
171            });
172
173        let border_width_signal = cfg.is_focused.map(move |focused| match variant {
174            ComboBoxVariant::Filled => 0.0,
175            _ => {
176                if *focused {
177                    focus_ring_width
178                } else {
179                    border_width
180                }
181            }
182        });
183
184        let row_id = build_inner_row(ctx, cfg.selected_label, border_width, divider_height);
185        let padding_id = ctx.add(Padding::symmetric(padding_h * 0.5, padding_h).child(row_id));
186
187        let bg = RectWidget::new()
188            .background(bg_role)
189            .border_color(border_role)
190            .border_width(border_width_signal)
191            .corner_radius(CornerRadius::uniform(corner_radius));
192        let bg_id = ctx.add(bg);
193
194        let visual_id = ctx.add(ZStack::new().child(bg_id).child(padding_id));
195        ctx.add(crate::primitives::MinSize::new(0.0, height).child(visual_id))
196    }
197}
198
199/// Build the trigger's inner row: `[selected_label | Spacer |
200/// vertical divider | chevron icon]`. Shared between every variant —
201/// only the surrounding chrome (bg / border / corner radius) varies.
202fn build_inner_row(
203    ctx: &mut BuildContext,
204    selected_label: WidgetId,
205    border_width: f32,
206    divider_height: f32,
207) -> WidgetId {
208    let divider_fill_id = ctx.add(RectWidget::new().background(BorderRole::Default));
209    let divider_id = ctx.add(
210        FixedSize::new()
211            .width(border_width)
212            .height(divider_height)
213            .child(divider_fill_id),
214    );
215
216    // Chevron colour: `text_primary` at 50 % alpha. No role captures
217    // this blend, so we derive a `Signal<Color>` off `theme_signal`
218    // directly.
219    let chevron_color = ctx
220        .theme_signal()
221        .map(|t| t.colors.text_primary.with_alpha(0.5));
222    let chevron = IconWidget::chevron_down(12.0).color(chevron_color);
223    let chevron_id = ctx.add(chevron);
224
225    ctx.add(
226        HStack::new()
227            .spacing(8.0)
228            .child(selected_label)
229            .child(Spacer::new())
230            .child(divider_id)
231            .child(chevron_id),
232    )
233}