use crate::app::{App, AppMode};
use crate::tui::paste_burst::FlushResult as PasteBurstFlushResult;
use crate::tui::screens::{
browser, config, delete_confirm, help, journal, normal, power, rss, welcome,
};
use ratatui::crossterm::event::{
Event as CrosstermEvent, KeyCode, KeyEvent, KeyEventKind, KeyModifiers,
};
use ratatui::prelude::Rect;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
static GLOBAL_ESC_TIMESTAMP: AtomicU64 = AtomicU64::new(0);
pub async fn handle_event(event: CrosstermEvent, app: &mut App) {
handle_event_at(event, app, Instant::now()).await;
}
pub async fn flush_pending_paste_burst(app: &mut App) {
flush_pending_paste_burst_at(app, Instant::now()).await;
}
async fn handle_event_at(event: CrosstermEvent, app: &mut App, now: Instant) {
let translated = translate_event(event, app, now);
if translated.is_empty() {
return;
}
for event in translated {
apply_event(event, app).await;
}
app.app_state.ui.needs_redraw = true;
}
async fn flush_pending_paste_burst_at(app: &mut App, now: Instant) {
let translated = flush_due_events(app, now);
if translated.is_empty() {
return;
}
for event in translated {
apply_event(event, app).await;
}
app.app_state.ui.needs_redraw = true;
}
fn translate_event(event: CrosstermEvent, app: &mut App, now: Instant) -> Vec<CrosstermEvent> {
let mut translated = Vec::new();
if should_ignore_event_for_paste_burst(&event) {
return translated;
}
let buffered_key = match &event {
CrosstermEvent::Key(key) if should_buffer_paste_burst_key(app, *key) => Some(*key),
_ => None,
};
if let Some(key) = buffered_key {
let flush = app.app_state.ui.normal_paste_burst.push_key(key, now);
translated.extend(convert_burst_flush(flush));
return translated;
}
if app.app_state.ui.normal_paste_burst.has_pending() {
let flush = app
.app_state
.ui
.normal_paste_burst
.flush_now(normal::accepts_pasted_text);
translated.extend(convert_burst_flush(flush));
}
translated.push(event);
translated
}
fn flush_due_events(app: &mut App, now: Instant) -> Vec<CrosstermEvent> {
let flush = app
.app_state
.ui
.normal_paste_burst
.flush_if_due(now, normal::accepts_pasted_text);
convert_burst_flush(flush)
}
fn convert_burst_flush(flush: PasteBurstFlushResult) -> Vec<CrosstermEvent> {
match flush {
PasteBurstFlushResult::None | PasteBurstFlushResult::Buffered => Vec::new(),
PasteBurstFlushResult::Text(text) => vec![CrosstermEvent::Paste(text)],
PasteBurstFlushResult::Keys(keys) => keys.into_iter().map(CrosstermEvent::Key).collect(),
}
}
fn should_buffer_paste_burst_key(app: &App, key: KeyEvent) -> bool {
matches!(app.app_state.mode, AppMode::Normal | AppMode::Welcome)
&& !app.app_state.ui.is_searching
&& matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat)
&& matches!(key.code, KeyCode::Char(_))
&& !key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT)
}
fn should_ignore_event_for_paste_burst(event: &CrosstermEvent) -> bool {
matches!(
event,
CrosstermEvent::Key(KeyEvent {
kind: KeyEventKind::Release,
..
})
)
}
async fn apply_event(event: CrosstermEvent, app: &mut App) {
if handle_resize_event(&event, app) {
return;
}
if should_quit_on_ctrl_c(&event, app) {
return;
}
if should_debounce_escape(&event) {
return;
}
if matches!(app.app_state.mode, AppMode::FileBrowser) {
browser::handle_event(event, app).await;
app.app_state.ui.needs_redraw = true;
return;
}
dispatch_mode_event(event, app).await;
}
fn should_quit_on_ctrl_c(event: &CrosstermEvent, app: &mut App) -> bool {
if let CrosstermEvent::Key(key) = event {
if key.kind == KeyEventKind::Press
&& key.code == KeyCode::Char('c')
&& key.modifiers.contains(KeyModifiers::CONTROL)
{
app.app_state.should_quit = true;
app.app_state.ui.needs_redraw = true;
return true;
}
}
false
}
fn handle_resize_event(event: &CrosstermEvent, app: &mut App) -> bool {
if let CrosstermEvent::Resize(w, h) = event {
app.app_state.screen_area = Rect::new(0, 0, *w, *h);
app.app_state.ui.needs_redraw = true;
return true;
}
false
}
fn should_debounce_escape(event: &CrosstermEvent) -> bool {
if let CrosstermEvent::Key(key) = event {
if key.kind == KeyEventKind::Press && key.code == KeyCode::Esc {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let last = GLOBAL_ESC_TIMESTAMP.load(Ordering::Relaxed);
if now.saturating_sub(last) < 200 {
return true;
}
GLOBAL_ESC_TIMESTAMP.store(now, Ordering::Relaxed);
}
}
false
}
async fn dispatch_mode_event(event: CrosstermEvent, app: &mut App) {
match app.app_state.mode {
AppMode::Help => {
help::handle_event(event, &mut app.app_state);
}
AppMode::Journal => {
journal::handle_event(event, &mut app.app_state, &app.app_command_tx);
}
AppMode::Welcome => {
welcome::handle_event(event, &mut app.app_state);
}
AppMode::Normal => normal::handle_event(event, app).await,
AppMode::PowerSaving => power::handle_event(event, &mut app.app_state),
AppMode::Config => {
config::handle_event(
event,
config::ConfigHandleContext {
mode: &mut app.app_state.mode,
settings_edit: &mut app.app_state.ui.config.settings_edit,
selected_index: &mut app.app_state.ui.config.selected_index,
items: app.app_state.ui.config.items.as_mut_slice(),
editing: &mut app.app_state.ui.config.editing,
app_command_tx: &app.app_command_tx,
global_dl_bucket: &app.global_dl_bucket,
global_ul_bucket: &app.global_ul_bucket,
},
);
}
AppMode::DeleteConfirm => {
let _ = delete_confirm::handle_event(event, app);
}
AppMode::Rss => {
rss::handle_event(
event,
&mut app.app_state,
&app.client_configs,
&app.app_command_tx,
);
}
AppMode::FileBrowser => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::{
AppState, FilePriority, PeerInfo, SelectedHeader, TorrentDisplayState, TorrentMetrics,
TorrentPreviewPayload,
};
use crate::config::Settings;
use crate::tui::paste_burst::PasteBurst;
use crate::tui::tree::RawNode;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::path::PathBuf;
use std::time::Instant;
fn create_mock_metrics(peer_count: usize) -> TorrentMetrics {
let mut metrics = TorrentMetrics::default();
let mut peers = Vec::new();
for i in 0..peer_count {
peers.push(PeerInfo {
address: format!("127.0.0.1:{}", 6881 + i),
..Default::default()
});
}
metrics.peers = peers;
metrics
}
fn create_mock_display_state(peer_count: usize) -> TorrentDisplayState {
TorrentDisplayState {
latest_state: create_mock_metrics(peer_count),
..Default::default()
}
}
fn create_test_app_state() -> AppState {
let mut app_state = AppState {
screen_area: ratatui::layout::Rect::new(0, 0, 200, 100),
..Default::default()
};
let torrent_a = create_mock_display_state(2); let torrent_b = create_mock_display_state(0);
app_state
.torrents
.insert("hash_a".as_bytes().to_vec(), torrent_a);
app_state
.torrents
.insert("hash_b".as_bytes().to_vec(), torrent_b);
app_state.torrent_list_order =
vec!["hash_a".as_bytes().to_vec(), "hash_b".as_bytes().to_vec()];
app_state
}
async fn build_test_app() -> App {
let settings = Settings {
client_port: 0,
..Settings::default()
};
let mut app = App::new(settings, crate::app::AppRuntimeMode::Normal)
.await
.expect("build app");
app.app_state.mode = AppMode::Normal;
app
}
#[test]
fn test_nav_down_torrents() {
let mut app_state = create_test_app_state();
app_state.ui.selected_torrent_index = 0;
app_state.ui.selected_header = SelectedHeader::Torrent(0);
normal::handle_navigation(&mut app_state, KeyCode::Down);
assert_eq!(app_state.ui.selected_torrent_index, 1);
assert_eq!(app_state.ui.selected_peer_index, 0); }
#[test]
fn test_nav_up_torrents() {
let mut app_state = create_test_app_state();
app_state.ui.selected_torrent_index = 1;
app_state.ui.selected_header = SelectedHeader::Torrent(0);
normal::handle_navigation(&mut app_state, KeyCode::Up);
assert_eq!(app_state.ui.selected_torrent_index, 0);
assert_eq!(app_state.ui.selected_peer_index, 0); }
#[test]
fn test_nav_down_peers() {
let mut app_state = create_test_app_state();
app_state.ui.selected_torrent_index = 0; app_state.ui.selected_peer_index = 0;
app_state.ui.selected_header = SelectedHeader::Peer(0);
normal::handle_navigation(&mut app_state, KeyCode::Down);
assert_eq!(app_state.ui.selected_torrent_index, 0); assert_eq!(app_state.ui.selected_peer_index, 1); }
#[test]
fn test_nav_right_to_peers_when_peers_exist() {
let mut app_state = create_test_app_state();
app_state.ui.selected_torrent_index = 0; app_state.ui.selected_header = SelectedHeader::Torrent(99);
normal::handle_navigation(&mut app_state, KeyCode::Right);
assert_eq!(app_state.ui.selected_header, SelectedHeader::Peer(0));
}
#[test]
fn test_nav_right_to_peers_when_no_peers() {
let mut app_state = create_test_app_state();
app_state.ui.selected_torrent_index = 1; app_state.ui.selected_header = SelectedHeader::Torrent(99);
normal::handle_navigation(&mut app_state, KeyCode::Right);
assert_eq!(app_state.ui.selected_header, SelectedHeader::Torrent(0));
}
#[test]
fn test_nav_left_from_peers() {
let mut app_state = create_test_app_state();
app_state.ui.selected_torrent_index = 0;
app_state.ui.selected_header = SelectedHeader::Peer(0);
normal::handle_navigation(&mut app_state, KeyCode::Left);
assert_eq!(app_state.ui.selected_header, SelectedHeader::Torrent(0));
}
#[test]
fn test_nav_up_peers() {
let mut app_state = create_test_app_state();
app_state.ui.selected_torrent_index = 0; app_state.ui.selected_peer_index = 1;
app_state.ui.selected_header = SelectedHeader::Peer(0);
normal::handle_navigation(&mut app_state, KeyCode::Up);
assert_eq!(app_state.ui.selected_torrent_index, 0); assert_eq!(app_state.ui.selected_peer_index, 0); }
#[test]
fn test_nav_up_at_top_of_list() {
let mut app_state = create_test_app_state();
app_state.ui.selected_torrent_index = 0; app_state.ui.selected_header = SelectedHeader::Torrent(0);
normal::handle_navigation(&mut app_state, KeyCode::Up);
assert_eq!(app_state.ui.selected_torrent_index, 0);
}
#[test]
fn test_nav_down_at_bottom_of_list() {
let mut app_state = create_test_app_state();
app_state.ui.selected_torrent_index = 1; app_state.ui.selected_header = SelectedHeader::Torrent(0);
normal::handle_navigation(&mut app_state, KeyCode::Down);
assert_eq!(app_state.ui.selected_torrent_index, 1);
}
#[test]
fn test_nav_up_peers_at_top_of_list() {
let mut app_state = create_test_app_state();
app_state.ui.selected_torrent_index = 0; app_state.ui.selected_peer_index = 0; app_state.ui.selected_header = SelectedHeader::Peer(0);
normal::handle_navigation(&mut app_state, KeyCode::Up);
assert_eq!(app_state.ui.selected_peer_index, 0);
}
#[test]
fn test_nav_down_peers_at_bottom_of_list() {
let mut app_state = create_test_app_state();
app_state.ui.selected_torrent_index = 0; app_state.ui.selected_peer_index = 1; app_state.ui.selected_header = SelectedHeader::Peer(0);
normal::handle_navigation(&mut app_state, KeyCode::Down);
assert_eq!(app_state.ui.selected_peer_index, 1);
}
#[test]
fn test_nav_right_jumps_to_peers_when_only_name_column_visible() {
let mut app_state = create_test_app_state();
app_state.ui.selected_torrent_index = 0;
app_state.ui.selected_header = SelectedHeader::Torrent(0);
if let Some(torrent) = app_state.torrents.get_mut("hash_a".as_bytes()) {
torrent.latest_state.activity_message = "Seeding".to_string();
torrent.latest_state.number_of_pieces_total = 100;
torrent.latest_state.number_of_pieces_completed = 100;
}
for torrent in app_state.torrents.values_mut() {
torrent.smoothed_download_speed_bps = 0;
torrent.smoothed_upload_speed_bps = 0;
}
normal::handle_navigation(&mut app_state, KeyCode::Right);
assert_eq!(app_state.ui.selected_header, SelectedHeader::Peer(0));
}
#[test]
fn test_apply_priority_action_cycles_target_and_children() {
let mut nodes = vec![RawNode {
name: "root".to_string(),
full_path: PathBuf::from("root"),
is_dir: true,
payload: TorrentPreviewPayload::default(),
children: vec![RawNode {
name: "leaf.bin".to_string(),
full_path: PathBuf::from("root/leaf.bin"),
is_dir: false,
payload: TorrentPreviewPayload::default(),
children: vec![],
}],
}];
let changed = browser::apply_priority_cycle(&mut nodes, &PathBuf::from("root"));
assert!(changed);
assert_eq!(nodes[0].payload.priority, FilePriority::Skip);
assert_eq!(nodes[0].children[0].payload.priority, FilePriority::Skip);
}
#[test]
fn test_apply_priority_action_returns_false_for_missing_path() {
let mut nodes = vec![RawNode {
name: "root".to_string(),
full_path: PathBuf::from("root"),
is_dir: true,
payload: TorrentPreviewPayload::default(),
children: vec![],
}];
let changed = browser::apply_priority_cycle(&mut nodes, &PathBuf::from("missing"));
assert!(!changed);
assert_eq!(nodes[0].payload.priority, FilePriority::Normal);
}
#[test]
fn test_escape_debounce_ignores_non_escape_keys() {
GLOBAL_ESC_TIMESTAMP.store(0, Ordering::Relaxed);
let event = CrosstermEvent::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
assert!(!should_debounce_escape(&event));
}
#[test]
fn test_escape_debounce_blocks_rapid_second_escape() {
GLOBAL_ESC_TIMESTAMP.store(0, Ordering::Relaxed);
let event = CrosstermEvent::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(!should_debounce_escape(&event));
assert!(should_debounce_escape(&event));
}
#[tokio::test]
async fn single_shortcut_replays_after_burst_timeout() {
let mut app = build_test_app().await;
let start = Instant::now();
handle_event_at(
CrosstermEvent::Key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)),
&mut app,
start,
)
.await;
assert!(matches!(app.app_state.mode, AppMode::Normal));
let translated = flush_due_events(&mut app, start + PasteBurst::flush_delay());
assert!(matches!(translated.as_slice(), [CrosstermEvent::Key(_)]));
let _ = app.shutdown_tx.send(());
}
#[tokio::test]
async fn supported_burst_flushes_as_synthetic_paste() {
let mut app = build_test_app().await;
let start = Instant::now();
let magnet = "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567";
for (offset, ch) in magnet.chars().enumerate() {
handle_event_at(
CrosstermEvent::Key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)),
&mut app,
start + std::time::Duration::from_millis(offset as u64),
)
.await;
}
let translated = flush_due_events(
&mut app,
start
+ std::time::Duration::from_millis((magnet.len() - 1) as u64)
+ PasteBurst::flush_delay(),
);
assert!(matches!(translated.as_slice(), [CrosstermEvent::Paste(text)] if text == magnet));
assert!(matches!(app.app_state.mode, AppMode::Normal));
let _ = app.shutdown_tx.send(());
}
#[tokio::test]
async fn welcome_screen_paste_burst_flushes_as_synthetic_paste() {
let mut app = build_test_app().await;
app.app_state.mode = AppMode::Welcome;
let start = Instant::now();
let magnet = "magnet:?xt=urn:btih:fedcba9876543210fedcba9876543210fedcba98";
for (offset, ch) in magnet.chars().enumerate() {
handle_event_at(
CrosstermEvent::Key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)),
&mut app,
start + std::time::Duration::from_millis(offset as u64),
)
.await;
}
let translated = flush_due_events(
&mut app,
start
+ std::time::Duration::from_millis((magnet.len() - 1) as u64)
+ PasteBurst::flush_delay(),
);
assert!(matches!(translated.as_slice(), [CrosstermEvent::Paste(text)] if text == magnet));
assert!(matches!(app.app_state.mode, AppMode::Welcome));
let _ = app.shutdown_tx.send(());
}
#[tokio::test]
async fn unsupported_burst_replays_original_keys() {
let mut app = build_test_app().await;
let start = Instant::now();
for (offset, ch) in ['j', 'j'].into_iter().enumerate() {
handle_event_at(
CrosstermEvent::Key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)),
&mut app,
start + std::time::Duration::from_millis(offset as u64),
)
.await;
}
let translated = flush_due_events(
&mut app,
start + std::time::Duration::from_millis(1) + PasteBurst::flush_delay(),
);
assert!(matches!(
translated.as_slice(),
[CrosstermEvent::Key(_), CrosstermEvent::Key(_)]
));
let _ = app.shutdown_tx.send(());
}
#[tokio::test]
async fn explicit_paste_bypasses_pending_burst() {
let mut app = build_test_app().await;
let start = Instant::now();
handle_event_at(
CrosstermEvent::Key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)),
&mut app,
start,
)
.await;
let translated = translate_event(
CrosstermEvent::Paste(
"magnet:?xt=urn:btih:fedcba9876543210fedcba9876543210fedcba98".to_string(),
),
&mut app,
start + std::time::Duration::from_millis(1),
);
assert!(matches!(
translated.as_slice(),
[CrosstermEvent::Key(_), CrosstermEvent::Paste(_)]
));
let _ = app.shutdown_tx.send(());
}
#[tokio::test]
async fn explicit_paste_on_welcome_screen_is_ignored() {
let mut app = build_test_app().await;
app.app_state.mode = AppMode::Welcome;
let magnet = "magnet:?xt=urn:btih:00112233445566778899aabbccddeeff00112233";
handle_event_at(
CrosstermEvent::Paste(magnet.to_string()),
&mut app,
Instant::now(),
)
.await;
assert!(matches!(app.app_state.mode, AppMode::Welcome));
assert!(app.app_state.pending_torrent_link.is_empty());
let _ = app.shutdown_tx.send(());
}
#[tokio::test]
async fn release_events_are_ignored_by_translation() {
let mut app = build_test_app().await;
app.app_state.mode = AppMode::Help;
let translated = translate_event(
CrosstermEvent::Key(KeyEvent::new_with_kind(
KeyCode::Char('m'),
KeyModifiers::NONE,
KeyEventKind::Release,
)),
&mut app,
Instant::now(),
);
assert!(translated.is_empty());
let _ = app.shutdown_tx.send(());
}
}