rat_theme4/
lib.rs

1//!
2//! SalsaTheme provides a styling system for ratatui apps.
3//!
4//! It has a simple flat naming scheme.
5//!
6//! But it can store
7//! * [ratatui Style](ratatui::style::Style)
8//! * composite styles as used by [rat-widget](rat_widget).
9//!   eg [CheckboxStyle](rat_widget::checkbox::CheckboxStyle)
10//! * practically anything else.
11//!
12//! ## Naming styles
13//!
14//! * It has an extension trait for [Style](ratatui::style::Style) that
15//!   adds constants for known styles. In the same manner you can add your
16//!   application specific styles and have them with code completion.
17//!
18//! * For [rat-widget](rat_widget) composite style it defines an anchor struct
19//!   [WidgetStyle] that performs the same purpose.
20//!
21//! ## Usage
22//!
23//! ```rust
24//! # use ratatui::buffer::Buffer;
25//! # use ratatui::layout::Rect;
26//! # use ratatui::style::Style;
27//! # use ratatui::widgets::StatefulWidget;
28//! # use rat_theme4::theme::{SalsaTheme};
29//! # use rat_theme4::{StyleName, WidgetStyle};
30//! # use rat_theme4::palettes::dark::BLACKOUT;
31//! # use rat_widget::checkbox::{Checkbox, CheckboxState, CheckboxStyle};
32//! # let theme = SalsaTheme::default();
33//! # let area = Rect::default();
34//! # let mut buf = Buffer::default();
35//! # let buf = &mut buf;
36//! # let mut state = CheckboxState::default();
37//!
38//! // ratatui Style
39//! let s = theme.style::<Style>(Style::SELECT);
40//!
41//! // composite style
42//! Checkbox::new()
43//!     .styles(theme.style(WidgetStyle::CHECKBOX))
44//!     .render(area, buf, &mut state);
45//! ```
46
47use crate::theme::Category;
48use ratatui::style::{Color, Style};
49use std::borrow::Cow;
50use std::collections::HashMap;
51use std::error::Error;
52use std::fmt::{Display, Formatter};
53use std::io;
54use std::io::ErrorKind;
55use std::sync::OnceLock;
56use std::sync::atomic::{AtomicBool, Ordering};
57
58pub mod palette;
59pub mod theme;
60
61/// Currently shipped palettes.
62pub mod palettes {
63    pub mod core;
64    pub mod dark;
65    pub mod light;
66}
67
68pub mod themes {
69    mod core;
70    mod dark;
71    mod fallback;
72    mod shell;
73
74    /// Create a `core` theme that acts as a fallback.
75    /// It uses the SHELL palette and set almost no backgrounds.
76    pub use core::create_core;
77    /// Creates a `dark` theme.
78    pub use dark::create_dark;
79    /// Create the `fallback` theme.
80    /// This is more for testing widgets than anything else.
81    /// It just uses `Default::default()` for any style.
82    /// This helps to check if a widget is still functional
83    /// if no styling is applied.
84    pub use fallback::create_fallback;
85    /// Creates a `shell` theme. This uses the dark palettes,
86    /// but sets almost no backgrounds. Instead, it lets the
87    /// terminal background shine.
88    pub use shell::create_shell;
89}
90
91/// Anchor struct for the names of composite styles used
92/// by rat-widget's.
93///
94/// Use as
95/// ```rust
96/// # use ratatui::style::Style;
97/// # use rat_theme4::theme::{SalsaTheme};
98/// # use rat_theme4::{ StyleName, WidgetStyle};
99/// # use rat_theme4::palettes::dark::BLACKOUT;
100/// # use rat_widget::checkbox::CheckboxStyle;
101/// # let theme = SalsaTheme::default();
102///
103/// let s: CheckboxStyle = theme.style(WidgetStyle::CHECKBOX);
104/// ```
105/// or more likely
106/// ```rust
107/// # use ratatui::buffer::Buffer;
108/// # use ratatui::layout::Rect;
109/// # use ratatui::style::Style;
110/// # use ratatui::widgets::StatefulWidget;
111/// # use rat_theme4::theme::{SalsaTheme};
112/// # use rat_theme4::{ StyleName, WidgetStyle};
113/// # use rat_theme4::palettes::dark::BLACKOUT;
114/// # use rat_widget::checkbox::{Checkbox, CheckboxState, CheckboxStyle};
115/// # let theme = SalsaTheme::default();
116/// # let area = Rect::default();
117/// # let mut buf = Buffer::default();
118/// # let buf = &mut buf;
119/// # let mut state = CheckboxState::default();
120///
121/// Checkbox::new()
122///     .styles(theme.style(WidgetStyle::CHECKBOX))
123///     .render(area, buf, &mut state);
124/// ```
125pub struct WidgetStyle;
126
127impl WidgetStyle {
128    pub const BUTTON: &'static str = "button";
129    pub const CALENDAR: &'static str = "calendar";
130    pub const CHECKBOX: &'static str = "checkbox";
131    pub const CHOICE: &'static str = "choice";
132    pub const CLIPPER: &'static str = "clipper";
133    #[cfg(feature = "color_input")]
134    pub const COLOR_INPUT: &'static str = "color-input";
135    pub const COMBOBOX: &'static str = "combobox";
136    pub const DIALOG_FRAME: &'static str = "dialog-frame";
137    pub const FILE_DIALOG: &'static str = "file-dialog";
138    pub const FORM: &'static str = "form";
139    pub const LINE_NR: &'static str = "line-nr";
140    pub const LIST: &'static str = "list";
141    pub const MENU: &'static str = "menu";
142    pub const MONTH: &'static str = "month";
143    pub const MSG_DIALOG: &'static str = "msg-dialog";
144    pub const PARAGRAPH: &'static str = "paragraph";
145    pub const RADIO: &'static str = "radio";
146    pub const SCROLL: &'static str = "scroll";
147    pub const SCROLL_DIALOG: &'static str = "scroll.dialog";
148    pub const SCROLL_POPUP: &'static str = "scroll.popup";
149    pub const SHADOW: &'static str = "shadow";
150    pub const SLIDER: &'static str = "slider";
151    pub const SPLIT: &'static str = "split";
152    pub const STATUSLINE: &'static str = "statusline";
153    pub const TABBED: &'static str = "tabbed";
154    pub const TABLE: &'static str = "table";
155    pub const TEXT: &'static str = "text";
156    pub const TEXTAREA: &'static str = "textarea";
157    pub const TEXTVIEW: &'static str = "textview";
158    pub const VIEW: &'static str = "view";
159}
160
161/// Extension trait for [Style](ratatui::style::Style) that defines
162/// some standard names used by rat-theme/rat-widget
163///
164/// Use as
165/// ```rust
166/// # use ratatui::style::Style;
167/// # use rat_theme4::theme::{SalsaTheme};
168/// # use rat_theme4::{ StyleName, WidgetStyle};
169/// # use rat_theme4::palettes::dark::BLACKOUT;
170/// # let theme = SalsaTheme::default();
171///
172/// let s: Style = theme.style(Style::INPUT);
173/// ```
174pub trait StyleName {
175    const LABEL_FG: &'static str = "label-fg";
176    const INPUT: &'static str = "input";
177    const INPUT_FOCUS: &'static str = "text-focus";
178    const INPUT_SELECT: &'static str = "text-select";
179    const FOCUS: &'static str = "focus";
180    const SELECT: &'static str = "select";
181    const DISABLED: &'static str = "disabled";
182    const INVALID: &'static str = "invalid";
183
184    const TITLE: &'static str = "title";
185    const HEADER: &'static str = "header";
186    const FOOTER: &'static str = "footer";
187
188    const HOVER: &'static str = "hover";
189    const SHADOWS: &'static str = "shadows";
190
191    const WEEK_HEADER_FG: &'static str = "week-header-fg";
192    const MONTH_HEADER_FG: &'static str = "month-header-fg";
193
194    const KEY_BINDING: &'static str = "key-binding";
195    const BUTTON_BASE: &'static str = "button-base";
196    const MENU_BASE: &'static str = "menu-base";
197    const STATUS_BASE: &'static str = "status-base";
198
199    const CONTAINER_BASE: &'static str = "container-base";
200    const CONTAINER_BORDER_FG: &'static str = "container-border-fg";
201    const CONTAINER_ARROW_FG: &'static str = "container-arrows-fg";
202
203    const DOCUMENT_BASE: &'static str = "document-base";
204    const DOCUMENT_BORDER_FG: &'static str = "document-border-fg";
205    const DOCUMENT_ARROW_FG: &'static str = "document-arrows-fg";
206
207    const POPUP_BASE: &'static str = "popup-base";
208    const POPUP_BORDER_FG: &'static str = "popup-border-fg";
209    const POPUP_ARROW_FG: &'static str = "popup-arrow-fg";
210
211    const DIALOG_BASE: &'static str = "dialog-base";
212    const DIALOG_BORDER_FG: &'static str = "dialog-border-fg";
213    const DIALOG_ARROW_FG: &'static str = "dialog-arrow-fg";
214}
215impl StyleName for Style {}
216
217///
218/// Extension trait for [Color](ratatui::style::Color) that defines
219/// standard names used by rat-theme to define color-aliases.
220///
221/// Use as
222/// ```rust
223/// # use ratatui::style::{Style, Color};
224/// # use rat_theme4::theme::{SalsaTheme};
225/// # use rat_theme4::RatWidgetColor;
226/// # let theme = SalsaTheme::default();
227///
228/// let c: Color = theme.p.color_alias(Color::LABEL_FG);
229/// ```
230pub trait RatWidgetColor {
231    const LABEL_FG: &'static str = "label.fg";
232    const INPUT_BG: &'static str = "input.bg";
233    const INPUT_FOCUS_BG: &'static str = "input-focus.bg";
234    const INPUT_SELECT_BG: &'static str = "input-select.bg";
235    const FOCUS_BG: &'static str = "focus.bg";
236    const SELECT_BG: &'static str = "select.bg";
237    const DISABLED_BG: &'static str = "disabled.bg";
238    const INVALID_BG: &'static str = "invalid.bg";
239
240    const TITLE_FG: &'static str = "title.fg";
241    const TITLE_BG: &'static str = "title.bg";
242    const HEADER_FG: &'static str = "header.fg";
243    const HEADER_BG: &'static str = "header.bg";
244    const FOOTER_FG: &'static str = "footer.fg";
245    const FOOTER_BG: &'static str = "footer.bg";
246
247    const HOVER_BG: &'static str = "hover.bg";
248    const BUTTON_BASE_BG: &'static str = "button-base.bg";
249    const KEY_BINDING_BG: &'static str = "key-binding.bg";
250    const MENU_BASE_BG: &'static str = "menu-base.bg";
251    const STATUS_BASE_BG: &'static str = "status-base.bg";
252    const SHADOW_BG: &'static str = "shadow.bg";
253
254    const WEEK_HEADER_FG: &'static str = "week-header.fg";
255    const MONTH_HEADER_FG: &'static str = "month-header.fg";
256
257    const CONTAINER_BASE_BG: &'static str = "container-base.bg";
258    const CONTAINER_BORDER_FG: &'static str = "container-border.fg";
259    const CONTAINER_ARROW_FG: &'static str = "container-arrow.fg";
260    const DOCUMENT_BASE_BG: &'static str = "document-base.bg";
261    const DOCUMENT_BORDER_FG: &'static str = "document-border.fg";
262    const DOCUMENT_ARROW_FG: &'static str = "document-arrow.fg";
263    const POPUP_BASE_BG: &'static str = "popup-base.bg";
264    const POPUP_BORDER_FG: &'static str = "popup-border.fg";
265    const POPUP_ARROW_FG: &'static str = "popup-arrow.fg";
266    const DIALOG_BASE_BG: &'static str = "dialog-base.bg";
267    const DIALOG_BORDER_FG: &'static str = "dialog-border.fg";
268    const DIALOG_ARROW_FG: &'static str = "dialog-arrow.fg";
269}
270impl RatWidgetColor for Color {}
271
272static LOG_DEFINES: AtomicBool = AtomicBool::new(false);
273
274/// Log style definition.
275/// May help debugging styling problems ...
276pub fn log_style_define(log: bool) {
277    LOG_DEFINES.store(log, Ordering::Release);
278}
279
280fn is_log_style_define() -> bool {
281    LOG_DEFINES.load(Ordering::Acquire)
282}
283
284const PALETTE_DEF: &str = include_str!("themes.ini");
285
286#[derive(Debug)]
287struct Def {
288    palette: Vec<&'static str>,
289    theme: Vec<&'static str>,
290    theme_init: HashMap<&'static str, (&'static str, &'static str)>,
291}
292
293static THEMES: OnceLock<Def> = OnceLock::new();
294
295fn init_themes() -> Def {
296    let mut palette = Vec::new();
297    let mut theme = Vec::new();
298    let mut theme_init = HashMap::new();
299
300    for l in PALETTE_DEF.lines() {
301        if !l.contains('=') {
302            continue;
303        }
304
305        let mut it = l.split(['=', ',']);
306        let Some(name) = it.next() else {
307            continue;
308        };
309        let Some(cat) = it.next() else {
310            continue;
311        };
312        let Some(pal) = it.next() else {
313            continue;
314        };
315        let name = name.trim();
316        let cat = cat.trim();
317        let pal = pal.trim();
318
319        if pal != "None" {
320            if !palette.contains(&pal) {
321                palette.push(pal);
322            }
323        }
324        if name != "Blackout" && name != "Fallback" {
325            if !theme.contains(&name) {
326                theme.push(name);
327            }
328        }
329        theme_init.insert(name, (cat, pal));
330    }
331
332    let d = Def {
333        palette,
334        theme,
335        theme_init,
336    };
337    d
338}
339
340/// All defined color palettes.
341pub fn salsa_palettes() -> Vec<&'static str> {
342    let themes = THEMES.get_or_init(init_themes);
343    themes.palette.clone()
344}
345
346#[derive(Debug)]
347pub struct LoadPaletteErr(u8);
348
349impl Display for LoadPaletteErr {
350    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
351        write!(f, "load palette failed: {}", self.0)
352    }
353}
354
355impl Error for LoadPaletteErr {}
356
357/// Load a .pal file as a Palette.
358pub fn load_palette(mut r: impl std::io::Read) -> Result<palette::Palette, std::io::Error> {
359    let mut buf = String::new();
360    r.read_to_string(&mut buf)?;
361
362    enum S {
363        Start,
364        Recognize,
365        Color,
366        Reference,
367        Fail(u8),
368    }
369
370    let mut pal = palette::Palette::default();
371    let mut dark = 63u8;
372
373    let mut state = S::Start;
374    'm: for l in buf.lines() {
375        let l = l.trim();
376        match state {
377            S::Start => {
378                if l.trim() == "[palette]" {
379                    state = S::Recognize;
380                } else {
381                    state = S::Fail(1);
382                    break 'm;
383                }
384            }
385            S::Recognize => {
386                if l == "[color]" {
387                    state = S::Color;
388                } else if l.is_empty() || l.starts_with("#") {
389                    // ok
390                } else if l.starts_with("name=") {
391                    if let Some(name_str) = l.split('=').nth(1) {
392                        pal.name = Cow::Owned(name_str.to_string());
393                    }
394                } else if l.starts_with("docs=") {
395                    // ok
396                } else if l.starts_with("dark") {
397                    if let Some(dark_str) = l.split('=').nth(1) {
398                        if let Ok(v) = dark_str.parse::<u8>() {
399                            dark = v;
400                        } else {
401                            // skip
402                        }
403                    }
404                } else {
405                    state = S::Fail(2);
406                    break 'm;
407                }
408            }
409            S::Color => {
410                if l == "[reference]" {
411                    state = S::Reference;
412                } else if l.is_empty() || l.starts_with("#") {
413                    // ok
414                } else {
415                    let mut kv = l.split('=');
416                    let cn = if let Some(v) = kv.next() {
417                        let Ok(c) = v.trim().parse::<palette::Colors>() else {
418                            state = S::Fail(3);
419                            break 'm;
420                        };
421                        c
422                    } else {
423                        state = S::Fail(4);
424                        break 'm;
425                    };
426                    let (c0, c3) = if let Some(v) = kv.next() {
427                        let mut vv = v.split(',');
428                        let c0 = if let Some(v) = vv.next() {
429                            let Ok(v) = v.trim().parse::<Color>() else {
430                                state = S::Fail(5);
431                                break 'm;
432                            };
433                            v
434                        } else {
435                            state = S::Fail(6);
436                            break 'm;
437                        };
438                        let c3 = if let Some(v) = vv.next() {
439                            let Ok(v) = v.trim().parse::<Color>() else {
440                                state = S::Fail(7);
441                                break 'm;
442                            };
443                            v
444                        } else {
445                            state = S::Fail(8);
446                            break 'm;
447                        };
448                        (c0, c3)
449                    } else {
450                        state = S::Fail(9);
451                        break 'm;
452                    };
453
454                    if cn == palette::Colors::TextLight || cn == palette::Colors::TextDark {
455                        pal.color[cn as usize] = palette::Palette::interpolatec2(
456                            c0,
457                            c3,
458                            Color::default(),
459                            Color::default(),
460                        )
461                    } else {
462                        pal.color[cn as usize] = palette::Palette::interpolatec(c0, c3, dark);
463                    }
464                }
465            }
466            S::Reference => {
467                let mut kv = l.split('=');
468                let rn = if let Some(v) = kv.next() {
469                    v
470                } else {
471                    state = S::Fail(9);
472                    break 'm;
473                };
474                let ci = if let Some(v) = kv.next() {
475                    if let Ok(ci) = v.parse::<palette::ColorIdx>() {
476                        ci
477                    } else {
478                        state = S::Fail(10);
479                        break 'm;
480                    }
481                } else {
482                    state = S::Fail(11);
483                    break 'm;
484                };
485                pal.add_aliased(rn, ci);
486            }
487            S::Fail(_) => {
488                unreachable!()
489            }
490        }
491    }
492
493    match state {
494        S::Fail(n) => Err(io::Error::new(ErrorKind::Other, LoadPaletteErr(n))),
495        S::Start => Err(io::Error::new(ErrorKind::Other, LoadPaletteErr(100))),
496        S::Recognize => Err(io::Error::new(ErrorKind::Other, LoadPaletteErr(101))),
497        S::Color | S::Reference => Ok(pal),
498    }
499}
500
501/// Create one of the defined palettes.
502///
503/// The available palettes can be queried by [salsa_palettes].
504///
505/// Currently known: Imperial, Radium, Tundra, Ocean, Monochrome,
506/// Black&White, Monekai, Solarized, OxoCarbon, EverForest,
507/// Nord, Rust, Material, Tailwind, VSCode, Reds, Blackout,
508/// Shell, Imperial Light, EverForest Light, Tailwind Light,
509/// Rust Light.
510pub fn create_palette(name: &str) -> Option<palette::Palette> {
511    use crate::palettes::core;
512    use crate::palettes::dark;
513    use crate::palettes::light;
514    match name {
515        "Imperial" => Some(dark::IMPERIAL),
516        "Radium" => Some(dark::RADIUM),
517        "Tundra" => Some(dark::TUNDRA),
518        "Ocean" => Some(dark::OCEAN),
519        "Monochrome" => Some(dark::MONOCHROME),
520        "Black&White" => Some(dark::BLACK_WHITE),
521        "Monekai" => Some(dark::MONEKAI),
522        "Solarized" => Some(dark::SOLARIZED),
523        "OxoCarbon" => Some(dark::OXOCARBON),
524        "EverForest" => Some(dark::EVERFOREST),
525        "Nord" => Some(dark::NORD),
526        "Rust" => Some(dark::RUST),
527        "Material" => Some(dark::MATERIAL),
528        "Tailwind" => Some(dark::TAILWIND),
529        "VSCode" => Some(dark::VSCODE),
530
531        "Reds" => Some(dark::REDS),
532        "Blackout" => Some(dark::BLACKOUT),
533        "Shell" => Some(core::SHELL),
534
535        "Imperial Light" => Some(light::IMPERIAL_LIGHT),
536        "EverForest Light" => Some(light::EVERFOREST_LIGHT),
537        "Tailwind Light" => Some(light::TAILWIND_LIGHT),
538        "Rust Light" => Some(light::RUST_LIGHT),
539        "SunriseBreeze Light" => Some(light::SUNRISEBREEZE_LIGHT),
540        _ => None,
541    }
542}
543
544/// All defined rat-salsa themes.
545pub fn salsa_themes() -> Vec<&'static str> {
546    let themes = THEMES.get_or_init(init_themes);
547    themes.theme.clone()
548}
549
550/// Create one of the defined themes.
551///
552/// The available themes can be queried by [salsa_themes].
553///
554/// Currently known: Imperial Dark, Radium Dark, Tundra Dark,
555/// Ocean Dark, Monochrome Dark, Black&White Dark, Monekai Dark,
556/// Solarized Dark, OxoCarbon Dark, EverForest Dark, Nord Dark,
557/// Rust Dark, Material Dark, Tailwind Dark, VSCode Dark,
558/// Imperial Light, EverForest Light, Tailwind Light, Rust Light,
559/// Imperial Shell, Radium Shell, Tundra Shell, Ocean Shell,
560/// Monochrome Shell, Black&White Shell, Monekai Shell,
561/// Solarized Shell, OxoCarbon Shell, EverForest Shell, Nord Shell,
562/// Rust Shell, Material Shell, Tailwind Shell, VSCode Shell,
563/// Shell, Blackout and Fallback.
564pub fn create_theme(theme: &str) -> theme::SalsaTheme {
565    let themes = THEMES.get_or_init(init_themes);
566    let Some(def) = themes.theme_init.get(&theme) else {
567        if cfg!(debug_assertions) {
568            panic!("no theme {:?}", theme);
569        } else {
570            return themes::create_core(theme);
571        }
572    };
573    match def {
574        ("dark", p) => {
575            let Some(pal) = create_palette(*p) else {
576                if cfg!(debug_assertions) {
577                    panic!("no palette {:?}", *p);
578                } else {
579                    return themes::create_core(theme);
580                }
581            };
582            themes::create_dark(theme, pal)
583        }
584        ("light", p) => {
585            let Some(pal) = create_palette(*p) else {
586                if cfg!(debug_assertions) {
587                    panic!("no palette {:?}", *p);
588                } else {
589                    return themes::create_core(theme);
590                }
591            };
592            // currently no difference, just a different
593            // set of color palettes
594            let mut theme = themes::create_dark(theme, pal);
595            theme.cat = Category::Light;
596            theme
597        }
598        ("shell", p) => {
599            let Some(pal) = create_palette(*p) else {
600                if cfg!(debug_assertions) {
601                    panic!("no palette {:?}", *p);
602                } else {
603                    return themes::create_core(theme);
604                }
605            };
606            themes::create_shell(theme, pal)
607        }
608        ("core", _) => themes::create_core(theme),
609        ("blackout", _) => themes::create_dark(theme, palettes::dark::BLACKOUT),
610        ("fallback", _) => themes::create_fallback(theme, palettes::dark::REDS),
611        _ => {
612            if cfg!(debug_assertions) {
613                panic!("no theme {:?}", theme);
614            } else {
615                themes::create_core(theme)
616            }
617        }
618    }
619}