oxiui_iced/theme.rs
1//! COOLJAPAN palette → iced theme conversion.
2//!
3//! Maps `oxiui_core::Palette` semantic colours to the closest equivalents in
4//! `iced::theme::palette::Palette` (iced 0.14). The mapping is intentionally
5//! lossy: OxiUI has 6 semantic slots; iced's palette has 6 as well
6//! (background, text, primary, success, warning, danger), but the semantics
7//! differ slightly. Cosmetic slots without a direct equivalent reuse `muted`.
8//!
9//! Also provides [`DesignTokensAdapter`] to expose `oxiui-theme` design tokens
10//! and typography as iced-compatible values (pixel sizes, padding), and the
11//! [`palette_and_tokens_to_iced_theme`] convenience wrapper.
12
13use iced::widget::scrollable;
14use iced::widget::text_input;
15use iced::{Background, Border};
16use oxiui_core::{Palette, Theme};
17use oxiui_theme::{DesignTokens, RadiusStep, SpacingStep, TypographyScale};
18
19/// Convert an OxiUI [`Palette`] into an `iced::Theme`.
20///
21/// The resulting theme is a `Theme::Custom` variant containing an
22/// `iced::theme::palette::Palette` whose fields are populated from the
23/// OxiUI palette using the following mapping:
24///
25/// | iced field | OxiUI source |
26/// |------------|-------------------|
27/// | background | palette.background |
28/// | text | palette.text |
29/// | primary | palette.primary |
30/// | success | palette.muted |
31/// | warning | palette.muted |
32/// | danger | palette.surface |
33pub fn palette_to_iced_theme(p: &Palette) -> iced::Theme {
34 let iced_palette = iced::theme::palette::Palette {
35 background: color_to_iced(&p.background),
36 text: color_to_iced(&p.text),
37 primary: color_to_iced(&p.primary),
38 success: color_to_iced(&p.muted),
39 warning: color_to_iced(&p.muted),
40 danger: color_to_iced(&p.surface),
41 };
42 iced::Theme::custom("OxiUI COOLJAPAN", iced_palette)
43}
44
45/// Convert an OxiUI [`Theme`] trait object into an `iced::Theme`.
46///
47/// This is an extended version of [`palette_to_iced_theme`] that accepts any
48/// `&dyn Theme` (rather than a bare `&Palette`), extracting the palette via
49/// [`Theme::palette`] and delegating to the core conversion.
50///
51/// The `oxiui_core::Palette` has the following semantic fields available for
52/// mapping (there are no separate error/warning/success fields in the current
53/// palette schema; those iced slots receive the closest approximation):
54///
55/// | iced field | OxiUI source | Rationale |
56/// |------------|-------------------|-----------------------------------|
57/// | background | palette.background | direct match |
58/// | text | palette.text | direct match |
59/// | primary | palette.primary | direct match |
60/// | success | palette.muted | no success slot → muted (subdued) |
61/// | warning | palette.muted | no warning slot → muted (subdued) |
62/// | danger | palette.surface | no error slot → surface (neutral) |
63///
64/// When `oxiui_core::Palette` gains error/warning/success fields in a future
65/// milestone, update this function's mapping accordingly.
66pub fn palette_to_iced_theme_ext(theme: &dyn Theme) -> iced::Theme {
67 palette_to_iced_theme(theme.palette())
68}
69
70pub(crate) fn color_to_iced(c: &oxiui_core::Color) -> iced::Color {
71 let oxiui_core::Color(r, g, b, a) = *c;
72 iced::Color::from_rgba8(r, g, b, a as f32 / 255.0)
73}
74
75/// Produce an iced `text_input::Style` derived from an OxiUI [`Palette`].
76///
77/// Sets `border.color` from the palette's `primary` colour and `border.radius`
78/// to 2 px. The background, placeholder, value, and selection colours fall
79/// back to sensible defaults from the palette.
80pub fn text_input_style_from_palette(p: &Palette) -> text_input::Style {
81 let background_color = color_to_iced(&p.background);
82 let text_color = color_to_iced(&p.text);
83 let primary_color = color_to_iced(&p.primary);
84 let muted_color = color_to_iced(&p.muted);
85 let mut selection = primary_color;
86 selection.a = 0.4;
87
88 text_input::Style {
89 background: Background::Color(background_color),
90 border: Border {
91 color: primary_color,
92 width: 1.0,
93 radius: 2.0.into(),
94 },
95 icon: muted_color,
96 placeholder: muted_color,
97 value: text_color,
98 selection,
99 }
100}
101
102/// Produce an iced `scrollable::Style` derived from an OxiUI [`Palette`].
103///
104/// Sets the scroller background to the palette's `primary` colour and the rail
105/// background to the palette's `surface` colour.
106pub fn scrollable_style_from_palette(p: &Palette) -> scrollable::Style {
107 let primary_color = color_to_iced(&p.primary);
108 let surface_color = color_to_iced(&p.surface);
109
110 let rail = scrollable::Rail {
111 background: Some(Background::Color(surface_color)),
112 border: Border::default(),
113 scroller: scrollable::Scroller {
114 background: Background::Color(primary_color),
115 border: Border::default(),
116 },
117 };
118
119 scrollable::Style {
120 container: iced::widget::container::Style::default(),
121 vertical_rail: rail,
122 horizontal_rail: rail,
123 gap: None,
124 auto_scroll: scrollable::AutoScroll {
125 background: Background::Color(surface_color),
126 border: Border::default(),
127 shadow: iced::Shadow::default(),
128 icon: primary_color,
129 },
130 }
131}
132
133/// Produce `text_input_style_from_palette` using a `&dyn Theme` trait object.
134pub fn text_input_style_from_theme(theme: &dyn Theme) -> text_input::Style {
135 text_input_style_from_palette(theme.palette())
136}
137
138/// Produce `scrollable_style_from_palette` using a `&dyn Theme` trait object.
139pub fn scrollable_style_from_theme(theme: &dyn Theme) -> scrollable::Style {
140 scrollable_style_from_palette(theme.palette())
141}
142
143// ── DesignTokens integration ──────────────────────────────────────────────────
144
145/// Convert an OxiUI [`Palette`] into an `iced::Theme`, optionally informed by
146/// [`DesignTokens`] and [`TypographyScale`].
147///
148/// # Limitation (iced 0.14)
149///
150/// `iced::Theme::Custom` wraps only a colour palette — it has no slots for
151/// border radius, spacing, or typography. Those values cannot be folded into
152/// the returned `iced::Theme` at this level; they are exposed through
153/// [`DesignTokensAdapter`] for per-widget use instead. The `_tokens` and
154/// `_typography` parameters are accepted for API symmetry and future extension
155/// but do not alter the produced theme in iced 0.14.
156///
157/// # Deviation note
158///
159/// Full "respect tokens" integration requires threading a [`DesignTokensAdapter`]
160/// into individual widget render sites (e.g. `text_input_style_from_palette`
161/// border radius, `build_one` heading/body font sizes). That per-site wiring is
162/// a separate follow-up; this function provides the public seam.
163pub fn palette_and_tokens_to_iced_theme(
164 palette: &Palette,
165 _tokens: Option<&DesignTokens>,
166 _typography: Option<&TypographyScale>,
167) -> iced::Theme {
168 // iced 0.14 Theme::Custom holds only colours; tokens cannot be embedded.
169 palette_to_iced_theme(palette)
170}
171
172/// Applies [`DesignTokens`] and [`TypographyScale`] to produce iced-compatible
173/// style values for use in per-widget style helpers.
174///
175/// iced 0.14 has no global style-override hook — styles are set per-widget
176/// (e.g. `button::style`, `text_input::style`). `DesignTokensAdapter` exposes
177/// the token values as iced primitives so callers can apply them at the widget
178/// call site without repeating the token-field mapping.
179///
180/// # Example
181///
182/// ```rust
183/// use oxiui_iced::DesignTokensAdapter;
184/// use oxiui_theme::{DesignTokens, TypographyScale};
185///
186/// let adapter = DesignTokensAdapter::from_tokens(
187/// &DesignTokens::default(),
188/// &TypographyScale::default(),
189/// );
190/// assert!(adapter.body_font_size > 0.0);
191/// let _padding = adapter.standard_padding();
192/// let _body_sz = adapter.body_text_size();
193/// ```
194#[derive(Clone, Copy, Debug, PartialEq)]
195pub struct DesignTokensAdapter {
196 /// Medium border radius in logical pixels (mapped from `RadiusStep::Md`).
197 pub border_radius: f32,
198 /// Body font size in logical pixels (from `TypographyScale::body.size`).
199 pub body_font_size: f32,
200 /// Headline font size in logical pixels (from `TypographyScale::headline.size`).
201 pub headline_font_size: f32,
202 /// Base spacing in logical pixels (mapped from `SpacingStep::Sm`, 8 px by default).
203 pub base_spacing: f32,
204}
205
206impl DesignTokensAdapter {
207 /// Build an adapter from references to a [`DesignTokens`] and a
208 /// [`TypographyScale`].
209 ///
210 /// Field mapping:
211 /// - `border_radius` ← `tokens.radius(RadiusStep::Md)` (4.0 px by default)
212 /// - `body_font_size` ← `typography.body.size` (14.0 px by default)
213 /// - `headline_font_size` ← `typography.headline.size` (24.0 px by default)
214 /// - `base_spacing` ← `tokens.spacing(SpacingStep::Sm)` (8.0 px by default)
215 pub fn from_tokens(tokens: &DesignTokens, typography: &TypographyScale) -> Self {
216 Self {
217 border_radius: tokens.radius(RadiusStep::Md),
218 body_font_size: typography.body.size,
219 headline_font_size: typography.headline.size,
220 base_spacing: tokens.spacing(SpacingStep::Sm),
221 }
222 }
223
224 /// Returns an iced text size for body text.
225 pub fn body_text_size(&self) -> iced::Pixels {
226 iced::Pixels(self.body_font_size)
227 }
228
229 /// Returns an iced text size for headlines.
230 pub fn headline_text_size(&self) -> iced::Pixels {
231 iced::Pixels(self.headline_font_size)
232 }
233
234 /// Returns an iced [`iced::Padding`] with uniform padding equal to
235 /// `base_spacing` on all sides.
236 pub fn standard_padding(&self) -> iced::Padding {
237 iced::Padding::from(self.base_spacing)
238 }
239}