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}