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    pub const COLOR_INPUT: &'static str = "color-input";
134    pub const COMBOBOX: &'static str = "combobox";
135    pub const DIALOG_FRAME: &'static str = "dialog-frame";
136    pub const FILE_DIALOG: &'static str = "file-dialog";
137    pub const FORM: &'static str = "form";
138    pub const LINE_NR: &'static str = "line-nr";
139    pub const LIST: &'static str = "list";
140    pub const MENU: &'static str = "menu";
141    pub const MONTH: &'static str = "month";
142    pub const MSG_DIALOG: &'static str = "msg-dialog";
143    pub const PARAGRAPH: &'static str = "paragraph";
144    pub const RADIO: &'static str = "radio";
145    pub const SCROLL: &'static str = "scroll";
146    pub const SCROLL_DIALOG: &'static str = "scroll.dialog";
147    pub const SCROLL_POPUP: &'static str = "scroll.popup";
148    pub const SHADOW: &'static str = "shadow";
149    pub const SLIDER: &'static str = "slider";
150    pub const SPLIT: &'static str = "split";
151    pub const STATUSLINE: &'static str = "statusline";
152    pub const TABBED: &'static str = "tabbed";
153    pub const TABLE: &'static str = "table";
154    pub const TEXT: &'static str = "text";
155    pub const TEXTAREA: &'static str = "textarea";
156    pub const TEXTVIEW: &'static str = "textview";
157    pub const VIEW: &'static str = "view";
158}
159
160/// Extension trait for [Style](ratatui::style::Style) that defines
161/// some standard names used by rat-theme/rat-widget
162///
163/// Use as
164/// ```rust
165/// # use ratatui::style::Style;
166/// # use rat_theme4::theme::{SalsaTheme};
167/// # use rat_theme4::{ StyleName, WidgetStyle};
168/// # use rat_theme4::palettes::dark::BLACKOUT;
169/// # let theme = SalsaTheme::default();
170///
171/// let s: Style = theme.style(Style::INPUT);
172/// ```
173pub trait StyleName {
174    const LABEL_FG: &'static str = "label-fg";
175    const INPUT: &'static str = "input";
176    const INPUT_FOCUS: &'static str = "text-focus";
177    const INPUT_SELECT: &'static str = "text-select";
178    const FOCUS: &'static str = "focus";
179    const SELECT: &'static str = "select";
180    const DISABLED: &'static str = "disabled";
181    const INVALID: &'static str = "invalid";
182
183    const TITLE: &'static str = "title";
184    const HEADER: &'static str = "header";
185    const FOOTER: &'static str = "footer";
186
187    const HOVER: &'static str = "hover";
188    const SHADOWS: &'static str = "shadows";
189
190    const WEEK_HEADER_FG: &'static str = "week-header-fg";
191    const MONTH_HEADER_FG: &'static str = "month-header-fg";
192
193    const KEY_BINDING: &'static str = "key-binding";
194    const BUTTON_BASE: &'static str = "button-base";
195    const MENU_BASE: &'static str = "menu-base";
196    const STATUS_BASE: &'static str = "status-base";
197
198    const CONTAINER_BASE: &'static str = "container-base";
199    const CONTAINER_BORDER_FG: &'static str = "container-border-fg";
200    const CONTAINER_ARROW_FG: &'static str = "container-arrows-fg";
201
202    const DOCUMENT_BASE: &'static str = "document-base";
203    const DOCUMENT_BORDER_FG: &'static str = "document-border-fg";
204    const DOCUMENT_ARROW_FG: &'static str = "document-arrows-fg";
205
206    const POPUP_BASE: &'static str = "popup-base";
207    const POPUP_BORDER_FG: &'static str = "popup-border-fg";
208    const POPUP_ARROW_FG: &'static str = "popup-arrow-fg";
209
210    const DIALOG_BASE: &'static str = "dialog-base";
211    const DIALOG_BORDER_FG: &'static str = "dialog-border-fg";
212    const DIALOG_ARROW_FG: &'static str = "dialog-arrow-fg";
213}
214impl StyleName for Style {}
215
216///
217/// Extension trait for [Color](ratatui::style::Color) that defines
218/// standard names used by rat-theme to define color-aliases.
219///
220/// Use as
221/// ```rust
222/// # use ratatui::style::{Style, Color};
223/// # use rat_theme4::theme::{SalsaTheme};
224/// # use rat_theme4::RatWidgetColor;
225/// # let theme = SalsaTheme::default();
226///
227/// let c: Color = theme.p.color_alias(Color::LABEL_FG);
228/// ```
229pub trait RatWidgetColor {
230    const LABEL_FG: &'static str = "label.fg";
231    const INPUT_BG: &'static str = "input.bg";
232    const INPUT_FOCUS_BG: &'static str = "input-focus.bg";
233    const INPUT_SELECT_BG: &'static str = "input-select.bg";
234    const FOCUS_BG: &'static str = "focus.bg";
235    const SELECT_BG: &'static str = "select.bg";
236    const DISABLED_BG: &'static str = "disabled.bg";
237    const INVALID_BG: &'static str = "invalid.bg";
238
239    const TITLE_FG: &'static str = "title.fg";
240    const TITLE_BG: &'static str = "title.bg";
241    const HEADER_FG: &'static str = "header.fg";
242    const HEADER_BG: &'static str = "header.bg";
243    const FOOTER_FG: &'static str = "footer.fg";
244    const FOOTER_BG: &'static str = "footer.bg";
245
246    const HOVER_BG: &'static str = "hover.bg";
247    const BUTTON_BASE_BG: &'static str = "button-base.bg";
248    const KEY_BINDING_BG: &'static str = "key-binding.bg";
249    const MENU_BASE_BG: &'static str = "menu-base.bg";
250    const STATUS_BASE_BG: &'static str = "status-base.bg";
251    const SHADOW_BG: &'static str = "shadow.bg";
252
253    const WEEK_HEADER_FG: &'static str = "week-header.fg";
254    const MONTH_HEADER_FG: &'static str = "month-header.fg";
255
256    const CONTAINER_BASE_BG: &'static str = "container-base.bg";
257    const CONTAINER_BORDER_FG: &'static str = "container-border.fg";
258    const CONTAINER_ARROW_FG: &'static str = "container-arrow.fg";
259    const DOCUMENT_BASE_BG: &'static str = "document-base.bg";
260    const DOCUMENT_BORDER_FG: &'static str = "document-border.fg";
261    const DOCUMENT_ARROW_FG: &'static str = "document-arrow.fg";
262    const POPUP_BASE_BG: &'static str = "popup-base.bg";
263    const POPUP_BORDER_FG: &'static str = "popup-border.fg";
264    const POPUP_ARROW_FG: &'static str = "popup-arrow.fg";
265    const DIALOG_BASE_BG: &'static str = "dialog-base.bg";
266    const DIALOG_BORDER_FG: &'static str = "dialog-border.fg";
267    const DIALOG_ARROW_FG: &'static str = "dialog-arrow.fg";
268}
269impl RatWidgetColor for Color {}
270
271static LOG_DEFINES: AtomicBool = AtomicBool::new(false);
272
273/// Log style definition.
274/// May help debugging styling problems ...
275pub fn log_style_define(log: bool) {
276    LOG_DEFINES.store(log, Ordering::Release);
277}
278
279fn is_log_style_define() -> bool {
280    LOG_DEFINES.load(Ordering::Acquire)
281}
282
283const PALETTE_DEF: &str = include_str!("themes.ini");
284
285#[derive(Debug)]
286struct Def {
287    palette: Vec<&'static str>,
288    theme: Vec<&'static str>,
289    theme_init: HashMap<&'static str, (&'static str, &'static str)>,
290}
291
292static THEMES: OnceLock<Def> = OnceLock::new();
293
294fn init_themes() -> Def {
295    let mut palette = Vec::new();
296    let mut theme = Vec::new();
297    let mut theme_init = HashMap::new();
298
299    for l in PALETTE_DEF.lines() {
300        if !l.contains('=') {
301            continue;
302        }
303
304        let mut it = l.split(['=', ',']);
305        let Some(name) = it.next() else {
306            continue;
307        };
308        let Some(cat) = it.next() else {
309            continue;
310        };
311        let Some(pal) = it.next() else {
312            continue;
313        };
314        let name = name.trim();
315        let cat = cat.trim();
316        let pal = pal.trim();
317
318        if pal != "None" {
319            if !palette.contains(&pal) {
320                palette.push(pal);
321            }
322        }
323        if name != "Blackout" && name != "Fallback" {
324            if !theme.contains(&name) {
325                theme.push(name);
326            }
327        }
328        theme_init.insert(name, (cat, pal));
329    }
330
331    let d = Def {
332        palette,
333        theme,
334        theme_init,
335    };
336    d
337}
338
339/// All defined color palettes.
340pub fn salsa_palettes() -> Vec<&'static str> {
341    let themes = THEMES.get_or_init(init_themes);
342    themes.palette.clone()
343}
344
345#[derive(Debug)]
346pub struct LoadPaletteErr(u8);
347
348impl Display for LoadPaletteErr {
349    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
350        write!(f, "load palette failed: {}", self.0)
351    }
352}
353
354impl Error for LoadPaletteErr {}
355
356/// Load a .pal file as a Palette.
357pub fn load_palette(mut r: impl std::io::Read) -> Result<palette::Palette, std::io::Error> {
358    let mut buf = String::new();
359    r.read_to_string(&mut buf)?;
360
361    enum S {
362        Start,
363        Recognize,
364        Color,
365        Reference,
366        Fail(u8),
367    }
368
369    let mut pal = palette::Palette::default();
370    let mut dark = 63u8;
371
372    let mut state = S::Start;
373    'm: for l in buf.lines() {
374        let l = l.trim();
375        match state {
376            S::Start => {
377                if l.trim() == "[palette]" {
378                    state = S::Recognize;
379                } else {
380                    state = S::Fail(1);
381                    break 'm;
382                }
383            }
384            S::Recognize => {
385                if l == "[color]" {
386                    state = S::Color;
387                } else if l.is_empty() || l.starts_with("#") {
388                    // ok
389                } else if l.starts_with("name=") {
390                    if let Some(name_str) = l.split('=').nth(1) {
391                        pal.name = Cow::Owned(name_str.to_string());
392                    }
393                } else if l.starts_with("docs=") {
394                    // ok
395                } else if l.starts_with("dark") {
396                    if let Some(dark_str) = l.split('=').nth(1) {
397                        if let Ok(v) = dark_str.parse::<u8>() {
398                            dark = v;
399                        } else {
400                            // skip
401                        }
402                    }
403                } else {
404                    state = S::Fail(2);
405                    break 'm;
406                }
407            }
408            S::Color => {
409                if l == "[reference]" {
410                    state = S::Reference;
411                } else if l.is_empty() || l.starts_with("#") {
412                    // ok
413                } else {
414                    let mut kvv = l.split(['=', ',']);
415                    let cn = if let Some(v) = kvv.next() {
416                        let Ok(c) = v.trim().parse::<palette::Colors>() else {
417                            state = S::Fail(3);
418                            break 'm;
419                        };
420                        c
421                    } else {
422                        state = S::Fail(4);
423                        break 'm;
424                    };
425                    let c0 = if let Some(v) = kvv.next() {
426                        let Ok(v) = v.trim().parse::<Color>() else {
427                            state = S::Fail(5);
428                            break 'm;
429                        };
430                        v
431                    } else {
432                        state = S::Fail(6);
433                        break 'm;
434                    };
435                    let c3 = if let Some(v) = kvv.next() {
436                        let Ok(v) = v.trim().parse::<Color>() else {
437                            state = S::Fail(7);
438                            break 'm;
439                        };
440                        v
441                    } else {
442                        state = S::Fail(8);
443                        break 'm;
444                    };
445                    if cn == palette::Colors::TextLight || cn == palette::Colors::TextDark {
446                        pal.color[cn as usize] = palette::Palette::interpolatec2(
447                            c0,
448                            c3,
449                            Color::default(),
450                            Color::default(),
451                        )
452                    } else {
453                        pal.color[cn as usize] = palette::Palette::interpolatec(c0, c3, dark);
454                    }
455                }
456            }
457            S::Reference => {
458                let mut kv = l.split('=');
459                let rn = if let Some(v) = kv.next() {
460                    v
461                } else {
462                    state = S::Fail(9);
463                    break 'm;
464                };
465                let ci = if let Some(v) = kv.next() {
466                    if let Ok(ci) = v.parse::<palette::ColorIdx>() {
467                        ci
468                    } else {
469                        state = S::Fail(10);
470                        break 'm;
471                    }
472                } else {
473                    state = S::Fail(11);
474                    break 'm;
475                };
476                pal.add_aliased(rn, ci);
477            }
478            S::Fail(_) => {
479                unreachable!()
480            }
481        }
482    }
483
484    match state {
485        S::Fail(n) => Err(io::Error::new(ErrorKind::Other, LoadPaletteErr(n))),
486        S::Start => Err(io::Error::new(ErrorKind::Other, LoadPaletteErr(100))),
487        S::Recognize => Err(io::Error::new(ErrorKind::Other, LoadPaletteErr(101))),
488        S::Color | S::Reference => Ok(pal),
489    }
490}
491
492/// Create one of the defined palettes.
493///
494/// The available palettes can be queried by [salsa_palettes].
495///
496/// Currently known: Imperial, Radium, Tundra, Ocean, Monochrome,
497/// Black&White, Monekai, Solarized, OxoCarbon, EverForest,
498/// Nord, Rust, Material, Tailwind, VSCode, Reds, Blackout,
499/// Shell, Imperial Light, EverForest Light, Tailwind Light,
500/// Rust Light.
501pub fn create_palette(name: &str) -> Option<palette::Palette> {
502    use crate::palettes::core;
503    use crate::palettes::dark;
504    use crate::palettes::light;
505    match name {
506        "Imperial" => Some(dark::IMPERIAL),
507        "Radium" => Some(dark::RADIUM),
508        "Tundra" => Some(dark::TUNDRA),
509        "Ocean" => Some(dark::OCEAN),
510        "Monochrome" => Some(dark::MONOCHROME),
511        "Black&White" => Some(dark::BLACK_WHITE),
512        "Monekai" => Some(dark::MONEKAI),
513        "Solarized" => Some(dark::SOLARIZED),
514        "OxoCarbon" => Some(dark::OXOCARBON),
515        "EverForest" => Some(dark::EVERFOREST),
516        "Nord" => Some(dark::NORD),
517        "Rust" => Some(dark::RUST),
518        "Material" => Some(dark::MATERIAL),
519        "Tailwind" => Some(dark::TAILWIND),
520        "VSCode" => Some(dark::VSCODE),
521
522        "Reds" => Some(dark::REDS),
523        "Blackout" => Some(dark::BLACKOUT),
524        "Shell" => Some(core::SHELL),
525
526        "Imperial Light" => Some(light::IMPERIAL_LIGHT),
527        "EverForest Light" => Some(light::EVERFOREST_LIGHT),
528        "Tailwind Light" => Some(light::TAILWIND_LIGHT),
529        "Rust Light" => Some(light::RUST_LIGHT),
530        "SunriseBreeze Light" => Some(light::SUNRISEBREEZE_LIGHT),
531        _ => None,
532    }
533}
534
535/// All defined rat-salsa themes.
536pub fn salsa_themes() -> Vec<&'static str> {
537    let themes = THEMES.get_or_init(init_themes);
538    themes.theme.clone()
539}
540
541/// Create one of the defined themes.
542///
543/// The available themes can be queried by [salsa_themes].
544///
545/// Currently known: Imperial Dark, Radium Dark, Tundra Dark,
546/// Ocean Dark, Monochrome Dark, Black&White Dark, Monekai Dark,
547/// Solarized Dark, OxoCarbon Dark, EverForest Dark, Nord Dark,
548/// Rust Dark, Material Dark, Tailwind Dark, VSCode Dark,
549/// Imperial Light, EverForest Light, Tailwind Light, Rust Light,
550/// Imperial Shell, Radium Shell, Tundra Shell, Ocean Shell,
551/// Monochrome Shell, Black&White Shell, Monekai Shell,
552/// Solarized Shell, OxoCarbon Shell, EverForest Shell, Nord Shell,
553/// Rust Shell, Material Shell, Tailwind Shell, VSCode Shell,
554/// Shell, Blackout and Fallback.
555pub fn create_theme(theme: &str) -> theme::SalsaTheme {
556    let themes = THEMES.get_or_init(init_themes);
557    let Some(def) = themes.theme_init.get(&theme) else {
558        if cfg!(debug_assertions) {
559            panic!("no theme {:?}", theme);
560        } else {
561            return themes::create_core(theme);
562        }
563    };
564    match def {
565        ("dark", p) => {
566            let Some(pal) = create_palette(*p) else {
567                if cfg!(debug_assertions) {
568                    panic!("no palette {:?}", *p);
569                } else {
570                    return themes::create_core(theme);
571                }
572            };
573            themes::create_dark(theme, pal)
574        }
575        ("light", p) => {
576            let Some(pal) = create_palette(*p) else {
577                if cfg!(debug_assertions) {
578                    panic!("no palette {:?}", *p);
579                } else {
580                    return themes::create_core(theme);
581                }
582            };
583            // currently no difference, just a different
584            // set of color palettes
585            let mut theme = themes::create_dark(theme, pal);
586            theme.cat = Category::Light;
587            theme
588        }
589        ("shell", p) => {
590            let Some(pal) = create_palette(*p) else {
591                if cfg!(debug_assertions) {
592                    panic!("no palette {:?}", *p);
593                } else {
594                    return themes::create_core(theme);
595                }
596            };
597            themes::create_shell(theme, pal)
598        }
599        ("core", _) => themes::create_core(theme),
600        ("blackout", _) => themes::create_dark(theme, palettes::dark::BLACKOUT),
601        ("fallback", _) => themes::create_fallback(theme, palettes::dark::REDS),
602        _ => {
603            if cfg!(debug_assertions) {
604                panic!("no theme {:?}", theme);
605            } else {
606                themes::create_core(theme)
607            }
608        }
609    }
610}