Skip to main content

basalt_tui/
statusbar.rs

1use ratatui::{
2    buffer::Buffer,
3    layout::{Constraint, Flex, Layout, Rect},
4    style::{Color, Style, Stylize},
5    text::{Line, Span, Text},
6    widgets::{Block, StatefulWidget, Widget},
7};
8
9use crate::{config::Theme, note_editor::state::Mode};
10
11/// Perceived brightness of an RGB colour (0..255). `None` for terminal-default
12/// or ANSI colours, whose real value the app can't know.
13fn luminance(color: Color) -> Option<f32> {
14    match color {
15        Color::Rgb(r, g, b) => Some(0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32),
16        _ => None,
17    }
18}
19
20/// The more legible of the theme's background / text over a filled `fill`, so a
21/// mode block stays readable whatever colour the mode uses. Falls back to dark
22/// text for ANSI/terminal-default themes, whose mode fills are light by default.
23fn legible_over(fill: Color, theme: &Theme) -> Color {
24    match (
25        luminance(fill),
26        luminance(theme.background),
27        luminance(theme.text),
28    ) {
29        (Some(fill), Some(background), Some(text)) => {
30            if (fill - text).abs() > (fill - background).abs() {
31                theme.text
32            } else {
33                theme.background
34            }
35        }
36        _ => Color::Black,
37    }
38}
39
40#[derive(Default, Clone, PartialEq)]
41pub struct StatusBarState<'a> {
42    active_component_name: &'a str,
43    mode: Mode,
44    word_count: usize,
45    char_count: usize,
46}
47
48impl<'a> StatusBarState<'a> {
49    pub fn new(
50        active_component_name: &'a str,
51        mode: Mode,
52        word_count: usize,
53        char_count: usize,
54    ) -> Self {
55        Self {
56            active_component_name,
57            mode,
58            word_count,
59            char_count,
60        }
61    }
62}
63
64pub struct StatusBar<'a> {
65    theme: &'a Theme,
66}
67
68impl<'a> StatusBar<'a> {
69    pub(crate) fn new(theme: &'a Theme) -> Self {
70        Self { theme }
71    }
72}
73
74impl<'a> StatefulWidget for StatusBar<'a> {
75    type State = StatusBarState<'a>;
76
77    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
78        let bar = self.theme.status_bar;
79        Block::new()
80            .style(Style::new().bg(bar.background))
81            .render(area, buf);
82
83        let [left, right] = Layout::horizontal([Constraint::Fill(1), Constraint::Length(28)])
84            .flex(Flex::SpaceBetween)
85            .areas(area);
86
87        // A rectangular mode block, then the active pane name.
88        let mode = state.mode;
89        let mode_color = mode.color(self.theme);
90        let status = Line::from(vec![
91            Span::from(format!(" {} ", mode.label()))
92                .fg(legible_over(mode_color, self.theme))
93                .bg(mode_color)
94                .bold(),
95            Span::from(format!("  {}", state.active_component_name))
96                .fg(bar.foreground)
97                .bold(),
98        ]);
99        Text::from(status).render(left, buf);
100
101        let [word_count, char_count] =
102            Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)])
103                .flex(Flex::End)
104                .areas(right);
105
106        Text::from(
107            format!(
108                "{} word{}",
109                state.word_count,
110                if state.word_count == 1 { "" } else { "s" }
111            )
112            .fg(bar.foreground),
113        )
114        .right_aligned()
115        .render(word_count, buf);
116
117        Text::from(
118            format!(
119                "{} char{}",
120                state.char_count,
121                if state.char_count == 1 { "" } else { "s" }
122            )
123            .fg(bar.foreground),
124        )
125        .right_aligned()
126        .render(char_count, buf);
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn legible_over_picks_the_contrasting_neutral() {
136        let theme = Theme {
137            background: Color::Rgb(20, 20, 20),
138            text: Color::Rgb(230, 230, 230),
139            ..Theme::default()
140        };
141        // Light fill -> dark background text; dark fill -> light text.
142        assert_eq!(
143            legible_over(Color::Rgb(220, 210, 120), &theme),
144            theme.background
145        );
146        assert_eq!(legible_over(Color::Rgb(60, 40, 40), &theme), theme.text);
147    }
148
149    #[test]
150    fn legible_over_falls_back_to_dark_for_ansi_colours() {
151        // Terminal-default themes have no known RGB; their mode fills are light,
152        // so dark text stays legible.
153        assert_eq!(legible_over(Color::Red, &Theme::default()), Color::Black);
154    }
155}