Skip to main content

kimun_notes/components/
footer_bar.rs

1//! The two-line **status bar** pinned to the bottom of the editor screen.
2//!
3//! Line 1 — context + actions: a focus-context indicator (`⌨ EDITOR` when a
4//! text field holds the cursor, `≣ LIST` when a list/panel is focused)
5//! followed by the focused surface's key hints, with the global hints
6//! right-aligned. There is no editing "mode"; focus is the only state
7//! (spec §7).
8//!
9//! Line 2 — document state: path · ln/col · modified/saved · backlink count
10//! · git status · (in query contexts) match count.
11
12use std::time::{Duration, Instant};
13
14use ratatui::Frame;
15use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
16use ratatui::style::{Modifier, Style};
17use ratatui::text::{Line, Span};
18use ratatui::widgets::Paragraph;
19use unicode_width::UnicodeWidthStr;
20
21use crate::components::events::{AppEvent, AppTx};
22use crate::components::hints::Hint;
23use crate::settings::themes::Theme;
24
25const FLASH_DURATION: Duration = Duration::from_secs(2);
26
27/// Rows the status bar occupies.
28pub const STATUS_BAR_HEIGHT: u16 = 2;
29
30/// Document state shown on line 2. `None` fields render nothing — each
31/// segment appears only when it has a value.
32#[derive(Default)]
33pub struct DocState<'a> {
34    pub path: &'a str,
35    pub dirty: bool,
36    /// 1-based cursor line/column, when a text buffer holds the cursor.
37    pub ln_col: Option<(usize, usize)>,
38    /// Backlink count of the open note (async-loaded).
39    pub backlinks: Option<usize>,
40    /// Workspace git status summary, e.g. `git ✓` / `git ●3`.
41    pub git: Option<String>,
42    /// Result count when a query context is focused.
43    pub matches: Option<usize>,
44    /// Link-under-cursor affordance: `→ target · N backlinks` (spec §5.2).
45    pub link: Option<String>,
46    /// Newer release available, e.g. `⬆ 0.18.0` — opens the update dialog.
47    pub update: Option<String>,
48}
49
50/// Everything the status bar shows for the current frame.
51pub struct StatusContext<'a> {
52    /// Label of the focused surface (panel or overlay), e.g. `EDITOR`.
53    pub focus_label: &'a str,
54    /// True when a text field holds the cursor (`⌨`); false for lists (`≣`).
55    pub editing: bool,
56    /// Key hints for the focused surface.
57    pub hints: &'a [Hint],
58    /// Always-on hints, right-aligned (from `hints::global_hints`).
59    pub global_hints: &'a [Hint],
60    /// Document state for line 2.
61    pub doc: DocState<'a>,
62}
63
64pub struct FooterBar {
65    key_flash: Option<(String, Instant)>,
66}
67
68impl FooterBar {
69    pub fn new() -> Self {
70        Self { key_flash: None }
71    }
72
73    /// Show a key-flash message for 2 seconds. Schedules a delayed redraw so
74    /// the message disappears even when no user input arrives in the meantime.
75    pub fn flash(&mut self, text: String, tx: &AppTx) {
76        self.key_flash = Some((text, Instant::now()));
77        let tx2 = tx.clone();
78        tokio::spawn(async move {
79            tokio::time::sleep(FLASH_DURATION).await;
80            let _ = tx2.send(AppEvent::Redraw);
81        });
82    }
83
84    pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, ctx: &StatusContext) {
85        let StatusContext {
86            focus_label,
87            editing,
88            hints,
89            global_hints,
90            doc,
91        } = ctx;
92
93        // Expire stale key flash
94        if let Some((_, instant)) = &self.key_flash
95            && instant.elapsed() >= FLASH_DURATION
96        {
97            self.key_flash = None;
98        }
99
100        let rows = Layout::default()
101            .direction(Direction::Vertical)
102            .constraints([Constraint::Length(1), Constraint::Length(1)])
103            .split(rect);
104
105        let secondary = Style::default().fg(theme.fg_secondary.to_ratatui());
106        let muted = Style::default().fg(theme.gray.to_ratatui());
107        let keycap = Style::default().fg(theme.yellow.to_ratatui());
108
109        // ── Line 1: focus context + hints (or the key flash) ────────────────
110        if let Some((flash, _)) = &self.key_flash {
111            f.render_widget(
112                Paragraph::new(Line::from(Span::styled(
113                    flash.as_str(),
114                    Style::default()
115                        .fg(theme.accent.to_ratatui())
116                        .add_modifier(Modifier::BOLD),
117                )))
118                .alignment(Alignment::Center),
119                rows[0],
120            );
121        } else {
122            // Right-aligned global hints first, so the left side knows how
123            // much width remains.
124            let mut right_spans: Vec<Span> = Vec::new();
125            for (i, (key, label)) in global_hints.iter().enumerate() {
126                if i > 0 {
127                    right_spans.push(Span::styled("  ", secondary));
128                }
129                right_spans.push(Span::styled(format!("{key} "), keycap));
130                right_spans.push(Span::styled(label.clone(), secondary));
131            }
132            let mut right_width: u16 = right_spans.iter().map(|s| s.content.width() as u16).sum();
133            // Context hints outrank global hints: on a narrow terminal the
134            // globals drop entirely rather than squeezing out the focus
135            // indicator and the surface's own hints.
136            const MIN_CONTEXT_WIDTH: u16 = 30;
137            if right_width + 1 + MIN_CONTEXT_WIDTH > rows[0].width {
138                right_spans.clear();
139                right_width = 0;
140            }
141            let cols = Layout::default()
142                .direction(Direction::Horizontal)
143                .constraints([Constraint::Min(0), Constraint::Length(right_width + 1)])
144                .split(rows[0]);
145
146            let glyph = if *editing { "⌨" } else { "≣" };
147            let mut spans = vec![Span::styled(
148                format!(" {glyph} {focus_label}  "),
149                Style::default()
150                    .fg(theme.fg_bright.to_ratatui())
151                    .add_modifier(Modifier::BOLD),
152            )];
153            let sep = Span::styled("  ", secondary);
154            for (i, (key, label)) in hints.iter().enumerate() {
155                if i > 0 {
156                    spans.push(sep.clone());
157                }
158                if key.is_empty() {
159                    // Mode / command-line label from the nvim backend — make it pop.
160                    spans.push(Span::styled(
161                        format!(" {label} "),
162                        Style::default()
163                            .fg(theme.accent.to_ratatui())
164                            .add_modifier(Modifier::BOLD),
165                    ));
166                } else {
167                    spans.push(Span::styled(format!("{key} "), keycap));
168                    spans.push(Span::styled(label.clone(), secondary));
169                }
170            }
171            f.render_widget(Paragraph::new(Line::from(spans)), cols[0]);
172            f.render_widget(
173                Paragraph::new(Line::from(right_spans)).alignment(Alignment::Right),
174                cols[1],
175            );
176        }
177
178        // ── Line 2: document state, `·`-separated segments ──────────────────
179        // The path yields to the live segments: when the line would overflow,
180        // the path is head-truncated with an ellipsis so ln/col, dirty state,
181        // git, and match count stay visible.
182        let tail_width: usize = {
183            let mut w = 0usize;
184            if let Some((ln, col)) = doc.ln_col {
185                w += format!(" · ln {ln} col {col}").width();
186            }
187            w += if doc.dirty {
188                " · ● modified".width()
189            } else {
190                " · ✓ saved".width()
191            };
192            if let Some(count) = doc.backlinks {
193                w += format!(" · {count} backlinks").width();
194            }
195            if let Some(git) = &doc.git {
196                w += " · ".width() + git.width();
197            }
198            if let Some(matches) = doc.matches {
199                w += format!(" · {matches} matches").width();
200            }
201            if let Some(update) = &doc.update {
202                w += " · ".width() + update.width();
203            }
204            w
205        };
206        let path_budget = (rect.width as usize).saturating_sub(tail_width + 1);
207        let path_display = if doc.path.width() > path_budget {
208            let keep: String = doc
209                .path
210                .chars()
211                .rev()
212                .scan(0usize, |acc, c| {
213                    *acc += unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
214                    (*acc < path_budget).then_some(c)
215                })
216                .collect::<Vec<_>>()
217                .into_iter()
218                .rev()
219                .collect();
220            format!("…{keep}")
221        } else {
222            doc.path.to_string()
223        };
224        let mut segments: Vec<Span> = vec![Span::styled(format!(" {path_display}"), muted)];
225        let push = |segments: &mut Vec<Span>, span: Span<'static>| {
226            segments.push(Span::styled(" · ", muted));
227            segments.push(span);
228        };
229        if let Some((ln, col)) = doc.ln_col {
230            push(
231                &mut segments,
232                Span::styled(format!("ln {ln} col {col}"), muted),
233            );
234        }
235        let state_span = if doc.dirty {
236            Span::styled("● modified", Style::default().fg(theme.yellow.to_ratatui()))
237        } else {
238            Span::styled("✓ saved", Style::default().fg(theme.green.to_ratatui()))
239        };
240        push(&mut segments, state_span);
241        if let Some(count) = doc.backlinks {
242            push(
243                &mut segments,
244                Span::styled(format!("{count} backlinks"), muted),
245            );
246        }
247        if let Some(git) = &doc.git {
248            push(&mut segments, Span::styled(git.clone(), muted));
249        }
250        if let Some(matches) = doc.matches {
251            push(
252                &mut segments,
253                Span::styled(
254                    format!("{matches} matches"),
255                    Style::default().fg(theme.fg_secondary.to_ratatui()),
256                ),
257            );
258        }
259        if let Some(link) = &doc.link {
260            push(
261                &mut segments,
262                Span::styled(link.clone(), Style::default().fg(theme.blue.to_ratatui())),
263            );
264        }
265        if let Some(update) = &doc.update {
266            push(
267                &mut segments,
268                Span::styled(
269                    update.clone(),
270                    Style::default()
271                        .fg(theme.accent.to_ratatui())
272                        .add_modifier(Modifier::BOLD),
273                ),
274            );
275        }
276        f.render_widget(Paragraph::new(Line::from(segments)), rows[1]);
277    }
278}
279
280impl Default for FooterBar {
281    fn default() -> Self {
282        Self::new()
283    }
284}