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 pub rag: Option<String>,
51}
52
53pub struct StatusContext<'a> {
55 pub focus_label: &'a str,
57 pub editing: bool,
59 pub hints: &'a [Hint],
61 pub global_hints: &'a [Hint],
63 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 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 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 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 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 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 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 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
282fn 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 assert_eq!(fit_path("abcdefghij", 5), "…ghij");
321 }
322
323 #[test]
324 fn cjk_width_counted_as_two_columns() {
325 assert_eq!(fit_path("猫猫猫", 6), "猫猫猫");
327 }
328
329 #[test]
330 fn emoji_cluster_not_split_or_reordered() {
331 let es = "\u{1F1F8}"; 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}