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}