Skip to main content

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}