A terminal app to allow for easy voice recording
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 896 lines 35 kB view raw
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}