1use ratatui::{
24 layout::{Alignment, Constraint, Direction, Layout, Rect},
25 style::{Modifier, Style},
26 text::{Line, Span},
27 widgets::{Block, BorderType, Borders, List, ListItem, ListState, Padding, Paragraph},
28 Frame,
29};
30
31use crate::{
32 dual_pane::DualPane,
33 explorer::{entry_icon, fmt_size},
34 palette::Theme,
35 FileExplorer,
36};
37
38macro_rules! render_input_footer {
59 ($explorer:expr, $frame:expr, $area:expr, $theme:expr,
60 $active:ident, $input_expr:expr, $label:expr, $colour:ident, $hint:expr) => {
61 if $explorer.$active {
62 let left_line = Line::from(vec![
63 Span::styled(
64 $label,
65 Style::default()
66 .fg($theme.$colour)
67 .add_modifier(Modifier::BOLD),
68 ),
69 Span::styled(
70 $input_expr,
71 Style::default()
72 .fg($theme.accent)
73 .add_modifier(Modifier::BOLD),
74 ),
75 Span::styled("\u{2588}", Style::default().fg($theme.accent)),
76 Span::styled($hint, Style::default().fg($theme.dim)),
77 ]);
78 let para = Paragraph::new(left_line).block(
79 Block::default()
80 .borders(Borders::ALL)
81 .border_type(BorderType::Rounded)
82 .border_style(Style::default().fg($theme.$colour)),
83 );
84 $frame.render_widget(para, $area);
85 return;
86 }
87 };
88}
89
90pub fn render(explorer: &mut FileExplorer, frame: &mut Frame, area: Rect) {
115 render_themed(explorer, frame, area, &Theme::default());
116}
117
118pub fn render_dual_pane(dual: &mut DualPane, frame: &mut Frame, area: Rect) {
139 render_dual_pane_themed(dual, frame, area, &Theme::default());
140}
141
142pub fn render_dual_pane_themed(dual: &mut DualPane, frame: &mut Frame, area: Rect, theme: &Theme) {
160 use crate::dual_pane::DualPaneActive;
161
162 if dual.single_pane {
163 match dual.active_side {
165 DualPaneActive::Left => render_pane(
166 &mut dual.left,
167 frame,
168 area,
169 theme,
170 true, ),
172 DualPaneActive::Right => render_pane(&mut dual.right, frame, area, theme, true),
173 }
174 } else {
175 let halves = Layout::default()
177 .direction(Direction::Horizontal)
178 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
179 .split(area);
180
181 let left_active = dual.active_side == DualPaneActive::Left;
182 render_pane(&mut dual.left, frame, halves[0], theme, left_active);
183 render_pane(&mut dual.right, frame, halves[1], theme, !left_active);
184 }
185}
186
187fn render_pane(
189 explorer: &mut FileExplorer,
190 frame: &mut Frame,
191 area: Rect,
192 theme: &Theme,
193 is_active: bool,
194) {
195 let pane_theme;
198 let effective_theme = if is_active {
199 theme
200 } else {
201 pane_theme = Theme {
202 accent: theme.dim,
203 ..*theme
204 };
205 &pane_theme
206 };
207
208 let chunks = Layout::default()
209 .direction(Direction::Vertical)
210 .constraints([
211 Constraint::Length(3),
212 Constraint::Min(1),
213 Constraint::Length(3),
214 ])
215 .split(area);
216
217 render_header(explorer, frame, chunks[0], effective_theme);
218 render_list(explorer, frame, chunks[1], effective_theme);
219 render_footer(explorer, frame, chunks[2], effective_theme);
220}
221
222pub fn render_themed(explorer: &mut FileExplorer, frame: &mut Frame, area: Rect, theme: &Theme) {
245 let chunks = Layout::default()
246 .direction(Direction::Vertical)
247 .constraints([
248 Constraint::Length(3),
249 Constraint::Min(1),
250 Constraint::Length(3),
251 ])
252 .split(area);
253
254 render_header(explorer, frame, chunks[0], theme);
255 render_list(explorer, frame, chunks[1], theme);
256 render_footer(explorer, frame, chunks[2], theme);
257}
258
259fn render_header(explorer: &FileExplorer, frame: &mut Frame, area: Rect, theme: &Theme) {
262 let path_str = explorer.current_dir.to_string_lossy();
263
264 let inner_width = area.width.saturating_sub(4) as usize;
266 let display_path = if path_str.len() > inner_width && inner_width > 3 {
267 let skip = path_str.len() - inner_width + 1;
268 format!("\u{2026}{}", &path_str[skip..])
269 } else {
270 path_str.to_string()
271 };
272
273 let version = concat!(" v", env!("CARGO_PKG_VERSION"), " ");
274
275 let mut block = Block::default()
276 .title(Span::styled(
277 " \u{1F4C1} File Explorer ",
278 Style::default()
279 .fg(theme.brand)
280 .add_modifier(Modifier::BOLD),
281 ))
282 .title_bottom(
283 ratatui::text::Line::from(Span::styled(version, Style::default().fg(theme.dim)))
284 .right_aligned(),
285 )
286 .borders(Borders::ALL)
287 .border_type(BorderType::Rounded)
288 .border_style(Style::default().fg(theme.accent))
289 .padding(Padding::horizontal(1));
290
291 if !explorer.theme_name.is_empty() {
292 let theme_label = format!(" {} ", explorer.theme_name);
293 block = block.title(
294 ratatui::text::Line::from(Span::styled(theme_label, Style::default().fg(theme.dim)))
295 .right_aligned(),
296 );
297 }
298
299 if !explorer.editor_name.is_empty() {
300 let editor_label = format!(" \u{270F} {} ", explorer.editor_name);
301 block = block.title_bottom(
302 ratatui::text::Line::from(Span::styled(editor_label, Style::default().fg(theme.dim)))
303 .left_aligned(),
304 );
305 }
306
307 let header = Paragraph::new(Span::styled(
308 display_path,
309 Style::default()
310 .fg(theme.accent)
311 .add_modifier(Modifier::BOLD),
312 ))
313 .block(block)
314 .alignment(Alignment::Left);
315
316 frame.render_widget(header, area);
317}
318
319fn render_list(explorer: &mut FileExplorer, frame: &mut Frame, area: Rect, theme: &Theme) {
322 let visible_height = area.height.saturating_sub(2) as usize;
323
324 if explorer.cursor < explorer.scroll_offset {
328 explorer.scroll_offset = explorer.cursor;
329 } else if explorer.cursor >= explorer.scroll_offset.saturating_add(visible_height) {
330 explorer.scroll_offset = explorer
331 .cursor
332 .saturating_sub(visible_height.saturating_sub(1));
333 }
334 let max_scroll = explorer.entries.len().saturating_sub(1);
336 if explorer.scroll_offset > max_scroll {
337 explorer.scroll_offset = max_scroll;
338 }
339
340 let items: Vec<ListItem> = explorer
341 .entries
342 .iter()
343 .skip(explorer.scroll_offset)
344 .take(visible_height)
345 .enumerate()
346 .map(|(visible_idx, entry)| {
347 let abs_idx = visible_idx + explorer.scroll_offset;
348 let is_selected = abs_idx == explorer.cursor;
349 let is_marked = explorer.marked.contains(&entry.path);
350
351 let icon = entry_icon(entry);
352
353 let name_style = if is_marked {
356 Style::default()
357 .fg(theme.brand)
358 .add_modifier(Modifier::BOLD)
359 } else if entry.is_dir {
360 Style::default().fg(theme.dir).add_modifier(Modifier::BOLD)
361 } else {
362 Style::default()
363 .fg(theme.match_file)
364 .add_modifier(Modifier::BOLD)
365 };
366
367 let size_str = match entry.size {
368 Some(b) => fmt_size(b),
369 None => String::new(),
370 };
371
372 let marker = if is_marked {
374 Span::styled(
375 "◆",
376 Style::default()
377 .fg(theme.brand)
378 .add_modifier(Modifier::BOLD),
379 )
380 } else {
381 Span::styled(" ", Style::default())
382 };
383
384 let mut spans = vec![
385 marker,
386 Span::styled(
387 format!("{icon} "),
388 Style::default().fg(if entry.is_dir { theme.dir } else { theme.fg }),
389 ),
390 Span::styled(entry.name.clone(), name_style),
391 ];
392
393 if !size_str.is_empty() {
394 spans.push(Span::styled(
395 format!(" {size_str}"),
396 Style::default().fg(theme.dim),
397 ));
398 }
399
400 if entry.is_dir {
401 spans.push(Span::styled("/", Style::default().fg(theme.dir)));
402 }
403
404 let line = Line::from(spans);
405 if is_selected {
406 ListItem::new(line).style(
407 Style::default()
408 .bg(theme.sel_bg)
409 .add_modifier(Modifier::BOLD),
410 )
411 } else if is_marked {
412 ListItem::new(line).style(Style::default().add_modifier(Modifier::BOLD))
413 } else {
414 ListItem::new(line)
415 }
416 })
417 .collect();
418
419 let count = explorer.entries.len();
420 let marked_count = explorer.marked.len();
421 let pos = if count == 0 {
422 "empty".to_string()
423 } else {
424 format!("{}/{count}", explorer.cursor + 1)
425 };
426 let title = if marked_count > 0 {
427 format!(" Files {pos} ◆ {marked_count} marked ")
428 } else {
429 format!(" Files {pos} ")
430 };
431
432 let block = Block::default()
433 .title(Span::styled(title, Style::default().fg(theme.dim)))
434 .borders(Borders::ALL)
435 .border_type(BorderType::Rounded)
436 .border_style(Style::default().fg(theme.accent));
437
438 let mut list_state = ListState::default();
439 if !explorer.entries.is_empty() {
440 list_state.select(Some(explorer.cursor.saturating_sub(explorer.scroll_offset)));
441 }
442
443 let list = List::new(items).block(block);
444 frame.render_stateful_widget(list, area, &mut list_state);
445}
446
447fn render_footer(explorer: &FileExplorer, frame: &mut Frame, area: Rect, theme: &Theme) {
450 render_input_footer!(
452 explorer,
453 frame,
454 area,
455 theme,
456 mkdir_active,
457 explorer.mkdir_input(),
458 " \u{1F4C2} New folder: ",
459 success,
460 " Enter confirm Esc cancel"
461 );
462
463 render_input_footer!(
465 explorer,
466 frame,
467 area,
468 theme,
469 touch_active,
470 explorer.touch_input(),
471 " \u{1F4C4} New file: ",
472 accent,
473 " Enter confirm Esc cancel"
474 );
475
476 render_input_footer!(
478 explorer,
479 frame,
480 area,
481 theme,
482 rename_active,
483 explorer.rename_input(),
484 " \u{270F}\u{FE0F} Rename: ",
485 brand,
486 " Enter confirm Esc cancel"
487 );
488
489 render_input_footer!(
491 explorer,
492 frame,
493 area,
494 theme,
495 search_active,
496 explorer.search_query.as_str(),
497 " / ",
498 brand,
499 " Backspace delete Esc cancel"
500 );
501
502 let status = if explorer.status.is_empty() {
504 let filter = if explorer.extension_filter.is_empty() {
505 "all".to_string()
506 } else {
507 explorer
508 .extension_filter
509 .iter()
510 .map(|e| format!(".{e}"))
511 .collect::<Vec<_>>()
512 .join(", ")
513 };
514 let hidden_hint = if explorer.show_hidden { " +hidden" } else { "" };
515 format!(
516 "sort:{} filter:{}{} ",
517 explorer.sort_mode.label(),
518 filter,
519 hidden_hint,
520 )
521 } else {
522 format!(" {} ", explorer.status)
523 };
524
525 let status_para = Paragraph::new(Span::styled(status, Style::default().fg(theme.success)))
526 .alignment(Alignment::Right)
527 .block(
528 Block::default()
529 .borders(Borders::ALL)
530 .border_type(BorderType::Rounded)
531 .border_style(Style::default().fg(theme.dim)),
532 );
533 frame.render_widget(status_para, area);
534}
535
536#[cfg(test)]
539mod tests {
540 use super::*;
541 use ratatui::{backend::TestBackend, Terminal};
542
543 fn make_terminal() -> Terminal<TestBackend> {
544 Terminal::new(TestBackend::new(80, 24)).unwrap()
545 }
546
547 fn make_explorer() -> FileExplorer {
548 FileExplorer::new(std::env::current_dir().unwrap(), vec![])
549 }
550
551 #[test]
552 fn render_footer_mkdir_active_does_not_panic() {
553 let mut terminal = make_terminal();
554 let mut explorer = make_explorer();
555 explorer.mkdir_active = true;
556 terminal
557 .draw(|frame| {
558 render(&mut explorer, frame, frame.area());
559 })
560 .unwrap();
561 }
562
563 #[test]
564 fn render_footer_touch_active_does_not_panic() {
565 let mut terminal = make_terminal();
566 let mut explorer = make_explorer();
567 explorer.touch_active = true;
568 terminal
569 .draw(|frame| {
570 render(&mut explorer, frame, frame.area());
571 })
572 .unwrap();
573 }
574
575 #[test]
576 fn render_footer_rename_active_does_not_panic() {
577 let mut terminal = make_terminal();
578 let mut explorer = make_explorer();
579 explorer.rename_active = true;
580 terminal
581 .draw(|frame| {
582 render(&mut explorer, frame, frame.area());
583 })
584 .unwrap();
585 }
586
587 #[test]
588 fn render_footer_search_active_does_not_panic() {
589 let mut terminal = make_terminal();
590 let mut explorer = make_explorer();
591 explorer.search_active = true;
592 terminal
593 .draw(|frame| {
594 render(&mut explorer, frame, frame.area());
595 })
596 .unwrap();
597 }
598
599 #[test]
600 fn render_footer_all_inactive_does_not_panic() {
601 let mut terminal = make_terminal();
602 let mut explorer = make_explorer();
603 terminal
604 .draw(|frame| {
605 render(&mut explorer, frame, frame.area());
606 })
607 .unwrap();
608 }
609}