A terminal app to allow for easy voice recording
1use crate::{player::Playback, recorder::Recorder, storage};
2use anyhow::Result;
3use crossterm::event::{self, Event, KeyCode, KeyEventKind};
4use ratatui::{
5 layout::{Constraint, Direction, Layout},
6 style::{Color, Modifier, Style},
7 text::{Line, Span},
8 widgets::{Block, Borders, Gauge, List, ListItem, ListState, Paragraph, Sparkline},
9 Frame,
10};
11use std::collections::VecDeque;
12use std::path::PathBuf;
13use std::time::Duration;
14
15// ── Data ─────────────────────────────────────────────────────────────────────
16
17enum ListEntry {
18 Header(String),
19 File(PathBuf),
20}
21
22enum Mode {
23 Idle,
24 Recording {
25 recorder: Recorder,
26 level_history: VecDeque<u64>,
27 tick: u64,
28 },
29 Playing {
30 playback: Playback,
31 filename: String,
32 },
33 Renaming {
34 /// Characters in the name being edited (without extension).
35 chars: Vec<char>,
36 /// Cursor position (0..=chars.len()).
37 cursor: usize,
38 /// The file being renamed.
39 target: PathBuf,
40 },
41 Deleting {
42 target: PathBuf,
43 },
44}
45
46pub struct App {
47 entries: Vec<ListEntry>,
48 list_state: ListState,
49 mode: Mode,
50 /// Set after the first `g` keypress to detect `gg`.
51 pending_g: bool,
52 /// One-line status notification shown below the panels.
53 notification: Option<String>,
54 pub should_quit: bool,
55 /// Path of the last saved recording, returned to main after quit.
56 pub saved_path: Option<PathBuf>,
57}
58
59// ── Constructor & data loading ────────────────────────────────────────────────
60
61impl App {
62 pub fn new() -> Result<Self> {
63 let mut app = App {
64 entries: Vec::new(),
65 list_state: ListState::default(),
66 mode: Mode::Idle,
67 pending_g: false,
68 notification: None,
69 should_quit: false,
70 saved_path: None,
71 };
72 app.reload_entries()?;
73 Ok(app)
74 }
75
76 fn reload_entries(&mut self) -> Result<()> {
77 self.entries.clear();
78 let recordings = storage::list_recordings()?;
79 for (date, files) in &recordings {
80 self.entries.push(ListEntry::Header(date.clone()));
81 for f in files {
82 self.entries.push(ListEntry::File(f.clone()));
83 }
84 }
85 // Keep selection valid; move to first file if nothing selected.
86 if self.list_state.selected().map_or(true, |i| i >= self.entries.len()) {
87 let first = self.entries.iter().position(|e| matches!(e, ListEntry::File(_)));
88 self.list_state.select(first);
89 }
90 Ok(())
91 }
92}
93
94// ── Tick (called every frame) ─────────────────────────────────────────────────
95
96impl App {
97 pub fn tick(&mut self) {
98 // Push the latest level sample into the waveform history.
99 if let Mode::Recording { recorder, level_history, tick } = &mut self.mode {
100 let bar = (recorder.level() * 6.0 * 64.0).min(64.0) as u64;
101 level_history.push_back(bar);
102 if level_history.len() > 60 {
103 level_history.pop_front();
104 }
105 *tick = tick.wrapping_add(1);
106 }
107
108 // Auto-return to Idle when playback finishes.
109 let done = matches!(&self.mode, Mode::Playing { playback, .. } if playback.is_done());
110 if done {
111 self.mode = Mode::Idle;
112 self.notification = Some("Playback finished.".into());
113 }
114 }
115}
116
117// ── Key handling ──────────────────────────────────────────────────────────────
118
119impl App {
120 pub fn handle_key(&mut self, key: KeyCode) -> Result<()> {
121 match &self.mode {
122 // ── Renaming mode ─────────────────────────────────────────────
123 Mode::Renaming { .. } => {
124 match key {
125 KeyCode::Char(c) => {
126 if let Mode::Renaming { chars, cursor, .. } = &mut self.mode {
127 chars.insert(*cursor, c);
128 *cursor += 1;
129 }
130 }
131 KeyCode::Backspace => {
132 if let Mode::Renaming { chars, cursor, .. } = &mut self.mode {
133 if *cursor > 0 {
134 *cursor -= 1;
135 chars.remove(*cursor);
136 }
137 }
138 }
139 KeyCode::Delete => {
140 if let Mode::Renaming { chars, cursor, .. } = &mut self.mode {
141 if *cursor < chars.len() {
142 chars.remove(*cursor);
143 }
144 }
145 }
146 KeyCode::Left => {
147 if let Mode::Renaming { cursor, .. } = &mut self.mode {
148 *cursor = cursor.saturating_sub(1);
149 }
150 }
151 KeyCode::Right => {
152 if let Mode::Renaming { chars, cursor, .. } = &mut self.mode {
153 *cursor = (*cursor + 1).min(chars.len());
154 }
155 }
156 KeyCode::Home => {
157 if let Mode::Renaming { cursor, .. } = &mut self.mode {
158 *cursor = 0;
159 }
160 }
161 KeyCode::End => {
162 if let Mode::Renaming { chars, cursor, .. } = &mut self.mode {
163 *cursor = chars.len();
164 }
165 }
166 KeyCode::Enter => self.confirm_rename()?,
167 KeyCode::Esc => {
168 self.mode = Mode::Idle;
169 }
170 _ => {}
171 }
172 }
173
174 // ── Deleting mode ─────────────────────────────────────────────
175 Mode::Deleting { .. } => {
176 match key {
177 KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
178 self.confirm_delete()?;
179 }
180 _ => {
181 self.mode = Mode::Idle;
182 self.notification = Some("Delete cancelled.".into());
183 }
184 }
185 }
186
187 // ── Recording mode ────────────────────────────────────────────
188 Mode::Recording { .. } => {
189 self.pending_g = false;
190 match key {
191 // Space / s / Enter → stop and save
192 KeyCode::Char(' ') | KeyCode::Char('s') | KeyCode::Enter => {
193 self.stop_recording(true)?;
194 }
195 // Esc → cancel (discard)
196 KeyCode::Esc => {
197 self.stop_recording(false)?;
198 self.notification = Some("Recording cancelled.".into());
199 }
200 // q → save and quit
201 KeyCode::Char('q') => {
202 self.stop_recording(true)?;
203 self.should_quit = true;
204 }
205 _ => {}
206 }
207 }
208
209 // ── Browsing / Playing mode ───────────────────────────────────
210 _ => {
211 // Playback-specific controls
212 if matches!(&self.mode, Mode::Playing { .. }) {
213 match key {
214 KeyCode::Char(' ') => {
215 if let Mode::Playing { playback, .. } = &self.mode {
216 playback.toggle_pause();
217 }
218 self.pending_g = false;
219 return Ok(());
220 }
221 KeyCode::Char('s') | KeyCode::Esc => {
222 self.stop_playing();
223 self.pending_g = false;
224 return Ok(());
225 }
226 _ => {}
227 }
228 }
229
230 // Navigation and global bindings
231 match key {
232 KeyCode::Char('q') => {
233 self.stop_playing();
234 self.should_quit = true;
235 self.pending_g = false;
236 }
237 KeyCode::Char('j') | KeyCode::Down => {
238 self.move_selection(1);
239 self.pending_g = false;
240 }
241 KeyCode::Char('k') | KeyCode::Up => {
242 self.move_selection(-1);
243 self.pending_g = false;
244 }
245 KeyCode::Char('G') => {
246 self.select_last();
247 self.pending_g = false;
248 }
249 KeyCode::Char('g') => {
250 if self.pending_g {
251 self.select_first();
252 self.pending_g = false;
253 } else {
254 self.pending_g = true;
255 }
256 }
257 KeyCode::Enter | KeyCode::Char('o') => {
258 self.pending_g = false;
259 self.stop_playing();
260 self.start_playing()?;
261 }
262 KeyCode::Char(' ') => {
263 self.pending_g = false;
264 self.start_recording()?;
265 }
266 KeyCode::Char('r') => {
267 self.pending_g = false;
268 self.start_renaming();
269 }
270 KeyCode::Char('d') => {
271 self.pending_g = false;
272 self.stop_playing();
273 self.start_deleting();
274 }
275 _ => {
276 self.pending_g = false;
277 }
278 }
279 }
280 }
281 Ok(())
282 }
283}
284
285// ── Actions ───────────────────────────────────────────────────────────────────
286
287impl App {
288 fn start_recording(&mut self) -> Result<()> {
289 let path = storage::next_recording_path()?;
290 let recorder = Recorder::start(path)?;
291 self.mode = Mode::Recording {
292 recorder,
293 level_history: VecDeque::new(),
294 tick: 0,
295 };
296 self.notification = None;
297 Ok(())
298 }
299
300 fn stop_recording(&mut self, save: bool) -> Result<()> {
301 let prev = std::mem::replace(&mut self.mode, Mode::Idle);
302 if let Mode::Recording { recorder, .. } = prev {
303 let path = recorder.stop()?;
304 if save {
305 let name = path
306 .file_name()
307 .map(|n| n.to_string_lossy().to_string())
308 .unwrap_or_default();
309 self.notification = Some(format!("Saved: {}", name));
310 self.saved_path = Some(path.clone());
311 self.reload_entries()?;
312 self.select_file(&path);
313 } else {
314 let _ = std::fs::remove_file(&path);
315 }
316 }
317 Ok(())
318 }
319
320 fn start_playing(&mut self) -> Result<()> {
321 if let Some(idx) = self.list_state.selected() {
322 if let Some(ListEntry::File(path)) = self.entries.get(idx) {
323 let path = path.clone();
324 let filename = path
325 .file_name()
326 .map(|n| n.to_string_lossy().to_string())
327 .unwrap_or_default();
328 self.mode = Mode::Playing {
329 playback: Playback::open(&path)?,
330 filename,
331 };
332 self.notification = None;
333 }
334 }
335 Ok(())
336 }
337
338 fn stop_playing(&mut self) {
339 let prev = std::mem::replace(&mut self.mode, Mode::Idle);
340 if let Mode::Playing { playback, .. } = prev {
341 playback.stop();
342 }
343 }
344
345 fn start_renaming(&mut self) {
346 if let Some(idx) = self.list_state.selected() {
347 if let Some(ListEntry::File(path)) = self.entries.get(idx) {
348 let stem = path
349 .file_stem()
350 .map(|s| s.to_string_lossy().to_string())
351 .unwrap_or_default();
352 let chars: Vec<char> = stem.chars().collect();
353 let cursor = chars.len();
354 self.mode = Mode::Renaming {
355 chars,
356 cursor,
357 target: path.clone(),
358 };
359 }
360 }
361 }
362
363 fn start_deleting(&mut self) {
364 if let Some(idx) = self.list_state.selected() {
365 if let Some(ListEntry::File(path)) = self.entries.get(idx) {
366 self.mode = Mode::Deleting { target: path.clone() };
367 }
368 }
369 }
370
371 fn confirm_delete(&mut self) -> Result<()> {
372 let prev = std::mem::replace(&mut self.mode, Mode::Idle);
373 if let Mode::Deleting { target } = prev {
374 let name = target
375 .file_name()
376 .map(|n| n.to_string_lossy().to_string())
377 .unwrap_or_default();
378 std::fs::remove_file(&target)?;
379 self.notification = Some(format!("Deleted '{}'.", name));
380 self.reload_entries()?;
381 // Selection is automatically clamped by reload_entries.
382 }
383 Ok(())
384 }
385
386 fn confirm_rename(&mut self) -> Result<()> {
387 let prev = std::mem::replace(&mut self.mode, Mode::Idle);
388 if let Mode::Renaming { chars, target, .. } = prev {
389 let name: String = chars.iter().collect();
390 if name.is_empty() {
391 self.notification = Some("Rename cancelled — name was empty.".into());
392 return Ok(());
393 }
394 let new_name = if name.ends_with(".mp3") {
395 name
396 } else {
397 format!("{}.mp3", name)
398 };
399 if new_name.contains('/') || new_name.contains('\\') || new_name.starts_with("..") {
400 self.notification = Some("Invalid filename.".into());
401 return Ok(());
402 }
403 let new_path = target.with_file_name(&new_name);
404 match std::fs::rename(&target, &new_path) {
405 Ok(_) => {}
406 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
407 self.notification = Some(format!("'{}' already exists.", new_name));
408 return Ok(());
409 }
410 Err(e) => return Err(e.into()),
411 }
412 self.notification = Some(format!("Renamed to '{}'.", new_name));
413 self.reload_entries()?;
414 self.select_file(&new_path);
415 }
416 Ok(())
417 }
418}
419
420// ── Selection helpers ─────────────────────────────────────────────────────────
421
422impl App {
423 fn move_selection(&mut self, delta: i32) {
424 let len = self.entries.len() as i32;
425 if len == 0 {
426 return;
427 }
428 let cur = self.list_state.selected().unwrap_or(0) as i32;
429 let mut next = cur + delta;
430 for _ in 0..len {
431 if next < 0 {
432 next = 0;
433 break;
434 }
435 if next >= len {
436 next = len - 1;
437 break;
438 }
439 if matches!(self.entries[next as usize], ListEntry::File(_)) {
440 break;
441 }
442 next += delta;
443 }
444 let next = next.clamp(0, len - 1) as usize;
445 if matches!(self.entries[next], ListEntry::File(_)) {
446 self.list_state.select(Some(next));
447 }
448 }
449
450 fn select_first(&mut self) {
451 if let Some(i) = self.entries.iter().position(|e| matches!(e, ListEntry::File(_))) {
452 self.list_state.select(Some(i));
453 }
454 }
455
456 fn select_last(&mut self) {
457 if let Some(i) = self.entries.iter().rposition(|e| matches!(e, ListEntry::File(_))) {
458 self.list_state.select(Some(i));
459 }
460 }
461
462 fn select_file(&mut self, target: &PathBuf) {
463 if let Some(i) = self.entries.iter().position(|e| {
464 matches!(e, ListEntry::File(p) if p == target)
465 }) {
466 self.list_state.select(Some(i));
467 }
468 }
469}
470
471// ── Drawing ───────────────────────────────────────────────────────────────────
472
473impl App {
474 pub fn draw(&mut self, f: &mut Frame<'_>) {
475 let area = f.area();
476
477 // Outer layout: [left panel | right panel] + notification bar
478 let rows = Layout::default()
479 .direction(Direction::Vertical)
480 .constraints([Constraint::Min(0), Constraint::Length(1)])
481 .split(area);
482
483 let cols = Layout::default()
484 .direction(Direction::Horizontal)
485 .constraints([Constraint::Percentage(42), Constraint::Percentage(58)])
486 .split(rows[0]);
487
488 self.draw_file_list(f, cols[0]);
489 self.draw_right_panel(f, cols[1]);
490 self.draw_status_bar(f, rows[1]);
491 }
492
493 fn draw_file_list(&mut self, f: &mut Frame<'_>, area: ratatui::layout::Rect) {
494 let is_browsing = !matches!(self.mode, Mode::Recording { .. });
495
496 let items: Vec<ListItem> = self
497 .entries
498 .iter()
499 .map(|e| match e {
500 ListEntry::Header(date) => ListItem::new(Line::from(Span::styled(
501 format!(" {}/", date),
502 Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
503 ))),
504 ListEntry::File(path) => {
505 let name = path
506 .file_name()
507 .map(|n| n.to_string_lossy().to_string())
508 .unwrap_or_default();
509 ListItem::new(Line::from(Span::raw(format!(" {}", name))))
510 }
511 })
512 .collect();
513
514 let title = if self.entries.is_empty() {
515 " Voice Notes — no recordings yet "
516 } else {
517 " Voice Notes "
518 };
519
520 let highlight_style = if is_browsing {
521 Style::default()
522 .fg(Color::Black)
523 .bg(Color::Green)
524 .add_modifier(Modifier::BOLD)
525 } else {
526 // Dim the selection when recording so focus is on the right panel.
527 Style::default().fg(Color::DarkGray).bg(Color::DarkGray)
528 };
529
530 let list = List::new(items)
531 .block(Block::default().title(title).borders(Borders::ALL))
532 .highlight_style(highlight_style)
533 .highlight_symbol("▶ ");
534
535 f.render_stateful_widget(list, area, &mut self.list_state);
536 }
537
538 fn draw_right_panel(&self, f: &mut Frame<'_>, area: ratatui::layout::Rect) {
539 match &self.mode {
540 Mode::Idle => self.draw_idle_panel(f, area),
541 Mode::Recording { recorder, level_history, tick } => {
542 self.draw_recording_panel(f, area, recorder, level_history, *tick)
543 }
544 Mode::Playing { playback, filename } => {
545 self.draw_playing_panel(f, area, playback, filename)
546 }
547 Mode::Renaming { chars, cursor, target } => {
548 self.draw_rename_panel(f, area, chars, *cursor, target)
549 }
550 Mode::Deleting { target } => self.draw_delete_panel(f, area, target),
551 }
552 }
553
554 fn draw_idle_panel(&self, f: &mut Frame<'_>, area: ratatui::layout::Rect) {
555 let chunks = Layout::default()
556 .direction(Direction::Vertical)
557 .constraints([Constraint::Min(0), Constraint::Length(8)])
558 .split(area);
559
560 // Empty top area with border
561 let top = Paragraph::new("").block(
562 Block::default()
563 .title(" Ready ")
564 .borders(Borders::ALL)
565 .style(Style::default().fg(Color::DarkGray)),
566 );
567 f.render_widget(top, chunks[0]);
568
569 // Help panel at the bottom of the right side
570 let help_lines = vec![
571 Line::from(vec![
572 Span::styled(" Space", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
573 Span::raw(" start recording"),
574 ]),
575 Line::from(vec![
576 Span::styled(" r", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
577 Span::raw(" rename selected"),
578 ]),
579 Line::from(vec![
580 Span::styled(" d", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
581 Span::raw(" delete selected"),
582 ]),
583 Line::from(vec![
584 Span::styled(" Enter / o", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
585 Span::raw(" play selected"),
586 ]),
587 Line::from(vec![
588 Span::styled(" j / k", Style::default().fg(Color::Cyan)),
589 Span::raw(" navigate"),
590 ]),
591 Line::from(vec![
592 Span::styled(" gg / G", Style::default().fg(Color::Cyan)),
593 Span::raw(" first / last"),
594 ]),
595 ];
596 let help = Paragraph::new(help_lines).block(
597 Block::default()
598 .title(" Keys ")
599 .borders(Borders::ALL)
600 .style(Style::default().fg(Color::DarkGray)),
601 );
602 f.render_widget(help, chunks[1]);
603 }
604
605 fn draw_recording_panel(
606 &self,
607 f: &mut Frame<'_>,
608 area: ratatui::layout::Rect,
609 recorder: &Recorder,
610 level_history: &VecDeque<u64>,
611 tick: u64,
612 ) {
613 let chunks = Layout::default()
614 .direction(Direction::Vertical)
615 .margin(0)
616 .constraints([
617 Constraint::Length(3), // status + duration
618 Constraint::Length(3), // waveform sparkline
619 Constraint::Length(3), // level gauge
620 Constraint::Min(1), // file path
621 Constraint::Length(2), // controls
622 ])
623 .split(area);
624
625 let duration_ms = recorder.duration_ms();
626 let secs = duration_ms / 1000;
627 let duration_str = format!("{:02}:{:02}", secs / 60, secs % 60);
628 let blink_on = (tick / 6) % 2 == 0;
629 let dot = if blink_on { "● REC" } else { " REC" };
630
631 // ── Status ────────────────────────────────────────────────────────
632 let status = Paragraph::new(Line::from(vec![
633 Span::styled(dot, Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
634 Span::raw(format!(" {}", duration_str)),
635 ]))
636 .block(Block::default().title(" Recording ").borders(Borders::ALL));
637 f.render_widget(status, chunks[0]);
638
639 // ── Waveform ──────────────────────────────────────────────────────
640 let sparkdata: Vec<u64> = level_history.iter().cloned().collect();
641 let sparkline = Sparkline::default()
642 .block(Block::default().borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM))
643 .style(Style::default().fg(Color::Green))
644 .data(&sparkdata)
645 .max(64);
646 f.render_widget(sparkline, chunks[1]);
647
648 // ── Level gauge ───────────────────────────────────────────────────
649 let raw_level = recorder.level();
650 let display_level = (raw_level * 6.0).min(1.0) as f64;
651 let gauge_color = if display_level > 0.85 {
652 Color::Red
653 } else if display_level > 0.55 {
654 Color::Yellow
655 } else {
656 Color::Green
657 };
658 let gauge = Gauge::default()
659 .block(
660 Block::default()
661 .title(" Level ")
662 .borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM),
663 )
664 .gauge_style(Style::default().fg(gauge_color).bg(Color::DarkGray))
665 .ratio(display_level);
666 f.render_widget(gauge, chunks[2]);
667
668 // ── File path ─────────────────────────────────────────────────────
669 let path_str = recorder
670 .path
671 .to_string_lossy()
672 .replace(&std::env::var("HOME").unwrap_or_default(), "~");
673 let path_widget = Paragraph::new(Line::from(vec![
674 Span::styled(" → ", Style::default().fg(Color::DarkGray)),
675 Span::styled(path_str, Style::default().fg(Color::DarkGray)),
676 ]));
677 f.render_widget(path_widget, chunks[3]);
678
679 // ── Controls ──────────────────────────────────────────────────────
680 let controls = Paragraph::new(Line::from(vec![
681 Span::styled(" [Space/s/Enter]", Style::default().fg(Color::Green)),
682 Span::raw(" Save "),
683 Span::styled("[Esc]", Style::default().fg(Color::Red)),
684 Span::raw(" Cancel"),
685 ]));
686 f.render_widget(controls, chunks[4]);
687 }
688
689 fn draw_playing_panel(
690 &self,
691 f: &mut Frame<'_>,
692 area: ratatui::layout::Rect,
693 playback: &Playback,
694 filename: &str,
695 ) {
696 let chunks = Layout::default()
697 .direction(Direction::Vertical)
698 .constraints([Constraint::Length(5), Constraint::Min(0), Constraint::Length(2)])
699 .split(area);
700
701 let paused = playback.is_paused();
702 let (icon, color) = if paused {
703 ("⏸ PAUSED", Color::Yellow)
704 } else {
705 ("▶ PLAYING", Color::Green)
706 };
707
708 let status_lines = vec![
709 Line::from(vec![Span::styled(
710 icon,
711 Style::default().fg(color).add_modifier(Modifier::BOLD),
712 )]),
713 Line::from(""),
714 Line::from(vec![Span::styled(
715 format!(" {}", filename),
716 Style::default().fg(Color::White),
717 )]),
718 ];
719 let status = Paragraph::new(status_lines)
720 .block(Block::default().title(" Now Playing ").borders(Borders::ALL));
721 f.render_widget(status, chunks[0]);
722
723 let controls = Paragraph::new(Line::from(vec![
724 Span::styled(" [Space]", Style::default().fg(Color::Cyan)),
725 Span::raw(" Pause "),
726 Span::styled("[s / Esc]", Style::default().fg(Color::Red)),
727 Span::raw(" Stop"),
728 ]));
729 f.render_widget(controls, chunks[2]);
730 }
731
732 fn draw_rename_panel(
733 &self,
734 f: &mut Frame<'_>,
735 area: ratatui::layout::Rect,
736 chars: &[char],
737 cursor: usize,
738 target: &PathBuf,
739 ) {
740 let chunks = Layout::default()
741 .direction(Direction::Vertical)
742 .constraints([Constraint::Length(5), Constraint::Min(0), Constraint::Length(2)])
743 .split(area);
744
745 let old_name = target
746 .file_name()
747 .map(|n| n.to_string_lossy().to_string())
748 .unwrap_or_default();
749
750 // Render the editable name with a block cursor.
751 let before: String = chars[..cursor].iter().collect();
752 let (at_char, after): (String, String) = if cursor < chars.len() {
753 (
754 chars[cursor].to_string(),
755 chars[cursor + 1..].iter().collect(),
756 )
757 } else {
758 (" ".to_string(), String::new())
759 };
760
761 let input_line = Line::from(vec![
762 Span::raw(" "),
763 Span::raw(before),
764 Span::styled(at_char, Style::default().add_modifier(Modifier::REVERSED)),
765 Span::raw(after),
766 ]);
767
768 let panel_lines = vec![
769 Line::from(vec![
770 Span::styled(" Old: ", Style::default().fg(Color::DarkGray)),
771 Span::styled(&old_name, Style::default().fg(Color::DarkGray)),
772 ]),
773 Line::from(""),
774 input_line,
775 ];
776
777 let panel = Paragraph::new(panel_lines)
778 .block(Block::default().title(" Rename ").borders(Borders::ALL));
779 f.render_widget(panel, chunks[0]);
780
781 let controls = Paragraph::new(Line::from(vec![
782 Span::styled(" [Enter]", Style::default().fg(Color::Green)),
783 Span::raw(" Confirm "),
784 Span::styled("[Esc]", Style::default().fg(Color::Red)),
785 Span::raw(" Cancel "),
786 Span::styled("[←/→]", Style::default().fg(Color::Cyan)),
787 Span::raw(" Move cursor"),
788 ]));
789 f.render_widget(controls, chunks[2]);
790 }
791
792 fn draw_delete_panel(&self, f: &mut Frame<'_>, area: ratatui::layout::Rect, target: &PathBuf) {
793 let name = target
794 .file_name()
795 .map(|n| n.to_string_lossy().to_string())
796 .unwrap_or_default();
797
798 let lines = vec![
799 Line::from(""),
800 Line::from(vec![
801 Span::raw(" Delete "),
802 Span::styled(&name, Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
803 Span::raw(" ?"),
804 ]),
805 Line::from(""),
806 Line::from(vec![
807 Span::styled(" [y / Enter]", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
808 Span::raw(" Yes, delete it"),
809 ]),
810 Line::from(vec![
811 Span::styled(" [any other key]", Style::default().fg(Color::Green)),
812 Span::raw(" Cancel"),
813 ]),
814 ];
815
816 let panel = Paragraph::new(lines).block(
817 Block::default()
818 .title(" Confirm Delete ")
819 .borders(Borders::ALL)
820 .style(Style::default().fg(Color::Red)),
821 );
822 f.render_widget(panel, area);
823 }
824
825 fn draw_status_bar(&self, f: &mut Frame<'_>, area: ratatui::layout::Rect) {
826 let text = match &self.mode {
827 Mode::Idle => {
828 if let Some(msg) = &self.notification {
829 Line::from(Span::styled(
830 format!(" {}", msg),
831 Style::default().fg(Color::Yellow),
832 ))
833 } else {
834 Line::from(vec![
835 Span::styled(" Space", Style::default().fg(Color::Green)),
836 Span::raw(" Record "),
837 Span::styled("r", Style::default().fg(Color::Green)),
838 Span::raw(" Rename "),
839 Span::styled("d", Style::default().fg(Color::Red)),
840 Span::raw(" Delete "),
841 Span::styled("j/k", Style::default().fg(Color::Cyan)),
842 Span::raw(" Navigate "),
843 Span::styled("Enter", Style::default().fg(Color::Cyan)),
844 Span::raw(" Play "),
845 Span::styled("gg/G", Style::default().fg(Color::Cyan)),
846 Span::raw(" Top/Bot "),
847 Span::styled("q", Style::default().fg(Color::Red)),
848 Span::raw(" Quit"),
849 ])
850 }
851 }
852 Mode::Recording { .. } => Line::from(Span::styled(
853 " Recording — Space/s/Enter: save Esc: cancel q: save+quit",
854 Style::default().fg(Color::Red),
855 )),
856 Mode::Playing { .. } => Line::from(Span::styled(
857 " Playing — Space: pause s/Esc: stop q: quit",
858 Style::default().fg(Color::Green),
859 )),
860 Mode::Renaming { .. } => Line::from(Span::styled(
861 " Renaming — type new name Enter: confirm Esc: cancel ←/→: move cursor",
862 Style::default().fg(Color::Yellow),
863 )),
864 Mode::Deleting { .. } => Line::from(Span::styled(
865 " Delete? — y/Enter: confirm any other key: cancel",
866 Style::default().fg(Color::Red),
867 )),
868 };
869 f.render_widget(Paragraph::new(text), area);
870 }
871}
872
873// ── Main event loop ───────────────────────────────────────────────────────────
874
875pub fn run(terminal: &mut super::Term) -> Result<Option<PathBuf>> {
876 let mut app = App::new()?;
877
878 loop {
879 app.tick();
880 terminal.draw(|f| app.draw(f))?;
881
882 if event::poll(Duration::from_millis(50))? {
883 if let Event::Key(key) = event::read()? {
884 if key.kind != KeyEventKind::Press {
885 continue;
886 }
887 app.handle_key(key.code)?;
888 if app.should_quit {
889 break;
890 }
891 }
892 }
893 }
894
895 Ok(app.saved_path)
896}