use crate::app::{App, AppCommand, AppMode, TorrentControlState};
use crate::integrations::control::ControlRequest;
use crate::tui::formatters::{centered_rect, sanitize_text};
use crate::tui::screen_context::ScreenContext;
use ratatui::crossterm::event::{Event as CrosstermEvent, KeyCode};
use ratatui::layout::{Alignment, Constraint, Layout};
use ratatui::prelude::{Frame, Line, Span, Style, Stylize};
use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph, Wrap};
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum DeleteConfirmAction {
Confirm,
Cancel,
}
#[derive(Clone, Debug, PartialEq)]
pub enum DeleteConfirmEffect {
SendManagerCommand {
info_hash: Vec<u8>,
with_files: bool,
},
MarkDeleting {
info_hash: Vec<u8>,
},
ToNormal,
}
#[derive(Default)]
pub struct DeleteConfirmReduceResult {
pub consumed: bool,
pub effects: Vec<DeleteConfirmEffect>,
}
fn map_key_to_delete_confirm_action(key_code: KeyCode) -> Option<DeleteConfirmAction> {
match key_code {
KeyCode::Char('Y') => Some(DeleteConfirmAction::Confirm),
KeyCode::Esc => Some(DeleteConfirmAction::Cancel),
_ => None,
}
}
pub fn reduce_delete_confirm_action(
app_state: &crate::app::AppState,
action: DeleteConfirmAction,
) -> DeleteConfirmReduceResult {
match action {
DeleteConfirmAction::Cancel => DeleteConfirmReduceResult {
consumed: true,
effects: vec![DeleteConfirmEffect::ToNormal],
},
DeleteConfirmAction::Confirm => {
let info_hash = app_state.ui.delete_confirm.info_hash.clone();
let with_files = app_state.ui.delete_confirm.with_files;
DeleteConfirmReduceResult {
consumed: true,
effects: vec![
DeleteConfirmEffect::SendManagerCommand {
info_hash: info_hash.clone(),
with_files,
},
DeleteConfirmEffect::MarkDeleting { info_hash },
DeleteConfirmEffect::ToNormal,
],
}
}
}
}
pub fn draw(f: &mut Frame, screen: &ScreenContext<'_>) {
let app_state = screen.ui;
let ctx = screen.theme;
if !matches!(app_state.mode, AppMode::DeleteConfirm) {
return;
}
let info_hash = &app_state.ui.delete_confirm.info_hash;
let with_files = app_state.ui.delete_confirm.with_files;
if let Some(torrent_to_delete) = app_state.torrents.get(info_hash) {
let terminal_area = f.area();
let rect_width = if terminal_area.width < 60 { 90 } else { 50 };
let rect_height = if terminal_area.height < 20 { 95 } else { 18 };
let area = centered_rect(rect_width, rect_height, terminal_area);
f.render_widget(Clear, area);
let vert_padding = if area.height < 10 { 0 } else { 1 };
let block = Block::default()
.borders(Borders::ALL)
.border_style(ctx.apply(Style::default().fg(ctx.state_error())))
.padding(Padding::new(2, 2, vert_padding, vert_padding));
let inner_area = block.inner(area);
f.render_widget(block, area);
let chunks = Layout::vertical([
Constraint::Length(2),
Constraint::Min(0),
Constraint::Length(1),
Constraint::Length(1),
])
.split(inner_area);
let name = sanitize_text(&torrent_to_delete.latest_state.torrent_name);
let path = torrent_to_delete
.latest_state
.download_path
.as_ref()
.map(|p| sanitize_text(&p.to_string_lossy()))
.unwrap_or_else(|| "Unknown Path".to_string());
f.render_widget(
Paragraph::new(vec![
Line::from(Span::styled(
name,
ctx.apply(Style::default().fg(ctx.state_warning()).bold().underlined()),
)),
Line::from(Span::styled(
path,
ctx.apply(Style::default().fg(ctx.theme.semantic.text)),
)),
])
.alignment(Alignment::Center),
chunks[0],
);
if chunks[1].height > 0 {
let body = if with_files {
vec![
Line::from(""),
Line::from(Span::styled(
"⚠️ PERMANENT TORRENT FILES DELETION ON ⚠️",
ctx.apply(Style::default().fg(ctx.state_error()).bold()),
)),
Line::from(vec![
Span::raw("All local data for this torrent will be "),
Span::styled(
"ERASED",
ctx.apply(Style::default().fg(ctx.state_error()).bold().underlined()),
),
]),
]
} else {
vec![
Line::from(""),
Line::from(Span::styled(
"Safe Removal (Files Kept)",
ctx.apply(Style::default().fg(ctx.state_success())),
)),
Line::from(vec![
Span::raw("Use "),
Span::styled(
"[D]",
ctx.apply(Style::default().fg(ctx.state_warning()).bold()),
),
Span::raw(" to remove files..."),
]),
]
};
f.render_widget(
Paragraph::new(body)
.alignment(Alignment::Center)
.wrap(Wrap { trim: true }),
chunks[1],
);
}
let actions = Line::from(vec![
Span::styled(
"[Y]",
ctx.apply(Style::default().fg(ctx.state_success()).bold()),
),
Span::raw(" Confirm "),
Span::styled("[Esc]", ctx.apply(Style::default().fg(ctx.state_error()))),
Span::raw(" Cancel"),
]);
f.render_widget(
Paragraph::new(actions).alignment(Alignment::Center),
chunks[3],
);
}
}
pub fn handle_event(event: CrosstermEvent, app: &mut App) -> bool {
if let CrosstermEvent::Key(key) = event {
if let Some(action) = map_key_to_delete_confirm_action(key.code) {
let reduced = reduce_delete_confirm_action(&app.app_state, action);
for effect in reduced.effects {
match effect {
DeleteConfirmEffect::SendManagerCommand {
info_hash,
with_files,
} => {
let _ = app
.app_command_tx
.try_send(AppCommand::SubmitControlRequest(ControlRequest::Delete {
info_hash_hex: hex::encode(info_hash),
delete_files: with_files,
}));
}
DeleteConfirmEffect::MarkDeleting { info_hash } => {
if !app.is_current_shared_follower() {
if let Some(torrent) = app.app_state.torrents.get_mut(&info_hash) {
torrent.latest_state.torrent_control_state =
TorrentControlState::Deleting;
torrent.latest_state.delete_files =
app.app_state.ui.delete_confirm.with_files;
}
}
}
DeleteConfirmEffect::ToNormal => {
app.app_state.mode = AppMode::Normal;
}
}
}
return reduced.consumed;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::{AppMode, AppState};
#[test]
fn keymap_uses_shift_y_for_confirm() {
assert_eq!(
map_key_to_delete_confirm_action(KeyCode::Char('Y')),
Some(DeleteConfirmAction::Confirm)
);
assert_eq!(map_key_to_delete_confirm_action(KeyCode::Enter), None);
}
#[test]
fn reducer_cancel_closes_without_effects() {
let app_state = AppState::default();
let out = reduce_delete_confirm_action(&app_state, DeleteConfirmAction::Cancel);
assert!(out.consumed);
assert_eq!(out.effects, vec![DeleteConfirmEffect::ToNormal]);
}
#[test]
fn reducer_confirm_emits_command_and_mark_deleting() {
let app_state = AppState {
mode: AppMode::DeleteConfirm,
ui: crate::app::UiState {
delete_confirm: crate::app::DeleteConfirmUiState {
info_hash: b"abc".to_vec(),
with_files: true,
},
..Default::default()
},
..Default::default()
};
let out = reduce_delete_confirm_action(&app_state, DeleteConfirmAction::Confirm);
assert!(out.consumed);
assert_eq!(out.effects.len(), 3);
assert!(matches!(
out.effects[0],
DeleteConfirmEffect::SendManagerCommand {
ref info_hash,
with_files: true
} if info_hash == b"abc"
));
assert!(matches!(
out.effects[1],
DeleteConfirmEffect::MarkDeleting { ref info_hash } if info_hash == b"abc"
));
assert!(matches!(out.effects[2], DeleteConfirmEffect::ToNormal));
}
}