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