use kimun_core::nfs::VaultPath;
use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseEvent};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::widgets::Paragraph;
use crate::components::Component;
use crate::components::clickable::{is_press_outside, key_at};
use crate::components::event_state::EventState;
use crate::components::events::{AppEvent, AppTx, FileOp};
use crate::components::hint_row::HintRow;
use crate::components::panel::{ModalSpec, modal_chrome};
use crate::settings::themes::Theme;
pub struct FileOpsMenuDialog {
pub path: VaultPath,
pub path_display: String,
popup_rect: Rect,
action_rects: Vec<(Rect, KeyCode)>,
hints: HintRow,
}
impl FileOpsMenuDialog {
pub fn new(path: VaultPath) -> Self {
let path_display = format!(" {}", path);
Self {
path,
path_display,
popup_rect: Rect::default(),
action_rects: Vec::new(),
hints: HintRow::new(&[(KeyCode::Esc, "Esc", "Cancel")]),
}
}
pub fn handle_mouse(&mut self, m: &MouseEvent, tx: &AppTx) -> EventState {
if is_press_outside(m, self.popup_rect) {
return self.handle_key(KeyEvent::from(KeyCode::Esc), tx);
}
if let Some(key) = self.hints.hit(m) {
return self.handle_key(key, tx);
}
if let Some(key) = key_at(&self.action_rects, m) {
return self.handle_key(key, tx);
}
EventState::Consumed
}
pub fn handle_key(
&mut self,
key: ratatui::crossterm::event::KeyEvent,
tx: &AppTx,
) -> EventState {
match key.code {
KeyCode::Char('d') | KeyCode::Char('D') => {
tx.send(AppEvent::FileOp(FileOp::ShowDelete(self.path.clone())))
.ok();
EventState::Consumed
}
KeyCode::Char('r') | KeyCode::Char('R') => {
tx.send(AppEvent::FileOp(FileOp::ShowRename(self.path.clone())))
.ok();
EventState::Consumed
}
KeyCode::Char('m') | KeyCode::Char('M') => {
tx.send(AppEvent::FileOp(FileOp::ShowMove(self.path.clone())))
.ok();
EventState::Consumed
}
KeyCode::Esc => {
tx.send(AppEvent::CloseOverlay).ok();
EventState::Consumed
}
_ => EventState::Consumed, }
}
}
impl Component for FileOpsMenuDialog {
fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
let popup_area = super::fixed_centered_rect(46, 9, rect);
self.popup_rect = popup_area;
let inner = modal_chrome(
f,
popup_area,
theme,
ModalSpec {
title: Some(" File Operations "),
border: Some(Style::default().fg(theme.fg.to_ratatui())),
..Default::default()
},
);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
.split(inner);
let bg = theme.bg_panel.to_ratatui();
let fg = theme.fg.to_ratatui();
let gray = theme.gray.to_ratatui();
super::render_path_row(f, rows[1], &self.path_display, fg, bg);
super::render_separator(f, rows[2], gray, bg);
let action_cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
])
.split(rows[3]);
let key_style = Style::default()
.bg(bg)
.patch(theme.action())
.add_modifier(Modifier::BOLD);
let label_style = Style::default().fg(fg).bg(bg);
self.action_rects.clear();
for (col, (key, label, code)) in action_cols.iter().zip([
("[D]", " Delete", KeyCode::Char('d')),
("[R]", " Rename", KeyCode::Char('r')),
("[M]", " Move ", KeyCode::Char('m')),
]) {
let text_w = (key.len() + label.trim_end().len()) as u16;
let r = Rect::new(col.x + 1, col.y, text_w.min(col.width.saturating_sub(1)), 1);
crate::components::clickable::register(r);
self.action_rects.push((r, code));
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(1), Constraint::Length(3), Constraint::Min(1), ])
.split(*col);
f.render_widget(Paragraph::new(key).style(key_style), chunks[1]);
f.render_widget(Paragraph::new(label).style(label_style), chunks[2]);
}
self.hints
.render(f, rows[5], Style::default().fg(gray).bg(bg), theme);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn esc_sends_close_dialog() {
use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
use tokio::sync::mpsc;
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
let mut dialog = FileOpsMenuDialog::new(VaultPath::new("notes/test.md"));
let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
let state = dialog.handle_key(key, &tx);
assert_eq!(state, EventState::Consumed);
let event = rx.try_recv().expect("expected AppEvent::CloseOverlay");
assert!(matches!(event, AppEvent::CloseOverlay));
});
}
#[test]
fn d_sends_show_delete_dialog() {
use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
use tokio::sync::mpsc;
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let path = VaultPath::new("notes/test.md");
let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
let mut dialog = FileOpsMenuDialog::new(path.clone());
let key = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE);
let state = dialog.handle_key(key, &tx);
assert_eq!(state, EventState::Consumed);
let event = rx
.try_recv()
.expect("expected AppEvent::FileOp(FileOp::ShowDelete)");
assert!(matches!(event, AppEvent::FileOp(FileOp::ShowDelete(_))));
});
}
#[test]
fn r_sends_show_rename_dialog() {
use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
use tokio::sync::mpsc;
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let path = VaultPath::new("notes/test.md");
let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
let mut dialog = FileOpsMenuDialog::new(path.clone());
let key = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE);
let state = dialog.handle_key(key, &tx);
assert_eq!(state, EventState::Consumed);
let event = rx
.try_recv()
.expect("expected AppEvent::FileOp(FileOp::ShowRename)");
assert!(matches!(event, AppEvent::FileOp(FileOp::ShowRename(_))));
});
}
#[test]
fn m_sends_show_move_dialog() {
use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
use tokio::sync::mpsc;
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let path = VaultPath::new("notes/test.md");
let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
let mut dialog = FileOpsMenuDialog::new(path.clone());
let key = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE);
let state = dialog.handle_key(key, &tx);
assert_eq!(state, EventState::Consumed);
let event = rx
.try_recv()
.expect("expected AppEvent::FileOp(FileOp::ShowMove)");
assert!(matches!(event, AppEvent::FileOp(FileOp::ShowMove(_))));
});
}
}