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_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
27pub const STATUS_BAR_HEIGHT: u16 = 2;
29
30#[derive(Default)]
33pub struct DocState<'a> {
34 pub path: &'a str,
35 pub dirty: bool,
36 pub ln_col: Option<(usize, usize)>,
38 pub backlinks: Option<usize>,
40 pub git: Option<String>,
42 pub matches: Option<usize>,
44 pub link: Option<String>,
46 pub update: Option<String>,
48}
49
50pub struct StatusContext<'a> {
52 pub focus_label: &'a str,
54 pub editing: bool,
56 pub hints: &'a [Hint],
58 pub global_hints: &'a [Hint],
60 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 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 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 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 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 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 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 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}