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_segmentation::UnicodeSegmentation;
20use unicode_width::UnicodeWidthStr;
21
22use crate::components::events::{AppEvent, AppTx};
23use crate::components::hints::Hint;
24use crate::settings::themes::Theme;
25
26const FLASH_DURATION: Duration = Duration::from_secs(2);
27
28/// Rows the status bar occupies.
29pub const STATUS_BAR_HEIGHT: u16 = 2;
30
31/// Document state shown on line 2. `None` fields render nothing — each
32/// segment appears only when it has a value.
33#[derive(Default)]
34pub struct DocState<'a> {
35    pub path: &'a str,
36    pub dirty: bool,
37    /// 1-based cursor line/column, when a text buffer holds the cursor.
38    pub ln_col: Option<(usize, usize)>,
39    /// Backlink count of the open note (async-loaded).
40    pub backlinks: Option<usize>,
41    /// Workspace git status summary, e.g. `git ✓` / `git ●3`.
42    pub git: Option<String>,
43    /// Result count when a query context is focused.
44    pub matches: Option<usize>,
45    /// Link-under-cursor affordance: `→ target · N backlinks` (spec §5.2).
46    pub link: Option<String>,
47    /// Newer release available, e.g. `⬆ 0.18.0` — opens the update dialog.
48    pub update: Option<String>,
49    /// RAG server status, e.g. `rag: online` — absent when no server is set.
50    pub rag: Option<String>,
51}
52
53/// Everything the status bar shows for the current frame.
54pub struct StatusContext<'a> {
55    /// Label of the focused surface (panel or overlay), e.g. `EDITOR`.
56    pub focus_label: &'a str,
57    /// True when a text field holds the cursor (`⌨`); false for lists (`≣`).
58    pub editing: bool,
59    /// Key hints for the focused surface.
60    pub hints: &'a [Hint],
61    /// Always-on hints, right-aligned (from `hints::global_hints`).
62    pub global_hints: &'a [Hint],
63    /// Document state for line 2.
64    pub doc: DocState<'a>,
65}
66
67pub struct FooterBar {
68    key_flash: Option<(String, Instant)>,
69}
70
71impl FooterBar {
72    pub fn new() -> Self {
73        Self { key_flash: None }
74    }
75
76    /// Show a key-flash message for 2 seconds. Schedules a delayed redraw so
77    /// the message disappears even when no user input arrives in the meantime.
78    pub fn flash(&mut self, text: String, tx: &AppTx) {
79        self.key_flash = Some((text, Instant::now()));
80        let tx2 = tx.clone();
81        tokio::spawn(async move {
82            tokio::time::sleep(FLASH_DURATION).await;
83            let _ = tx2.send(AppEvent::Redraw);
84        });
85    }
86
87    pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, ctx: &StatusContext) {
88        let StatusContext {
89            focus_label,
90            editing,
91            hints,
92            global_hints,
93            doc,
94        } = ctx;
95
96        // Expire stale key flash
97        if let Some((_, instant)) = &self.key_flash
98            && instant.elapsed() >= FLASH_DURATION
99        {
100            self.key_flash = None;
101        }
102
103        let rows = Layout::default()
104            .direction(Direction::Vertical)
105            .constraints([Constraint::Length(1), Constraint::Length(1)])
106            .split(rect);
107
108        let secondary = Style::default().fg(theme.fg_secondary.to_ratatui());
109        let muted = Style::default().fg(theme.gray.to_ratatui());
110        let keycap = Style::default().fg(theme.yellow.to_ratatui());
111
112        // ── Line 1: focus context + hints (or the key flash) ────────────────
113        if let Some((flash, _)) = &self.key_flash {
114            f.render_widget(
115                Paragraph::new(Line::from(Span::styled(
116                    flash.as_str(),
117                    Style::default()
118                        .fg(theme.accent.to_ratatui())
119                        .add_modifier(Modifier::BOLD),
120                )))
121                .alignment(Alignment::Center),
122                rows[0],
123            );
124        } else {
125            // Right-aligned global hints first, so the left side knows how
126            // much width remains.
127            let mut right_spans: Vec<Span> = Vec::new();
128            for (i, (key, label)) in global_hints.iter().enumerate() {
129                if i > 0 {
130                    right_spans.push(Span::styled("  ", secondary));
131                }
132                right_spans.push(Span::styled(format!("{key} "), keycap));
133                right_spans.push(Span::styled(label.clone(), secondary));
134            }
135            let mut right_width: u16 = right_spans.iter().map(|s| s.content.width() as u16).sum();
136            // Context hints outrank global hints: on a narrow terminal the
137            // globals drop entirely rather than squeezing out the focus
138            // indicator and the surface's own hints.
139            const MIN_CONTEXT_WIDTH: u16 = 30;
140            if right_width + 1 + MIN_CONTEXT_WIDTH > rows[0].width {
141                right_spans.clear();
142                right_width = 0;
143            }
144            let cols = Layout::default()
145                .direction(Direction::Horizontal)
146                .constraints([Constraint::Min(0), Constraint::Length(right_width + 1)])
147                .split(rows[0]);
148
149            let glyph = if *editing { "⌨" } else { "≣" };
150            let mut spans = vec![Span::styled(
151                format!(" {glyph} {focus_label}  "),
152                Style::default()
153                    .fg(theme.fg_bright.to_ratatui())
154                    .add_modifier(Modifier::BOLD),
155            )];
156            let sep = Span::styled("  ", secondary);
157            for (i, (key, label)) in hints.iter().enumerate() {
158                if i > 0 {
159                    spans.push(sep.clone());
160                }
161                if key.is_empty() {
162                    // Mode / command-line label from the nvim backend — make it pop.
163                    spans.push(Span::styled(
164                        format!(" {label} "),
165                        Style::default()
166                            .fg(theme.accent.to_ratatui())
167                            .add_modifier(Modifier::BOLD),
168                    ));
169                } else {
170                    spans.push(Span::styled(format!("{key} "), keycap));
171                    spans.push(Span::styled(label.clone(), secondary));
172                }
173            }
174            f.render_widget(Paragraph::new(Line::from(spans)), cols[0]);
175            f.render_widget(
176                Paragraph::new(Line::from(right_spans)).alignment(Alignment::Right),
177                cols[1],
178            );
179        }
180
181        // ── Line 2: document state, `·`-separated segments ──────────────────
182        // The path yields to the live segments: when the line would overflow,
183        // the path is head-truncated with an ellipsis so ln/col, dirty state,
184        // git, and match count stay visible.
185        let tail_width: usize = {
186            let mut w = 0usize;
187            if let Some((ln, col)) = doc.ln_col {
188                w += format!(" · ln {ln} col {col}").width();
189            }
190            w += if doc.dirty {
191                " · ● modified".width()
192            } else {
193                " · ✓ saved".width()
194            };
195            if let Some(count) = doc.backlinks {
196                w += format!(" · {count} backlinks").width();
197            }
198            if let Some(git) = &doc.git {
199                w += " · ".width() + git.width();
200            }
201            if let Some(matches) = doc.matches {
202                w += format!(" · {matches} matches").width();
203            }
204            if let Some(update) = &doc.update {
205                w += " · ".width() + update.width();
206            }
207            if let Some(rag) = &doc.rag {
208                w += " · ".width() + rag.width();
209            }
210            w
211        };
212        let path_budget = (rect.width as usize).saturating_sub(tail_width + 1);
213        let path_display = fit_path(doc.path, path_budget);
214        let mut segments: Vec<Span> = vec![Span::styled(format!(" {path_display}"), muted)];
215        let push = |segments: &mut Vec<Span>, span: Span<'static>| {
216            segments.push(Span::styled(" · ", muted));
217            segments.push(span);
218        };
219        if let Some((ln, col)) = doc.ln_col {
220            push(
221                &mut segments,
222                Span::styled(format!("ln {ln} col {col}"), muted),
223            );
224        }
225        let state_span = if doc.dirty {
226            Span::styled("● modified", Style::default().fg(theme.yellow.to_ratatui()))
227        } else {
228            Span::styled("✓ saved", Style::default().fg(theme.green.to_ratatui()))
229        };
230        push(&mut segments, state_span);
231        if let Some(count) = doc.backlinks {
232            push(
233                &mut segments,
234                Span::styled(format!("{count} backlinks"), muted),
235            );
236        }
237        if let Some(git) = &doc.git {
238            push(&mut segments, Span::styled(git.clone(), muted));
239        }
240        if let Some(matches) = doc.matches {
241            push(
242                &mut segments,
243                Span::styled(
244                    format!("{matches} matches"),
245                    Style::default().fg(theme.fg_secondary.to_ratatui()),
246                ),
247            );
248        }
249        if let Some(link) = &doc.link {
250            push(
251                &mut segments,
252                Span::styled(link.clone(), Style::default().fg(theme.blue.to_ratatui())),
253            );
254        }
255        if let Some(update) = &doc.update {
256            push(
257                &mut segments,
258                Span::styled(
259                    update.clone(),
260                    Style::default()
261                        .fg(theme.accent.to_ratatui())
262                        .add_modifier(Modifier::BOLD),
263                ),
264            );
265        }
266        if let Some(rag) = &doc.rag {
267            push(
268                &mut segments,
269                Span::styled(rag.clone(), Style::default().fg(theme.green.to_ratatui())),
270            );
271        }
272        f.render_widget(Paragraph::new(Line::from(segments)), rows[1]);
273    }
274}
275
276impl Default for FooterBar {
277    fn default() -> Self {
278        Self::new()
279    }
280}
281
282/// Fit `path` into `budget` display columns for the footer. When it overflows,
283/// keep the trailing portion (the note-name end is the most useful part) and
284/// prefix it with `…`. Truncation lands on grapheme-cluster boundaries and
285/// measures by rendered width, so a multi-codepoint cluster (emoji presentation
286/// sequence, combining mark) is never split or reordered.
287fn fit_path(path: &str, budget: usize) -> String {
288    if path.width() <= budget {
289        return path.to_string();
290    }
291    let mut acc = 0usize;
292    let keep: String = path
293        .graphemes(true)
294        .rev()
295        .take_while(|g| {
296            acc += g.width();
297            acc < budget
298        })
299        .collect::<Vec<_>>()
300        .into_iter()
301        .rev()
302        .collect();
303    format!("…{keep}")
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    #[test]
311    fn short_path_returned_whole() {
312        assert_eq!(fit_path("notes/foo", 20), "notes/foo");
313    }
314
315    #[test]
316    fn overflowing_ascii_path_keeps_trailing_with_ellipsis() {
317        // "abcdefghij" (10 cols) into budget 5: keep trailing clusters while
318        // cumulative width stays strictly < 5 → "ghij" (4 cols); "f" would
319        // reach 5 and stop. Matches the pre-extraction truncation behavior.
320        assert_eq!(fit_path("abcdefghij", 5), "…ghij");
321    }
322
323    #[test]
324    fn cjk_width_counted_as_two_columns() {
325        // "猫猫猫" is 6 display cols; budget 6 fits whole (no ellipsis).
326        assert_eq!(fit_path("猫猫猫", 6), "猫猫猫");
327    }
328
329    #[test]
330    fn emoji_cluster_not_split_or_reordered() {
331        // Flag 🇪🇸 = two regional indicators, one cluster (2 cols). Budget 2 on
332        // "z🇪🇸" forces the cut to land *inside* the flag. A per-codepoint
333        // truncation would keep a lone regional indicator (🇸 — half a flag);
334        // grapheme-aware truncation must never emit a partial cluster, so the
335        // flag is kept whole or dropped entirely (here: dropped → just "…").
336        let es = "\u{1F1F8}"; // ES regional indicator (the 2nd half)
337        let flag = "\u{1F1EA}\u{1F1F8}";
338        let path = format!("z{flag}");
339        let out = fit_path(&path, 2);
340        assert!(out.starts_with('…'), "expected ellipsis prefix: {out:?}");
341        assert!(
342            !out.contains(es) || out.contains(flag),
343            "regional indicator emitted without its full flag cluster: {out:?}"
344        );
345    }
346}