Skip to main content

iced_code_editor/
theme.rs

1use iced::Color;
2
3/// The appearance of a code editor.
4#[derive(Debug, Clone, Copy)]
5pub struct Style {
6    /// Main editor background color
7    pub background: Color,
8    /// Text content color
9    pub text_color: Color,
10    /// Line numbers gutter background color
11    pub gutter_background: Color,
12    /// Border color for the gutter
13    pub gutter_border: Color,
14    /// Color for line numbers text
15    pub line_number_color: Color,
16    /// Scrollbar background color
17    pub scrollbar_background: Color,
18    /// Scrollbar scroller (thumb) color
19    pub scroller_color: Color,
20    /// Highlight color for the current line where cursor is located
21    pub current_line_highlight: Color,
22}
23
24/// The theme catalog of a code editor.
25pub trait Catalog {
26    /// The item class of the [`Catalog`].
27    type Class<'a>;
28
29    /// The default class produced by the [`Catalog`].
30    fn default<'a>() -> Self::Class<'a>;
31
32    /// The [`Style`] of a class with the given status.
33    fn style(&self, class: &Self::Class<'_>) -> Style;
34}
35
36/// A styling function for a code editor.
37///
38/// This is a shorthand for a function that takes a reference to a
39/// [`Theme`](iced::Theme) and returns a [`Style`].
40pub type StyleFn<'a, Theme> = Box<dyn Fn(&Theme) -> Style + 'a>;
41
42impl Catalog for iced::Theme {
43    type Class<'a> = StyleFn<'a, Self>;
44
45    fn default<'a>() -> Self::Class<'a> {
46        Box::new(from_iced_theme)
47    }
48
49    fn style(&self, class: &Self::Class<'_>) -> Style {
50        class(self)
51    }
52}
53
54/// Creates a theme style automatically from any Iced theme.
55///
56/// This is the default styling function that adapts to all native Iced themes including:
57/// - Basic themes: Light, Dark
58/// - Popular themes: Dracula, Nord, Solarized, Gruvbox
59/// - Catppuccin variants: Latte, Frappé, Macchiato, Mocha
60/// - Tokyo Night variants: Tokyo Night, Storm, Light
61/// - Kanagawa variants: Wave, Dragon, Lotus
62/// - And more: Moonfly, Nightfly, Oxocarbon, Ferra
63///
64/// The function automatically detects if the theme is dark or light and adjusts
65/// colors accordingly for optimal contrast and readability in code editing.
66///
67/// # Color Mapping
68///
69/// - `background`: Uses the theme's base background color
70/// - `text_color`: Uses the theme's base text color
71/// - `gutter_background`: Slightly darker/lighter than background
72/// - `gutter_border`: Border between gutter and editor
73/// - `line_number_color`: Dimmed text color for subtle line numbers
74/// - `scrollbar_background`: Matches editor background
75/// - `scroller_color`: Uses secondary color for visibility
76/// - `current_line_highlight`: Subtle highlight using primary color
77///
78/// # Example
79///
80/// ```
81/// use iced_code_editor::theme;
82///
83/// let tokyo_night = iced::Theme::TokyoNightStorm;
84/// let style = theme::from_iced_theme(&tokyo_night);
85///
86/// // Or use with any theme variant
87/// let dracula = iced::Theme::Dracula;
88/// let style = theme::from_iced_theme(&dracula);
89/// ```
90pub fn from_iced_theme(theme: &iced::Theme) -> Style {
91    let palette = theme.extended_palette();
92    let is_dark = palette.is_dark;
93
94    // Base colors from theme palette
95    let background = palette.background.base.color;
96    let text_color = palette.background.base.text;
97
98    // Gutter colors: slightly offset from background for subtle distinction
99    let gutter_background = palette.background.weak.color;
100    let gutter_border = if is_dark {
101        darken(palette.background.strong.color, 0.1)
102    } else {
103        lighten(palette.background.strong.color, 0.1)
104    };
105
106    // Line numbers: dimmed text color for subtlety
107    // For dark themes: dim the bright text (make it darker)
108    // For light themes: blend text towards background (make it lighter/grayer)
109    let line_number_color = if is_dark {
110        dim_color(text_color, 0.5)
111    } else {
112        // For light themes, blend text color towards background
113        blend_colors(text_color, background, 0.5)
114    };
115
116    // Scrollbar colors: blend with background
117    let scrollbar_background = background;
118    let scroller_color = palette.secondary.weak.color;
119
120    // Current line highlight: very subtle with primary color
121    let current_line_highlight = with_alpha(
122        palette.primary.weak.color,
123        if is_dark { 0.15 } else { 0.25 },
124    );
125
126    Style {
127        background,
128        text_color,
129        gutter_background,
130        gutter_border,
131        line_number_color,
132        scrollbar_background,
133        scroller_color,
134        current_line_highlight,
135    }
136}
137
138/// Darkens a color by a given factor (0.0 to 1.0).
139fn darken(color: Color, factor: f32) -> Color {
140    Color {
141        r: color.r * (1.0 - factor),
142        g: color.g * (1.0 - factor),
143        b: color.b * (1.0 - factor),
144        a: color.a,
145    }
146}
147
148/// Lightens a color by a given factor (0.0 to 1.0).
149fn lighten(color: Color, factor: f32) -> Color {
150    Color {
151        r: color.r + (1.0 - color.r) * factor,
152        g: color.g + (1.0 - color.g) * factor,
153        b: color.b + (1.0 - color.b) * factor,
154        a: color.a,
155    }
156}
157
158/// Dims a color by reducing its intensity.
159fn dim_color(color: Color, factor: f32) -> Color {
160    Color {
161        r: color.r * factor,
162        g: color.g * factor,
163        b: color.b * factor,
164        a: color.a,
165    }
166}
167
168/// Blends two colors together by a given factor (0.0 = first color, 1.0 = second color).
169fn blend_colors(color1: Color, color2: Color, factor: f32) -> Color {
170    Color {
171        r: color1.r + (color2.r - color1.r) * factor,
172        g: color1.g + (color2.g - color1.g) * factor,
173        b: color1.b + (color2.b - color1.b) * factor,
174        a: color1.a + (color2.a - color1.a) * factor,
175    }
176}
177
178/// Applies an alpha transparency to a color.
179fn with_alpha(color: Color, alpha: f32) -> Color {
180    Color { r: color.r, g: color.g, b: color.b, a: alpha }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn test_from_iced_theme_dark() {
189        let theme = iced::Theme::Dark;
190        let style = from_iced_theme(&theme);
191
192        // Dark theme should have dark background
193        let brightness =
194            (style.background.r + style.background.g + style.background.b)
195                / 3.0;
196        assert!(brightness < 0.5, "Dark theme should have dark background");
197
198        // Text should be bright for contrast
199        let text_brightness =
200            (style.text_color.r + style.text_color.g + style.text_color.b)
201                / 3.0;
202        assert!(text_brightness > 0.5, "Dark theme should have bright text");
203    }
204
205    #[test]
206    fn test_from_iced_theme_light() {
207        let theme = iced::Theme::Light;
208        let style = from_iced_theme(&theme);
209
210        // Light theme should have bright background
211        let brightness =
212            (style.background.r + style.background.g + style.background.b)
213                / 3.0;
214        assert!(brightness > 0.5, "Light theme should have bright background");
215
216        // Text should be dark for contrast
217        let text_brightness =
218            (style.text_color.r + style.text_color.g + style.text_color.b)
219                / 3.0;
220        assert!(text_brightness < 0.5, "Light theme should have dark text");
221    }
222
223    #[test]
224    fn test_all_iced_themes_produce_valid_styles() {
225        // Test all native Iced themes
226        for theme in iced::Theme::ALL {
227            let style = from_iced_theme(theme);
228
229            // All color components should be valid (0.0 to 1.0)
230            assert!(style.background.r >= 0.0 && style.background.r <= 1.0);
231            assert!(style.text_color.r >= 0.0 && style.text_color.r <= 1.0);
232            assert!(
233                style.gutter_background.r >= 0.0
234                    && style.gutter_background.r <= 1.0
235            );
236            assert!(
237                style.line_number_color.r >= 0.0
238                    && style.line_number_color.r <= 1.0
239            );
240
241            // Current line highlight should have transparency
242            assert!(
243                style.current_line_highlight.a < 1.0,
244                "Current line highlight should be semi-transparent for theme: {:?}",
245                theme
246            );
247        }
248    }
249
250    #[test]
251    fn test_tokyo_night_themes() {
252        // Test Tokyo Night variants specifically
253        let tokyo_night = iced::Theme::TokyoNight;
254        let style = from_iced_theme(&tokyo_night);
255        assert!(style.background.r >= 0.0 && style.background.r <= 1.0);
256
257        let tokyo_storm = iced::Theme::TokyoNightStorm;
258        let style = from_iced_theme(&tokyo_storm);
259        assert!(style.background.r >= 0.0 && style.background.r <= 1.0);
260
261        let tokyo_light = iced::Theme::TokyoNightLight;
262        let style = from_iced_theme(&tokyo_light);
263        let brightness =
264            (style.background.r + style.background.g + style.background.b)
265                / 3.0;
266        assert!(
267            brightness > 0.5,
268            "Tokyo Night Light should have bright background"
269        );
270    }
271
272    #[test]
273    fn test_catppuccin_themes() {
274        // Test Catppuccin variants
275        let themes = [
276            iced::Theme::CatppuccinLatte,
277            iced::Theme::CatppuccinFrappe,
278            iced::Theme::CatppuccinMacchiato,
279            iced::Theme::CatppuccinMocha,
280        ];
281
282        for theme in themes {
283            let style = from_iced_theme(&theme);
284            // All should produce valid styles
285            assert!(style.background.r >= 0.0 && style.background.r <= 1.0);
286            assert!(style.text_color.r >= 0.0 && style.text_color.r <= 1.0);
287        }
288    }
289
290    #[test]
291    fn test_gutter_colors_distinct_from_background() {
292        let theme = iced::Theme::Dark;
293        let style = from_iced_theme(&theme);
294
295        // Gutter background should be different from editor background
296        let gutter_diff = (style.gutter_background.r - style.background.r)
297            .abs()
298            + (style.gutter_background.g - style.background.g).abs()
299            + (style.gutter_background.b - style.background.b).abs();
300
301        assert!(
302            gutter_diff > 0.0,
303            "Gutter should be visually distinct from background"
304        );
305    }
306
307    #[test]
308    fn test_line_numbers_visible_but_subtle() {
309        for theme in [iced::Theme::Dark, iced::Theme::Light] {
310            let style = from_iced_theme(&theme);
311            let palette = theme.extended_palette();
312
313            // Line numbers should be dimmed compared to text
314            let line_num_brightness = (style.line_number_color.r
315                + style.line_number_color.g
316                + style.line_number_color.b)
317                / 3.0;
318
319            let text_brightness =
320                (style.text_color.r + style.text_color.g + style.text_color.b)
321                    / 3.0;
322
323            let bg_brightness =
324                (style.background.r + style.background.g + style.background.b)
325                    / 3.0;
326
327            // Line numbers should be between text and background (more subtle than text)
328            // For dark themes: text is bright, line numbers dimmer, background dark
329            // For light themes: text is dark, line numbers lighter (gray), background bright
330            if palette.is_dark {
331                // Dark theme: line numbers should be less bright than text
332                assert!(
333                    line_num_brightness < text_brightness,
334                    "Dark theme line numbers should be dimmer than text. Line num: {}, Text: {}",
335                    line_num_brightness,
336                    text_brightness
337                );
338            } else {
339                // Light theme: line numbers should be between text (dark) and background (bright)
340                assert!(
341                    line_num_brightness > text_brightness
342                        && line_num_brightness < bg_brightness,
343                    "Light theme line numbers should be between text and background. Text: {}, Line num: {}, Bg: {}",
344                    text_brightness,
345                    line_num_brightness,
346                    bg_brightness
347                );
348            }
349        }
350    }
351
352    #[test]
353    fn test_color_helper_functions() {
354        let color = Color::from_rgb(0.5, 0.5, 0.5);
355
356        // Test darken
357        let darker = darken(color, 0.5);
358        assert!(darker.r < color.r);
359        assert!(darker.g < color.g);
360        assert!(darker.b < color.b);
361
362        // Test lighten
363        let lighter = lighten(color, 0.5);
364        assert!(lighter.r > color.r);
365        assert!(lighter.g > color.g);
366        assert!(lighter.b > color.b);
367
368        // Test dim_color
369        let dimmed = dim_color(color, 0.5);
370        assert!(dimmed.r < color.r);
371
372        // Test with_alpha
373        let transparent = with_alpha(color, 0.3);
374        assert!((transparent.a - 0.3).abs() < f32::EPSILON);
375        assert!((transparent.r - color.r).abs() < f32::EPSILON);
376    }
377
378    #[test]
379    fn test_style_copy() {
380        let theme = iced::Theme::Dark;
381        let style1 = from_iced_theme(&theme);
382        let style2 = style1;
383
384        // Verify colors are approximately equal (using epsilon for float comparison)
385        assert!(
386            (style1.background.r - style2.background.r).abs() < f32::EPSILON
387        );
388        assert!(
389            (style1.text_color.r - style2.text_color.r).abs() < f32::EPSILON
390        );
391        assert!(
392            (style1.gutter_background.r - style2.gutter_background.r).abs()
393                < f32::EPSILON
394        );
395    }
396
397    #[test]
398    fn test_catalog_default() {
399        let theme = iced::Theme::Dark;
400        let class = <iced::Theme as Catalog>::default();
401        let style = theme.style(&class);
402
403        // Should produce a valid style
404        assert!(style.background.r >= 0.0 && style.background.r <= 1.0);
405        assert!(style.text_color.r >= 0.0 && style.text_color.r <= 1.0);
406    }
407}