1use 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
28pub const STATUS_BAR_HEIGHT: u16 = 2;
30
31#[derive(Default)]
34pub struct DocState<'a> {
35 pub path: &'a str,
36 pub dirty: bool,
37 pub ln_col: Option<(usize, usize)>,
39 pub backlinks: Option<usize>,
41 pub git: Option<String>,
43 pub matches: Option<usize>,
45 pub link: Option<String>,
47 pub update: Option<String>,
49}
50
51pub struct StatusContext<'a> {
53 pub focus_label: &'a str,
55 pub editing: bool,
57 pub hints: &'a [Hint],
59 pub global_hints: &'a [Hint],
61 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 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 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 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 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 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 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 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
271fn 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 assert_eq!(fit_path("abcdefghij", 5), "…ghij");
310 }
311
312 #[test]
313 fn cjk_width_counted_as_two_columns() {
314 assert_eq!(fit_path("猫猫猫", 6), "猫猫猫");
316 }
317
318 #[test]
319 fn emoji_cluster_not_split_or_reordered() {
320 let es = "\u{1F1F8}"; 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}