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