Skip to main content

teksilo_widgets/styles/
recipe_color_picker_style.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Default `ColorPickerStyle` impl + the picker's design tokens.
5//!
6//! Design tokens for `ColorPicker` live as `pub const`s on this module.
7//! The four functional renderers (`HsvCanvas`, `HueStrip`, `AlphaStrip`,
8//! `ColorSwatch`) read these directly when they need dimensions for
9//! their colour-space rendering.
10//!
11//! `RecipeColorPickerStyle::make_body` ports the IntUI layout exactly:
12//! one of three column / row arrangements per `ColorPickerLayout`,
13//! wrapped in a `Panel` surface (1 dp border + corner radius +
14//! `PADDING` interior).
15
16use teksilo_core::build_context::BuildContext;
17use teksilo_core::styles::{ColorPickerLayout, ColorPickerStyle, ColorPickerStyleConfig};
18use teksilo_core::widget_id::WidgetId;
19use teksilo_tokens::{Color, InputTokens, TargetRole};
20
21use crate::panel::Panel;
22use crate::primitives::{HStack, VStack};
23use teksilo_core::styles::density::{dp, spacing};
24
25// ─── IntUI design tokens for ColorPicker ───────────────────────────
26
27// HSV (saturation × value) canvas dimensions.
28pub const CANVAS_WIDTH: f32 = 224.0;
29pub const CANVAS_HEIGHT: f32 = 192.0;
30pub const CANVAS_CORNER_RADIUS: f32 = 4.0;
31
32// 1D hue and alpha strips (vertical orientation in default layouts).
33pub const STRIP_THICKNESS: f32 = 14.0;
34pub const STRIP_LENGTH: f32 = 192.0;
35pub const STRIP_CORNER_RADIUS: f32 = 4.0;
36
37// HSV-canvas indicator (double-ring: white outer, dark inner).
38pub const INDICATOR_RADIUS: f32 = 7.0;
39pub const INDICATOR_OUTER_STROKE_WIDTH: f32 = 1.5;
40pub const INDICATOR_INNER_STROKE_WIDTH: f32 = 1.0;
41pub const INDICATOR_OUTER_COLOR: Color = Color::WHITE;
42pub const INDICATOR_INNER_COLOR: Color = Color::new(0.0, 0.0, 0.0, 0.6);
43
44// Strip thumb (the bar across hue / alpha sliders).
45pub const STRIP_THUMB_WIDTH: f32 = 18.0;
46pub const STRIP_THUMB_HEIGHT: f32 = 8.0;
47pub const STRIP_THUMB_CORNER_RADIUS: f32 = 2.0;
48
49// Outer padding + inter-section gap.
50pub const PADDING: f32 = 12.0;
51pub const GAP: f32 = 10.0;
52
53// Preset swatch cells.
54pub const SWATCH_SIZE: f32 = 22.0;
55pub const SWATCH_SPACING: f32 = 6.0;
56pub const SWATCH_CORNER_RADIUS: f32 = 4.0;
57pub const SWATCH_SELECTED_STROKE_WIDTH: f32 = 2.0;
58
59// Checkerboard pattern (alpha visualization on swatches and the
60// alpha strip background).
61pub const CHECKER_CELL: f32 = 6.0;
62pub const CHECKER_COLOR_A: Color = Color::new(0.78, 0.78, 0.78, 1.0);
63pub const CHECKER_COLOR_B: Color = Color::WHITE;
64
65// Current-color preview swatch (inside the picker, distinct from the
66// individual ColorSwatch sizes).
67pub const PREVIEW_WIDTH: f32 = 64.0;
68pub const PREVIEW_HEIGHT: f32 = 28.0;
69pub const PREVIEW_CORNER_RADIUS: f32 = 4.0;
70
71// RGB / HSV spinner cell width and hex-input field width.
72pub const SPINNER_FIELD_WIDTH: f32 = 56.0;
73pub const HEX_FIELD_WIDTH: f32 = 96.0;
74
75/// Configurable dimension recipe for `RecipeColorPickerStyle`.
76///
77/// All fields default to the module-level `pub const` tokens so existing
78/// callers that use `RecipeColorPickerStyle::default()` are unaffected.
79/// Override individual fields to create a custom-sized picker without
80/// writing a full `ColorPickerStyle` implementation.
81#[derive(Debug, Clone, Copy, PartialEq)]
82pub struct ColorPickerRecipe {
83    // HSV canvas
84    pub canvas_width: f32,
85    pub canvas_height: f32,
86    pub canvas_corner_radius: f32,
87    // 1D strips
88    pub strip_thickness: f32,
89    pub strip_length: f32,
90    pub strip_corner_radius: f32,
91    // HSV-canvas indicator
92    pub indicator_radius: f32,
93    pub indicator_outer_stroke_width: f32,
94    pub indicator_inner_stroke_width: f32,
95    pub indicator_outer_color: Color,
96    pub indicator_inner_color: Color,
97    // Strip thumb
98    pub strip_thumb_width: f32,
99    pub strip_thumb_height: f32,
100    pub strip_thumb_corner_radius: f32,
101    // Layout spacing
102    pub padding: f32,
103    pub gap: f32,
104    // Preset swatches
105    pub swatch_size: f32,
106    pub swatch_spacing: f32,
107    pub swatch_corner_radius: f32,
108    pub swatch_selected_stroke_width: f32,
109    // Checkerboard (alpha visualisation)
110    pub checker_cell: f32,
111    pub checker_color_a: Color,
112    pub checker_color_b: Color,
113    // Current-color preview swatch
114    pub preview_width: f32,
115    pub preview_height: f32,
116    pub preview_corner_radius: f32,
117    // Input fields
118    pub spinner_field_width: f32,
119    pub hex_field_width: f32,
120}
121
122impl ColorPickerRecipe {
123    /// This recipe's dimensions resolved against a density's [`InputTokens`].
124    ///
125    /// [`Default`] is `for_tokens(&InputTokens::default())` — the Compact
126    /// ladder — so the shipped values below are the Compact column by
127    /// construction and cannot drift from it.
128    pub fn for_tokens(tokens: &InputTokens) -> Self {
129        Self {
130            canvas_width: dp(CANVAS_WIDTH, TargetRole::Target, tokens),
131            canvas_height: dp(CANVAS_HEIGHT, TargetRole::Target, tokens),
132            canvas_corner_radius: CANVAS_CORNER_RADIUS,
133            strip_thickness: STRIP_THICKNESS,
134            strip_length: dp(STRIP_LENGTH, TargetRole::Target, tokens),
135            strip_corner_radius: STRIP_CORNER_RADIUS,
136            indicator_radius: INDICATOR_RADIUS,
137            indicator_outer_stroke_width: INDICATOR_OUTER_STROKE_WIDTH,
138            indicator_inner_stroke_width: INDICATOR_INNER_STROKE_WIDTH,
139            indicator_outer_color: INDICATOR_OUTER_COLOR,
140            indicator_inner_color: INDICATOR_INNER_COLOR,
141            strip_thumb_width: STRIP_THUMB_WIDTH,
142            strip_thumb_height: STRIP_THUMB_HEIGHT,
143            strip_thumb_corner_radius: STRIP_THUMB_CORNER_RADIUS,
144            padding: spacing(PADDING, tokens),
145            gap: spacing(GAP, tokens),
146            swatch_size: SWATCH_SIZE,
147            swatch_spacing: spacing(SWATCH_SPACING, tokens),
148            swatch_corner_radius: SWATCH_CORNER_RADIUS,
149            swatch_selected_stroke_width: SWATCH_SELECTED_STROKE_WIDTH,
150            checker_cell: CHECKER_CELL,
151            checker_color_a: CHECKER_COLOR_A,
152            checker_color_b: CHECKER_COLOR_B,
153            preview_width: dp(PREVIEW_WIDTH, TargetRole::Target, tokens),
154            preview_height: dp(PREVIEW_HEIGHT, TargetRole::Target, tokens),
155            preview_corner_radius: PREVIEW_CORNER_RADIUS,
156            spinner_field_width: dp(SPINNER_FIELD_WIDTH, TargetRole::Target, tokens),
157            hex_field_width: dp(HEX_FIELD_WIDTH, TargetRole::Target, tokens),
158        }
159    }
160}
161
162impl Default for ColorPickerRecipe {
163    fn default() -> Self {
164        Self::for_tokens(&InputTokens::default())
165    }
166}
167
168/// Default `ColorPickerStyle` shipped with Teksilo.
169#[derive(Debug, Default, Clone, Copy)]
170pub struct RecipeColorPickerStyle {
171    pub recipe: ColorPickerRecipe,
172}
173
174impl RecipeColorPickerStyle {
175    pub fn new(recipe: ColorPickerRecipe) -> Self {
176        Self { recipe }
177    }
178
179    /// This style with every dimension resolved against a density's
180    /// [`InputTokens`], as `RecipeColorPickerStyle::for_tokens(&ctx.theme().input)` at
181    /// the widget's own build site.
182    ///
183    /// [`Default`] is the `TargetDensity::Compact` projection, so a Compact
184    /// tree gets exactly the values this module documents.
185    pub fn for_tokens(tokens: &InputTokens) -> Self {
186        Self {
187            recipe: ColorPickerRecipe::for_tokens(tokens),
188        }
189    }
190}
191
192impl ColorPickerStyle for RecipeColorPickerStyle {
193    fn make_body(&self, cfg: &ColorPickerStyleConfig, ctx: &mut BuildContext) -> WidgetId {
194        let body = match cfg.layout {
195            ColorPickerLayout::Compact => {
196                let mut col = VStack::new().spacing(self.recipe.gap).child(cfg.top_row);
197                if let Some(hex) = cfg.compact_hex {
198                    col = col.child(hex);
199                }
200                if let Some(footer) = cfg.footer {
201                    col = col.child(footer);
202                }
203                ctx.add(col)
204            }
205            ColorPickerLayout::Standard => {
206                let mut col = VStack::new().spacing(self.recipe.gap).child(cfg.top_row);
207                if let Some(preview) = cfg.preview_row {
208                    col = col.child(preview);
209                }
210                if let Some(rgb) = cfg.rgb_row {
211                    col = col.child(rgb);
212                }
213                if let Some(hsv) = cfg.hsv_row {
214                    col = col.child(hsv);
215                }
216                if let Some(grid) = cfg.swatches {
217                    col = col.child(grid);
218                }
219                if let Some(footer) = cfg.footer {
220                    col = col.child(footer);
221                }
222                ctx.add(col)
223            }
224            ColorPickerLayout::Wide => {
225                let mut side_col = VStack::new().spacing(self.recipe.gap);
226                if let Some(preview) = cfg.preview_row {
227                    side_col = side_col.child(preview);
228                }
229                if let Some(rgb) = cfg.rgb_row {
230                    side_col = side_col.child(rgb);
231                }
232                if let Some(hsv) = cfg.hsv_row {
233                    side_col = side_col.child(hsv);
234                }
235                let side_col_id = ctx.add(side_col);
236                let main_row_id = ctx.add(
237                    HStack::new()
238                        .spacing(self.recipe.gap)
239                        .child(cfg.top_row)
240                        .child(side_col_id),
241                );
242                let mut col = VStack::new().spacing(self.recipe.gap).child(main_row_id);
243                if let Some(grid) = cfg.swatches {
244                    col = col.child(grid);
245                }
246                if let Some(footer) = cfg.footer {
247                    col = col.child(footer);
248                }
249                ctx.add(col)
250            }
251        };
252
253        // Wrap in a Panel so the picker reads as a self-contained
254        // surface (background + border + corner radius). Embedding in
255        // an overlay without this produces a transparent popup.
256        ctx.add(
257            Panel::new()
258                .padding(self.recipe.padding)
259                .border_width(1.0)
260                .child(body),
261        )
262    }
263}