diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 0000000..411bd7a --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"044a4cde-2737-40a2-bf84-2dfeec22370d","pid":22026,"procStart":"Mon Jun 29 01:38:34 2026","acquiredAt":1782711515915} \ No newline at end of file diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..1fbebf1 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,12 @@ +{ + "permissions": { + "allow": [ + "Bash(*)", + "Read(*)", + "Write(*)", + "Edit(*)", + "Monitor(*)", + "WebFetch(*)" + ] + } +} diff --git a/Cargo.lock b/Cargo.lock index 2f22748..d4cfb10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "cc" -version = "1.2.61" +version = "1.2.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" dependencies = [ "find-msvc-tools", "shlex", @@ -46,9 +46,9 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "indexmap" @@ -75,9 +75,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "proc-macro2" @@ -90,16 +90,16 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] [[package]] name = "ruf4" -version = "0.0.5" +version = "0.1.0" dependencies = [ "embed-resource", "libc", @@ -164,9 +164,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "stdext" @@ -177,9 +177,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -341,9 +341,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" [[package]] name = "winreg" diff --git a/Cargo.toml b/Cargo.toml index 5a12ee6..99e51da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ resolver = "2" edition = "2024" license = "MIT" repository = "https://github.com/kromych/ruf4" -rust-version = "1.93" +rust-version = "1.96" [profile.release] codegen-units = 1 diff --git a/ReadMe.md b/ReadMe.md index be9dde4..30d6634 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -115,7 +115,6 @@ If you are a developer, [here](./ReleaseFlow.md) are the gory details and notes | F2 | Save settings | | F9 | Focus menubar | | F10 | Quit (with confirmation) | -| F12 | Reopen last shell output | | Any letter | Activate command line | ### macOS alternatives @@ -145,15 +144,10 @@ Commands run in the active panel's directory. | Escape | Cancel | | Backspace | Delete character | -### Shell output - -After running a command, the output is shown in a scrollable dialog. -Press F12 to reopen the last output at any time. - -| Key | Action | -|-----|--------| -| Ctrl+C | Copy output to clipboard | -| Esc / Enter | Close | +Commands run in the foreground with the terminal handed back to them, so +interactive programs (a shell, `python`, `vim`, `less`) work normally. The +panel display is restored when the command exits; press Enter at the prompt to +return. ### Dialogs @@ -170,7 +164,7 @@ Most confirmation dialogs respond to: - Click a panel to make it active - Click a file entry to select it - Double-click to enter a directory or open a file -- Scroll wheel to navigate +- Scroll wheel to navigate; over the quick view panel it scrolls the preview - Click the function key bar at the bottom for quick access ### Clicking around diff --git a/crates/lsh/src/compiler/generator.rs b/crates/lsh/src/compiler/generator.rs index 7e9e48a..31d6fb6 100644 --- a/crates/lsh/src/compiler/generator.rs +++ b/crates/lsh/src/compiler/generator.rs @@ -224,7 +224,9 @@ impl TryFrom for HighlightKind {{ _ = writeln!( output, " Language {{ id: {:?}, name: {:?}, entrypoint: {} }},", - ep.name, ep.display_name, ep.address + ep.name.replace('_', "-"), + ep.display_name, + ep.address, ); } output.push_str("];\n"); diff --git a/crates/lsh/src/lib.rs b/crates/lsh/src/lib.rs index 8f914e2..328fbcd 100644 --- a/crates/lsh/src/lib.rs +++ b/crates/lsh/src/lib.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Welcome to Leonard's Syntax Highlighter (LSH), otherwise known as +//! Welcome to the Lightweight Syntax Highlighter (LSH), otherwise known as //! Leonard's Shitty Highlighter, which is really what it is. //! //! ## Architecture diff --git a/crates/ruf4/Cargo.toml b/crates/ruf4/Cargo.toml index dcf22aa..138164d 100644 --- a/crates/ruf4/Cargo.toml +++ b/crates/ruf4/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruf4" -version = "0.0.5" +version = "0.1.0" description = "A dual-panel file commander" edition.workspace = true diff --git a/crates/ruf4/src/action.rs b/crates/ruf4/src/action.rs index 20c06b3..8054be5 100644 --- a/crates/ruf4/src/action.rs +++ b/crates/ruf4/src/action.rs @@ -48,7 +48,6 @@ pub enum Action { ChooseSort, // App - LastOutput, Help, SaveSettings, Refresh, @@ -59,6 +58,46 @@ pub enum Action { Quit, } +/// Every [`Action`] value, used to derive [`parse_action`] from [`action_str`] +/// and to drive round-trip tests. Keep in step with the [`Action`] enum. +pub const ALL_ACTIONS: &[Action] = &[ + Action::CursorUp, + Action::CursorDown, + Action::PageUp, + Action::PageDown, + Action::CursorHome, + Action::CursorEnd, + Action::OpenOrEnter, + Action::ParentDir, + Action::SwitchPanel, + Action::ToggleSelect, + Action::SelectGroup, + Action::DeselectGroup, + Action::InvertSelection, + Action::SelectAll, + Action::DeselectAll, + Action::Copy, + Action::Move, + Action::Rename, + Action::Delete, + Action::MkDir, + Action::ToggleQuickView, + Action::ToggleHidden, + Action::SortBy(SortBy::Name), + Action::SortBy(SortBy::Extension), + Action::SortBy(SortBy::Modified), + Action::SortBy(SortBy::Size), + Action::ChooseSort, + Action::Help, + Action::SaveSettings, + Action::Refresh, + Action::ChangeRoot, + Action::DirHistory, + Action::CmdHistory, + Action::FocusMenu, + Action::Quit, +]; + impl Action { /// "Immediate" actions are dispatched from the keyboard handler in /// `state.rs`. Everything else is dispatched via `consume_shortcut` @@ -136,10 +175,6 @@ pub fn default_bindings() -> Vec { key: vk::F10, action: Action::Quit, }, - Binding { - key: vk::F12, - action: Action::LastOutput, - }, // Ctrl combinations (cross-platform) Binding { key: kbmod::CTRL | vk::Q, @@ -289,6 +324,67 @@ pub fn key_for(bindings: &[Binding], action: Action) -> InputKey { .unwrap_or(vk::NULL) } +// ── Key name table ───────────────────────────────────────────────────────── + +/// Base keys and their human-readable names. Single source of truth for both +/// [`key_display_name`] and [`parse_key_name`], so the two directions cannot +/// drift apart. (Modifier prefixes like `Ctrl+` are handled separately.) +const KEY_NAMES: &[(InputKey, &str)] = &[ + (vk::F1, "F1"), + (vk::F2, "F2"), + (vk::F3, "F3"), + (vk::F4, "F4"), + (vk::F5, "F5"), + (vk::F6, "F6"), + (vk::F7, "F7"), + (vk::F8, "F8"), + (vk::F9, "F9"), + (vk::F10, "F10"), + (vk::F11, "F11"), + (vk::F12, "F12"), + (vk::UP, "Up"), + (vk::DOWN, "Down"), + (vk::LEFT, "Left"), + (vk::RIGHT, "Right"), + (vk::PRIOR, "PgUp"), + (vk::NEXT, "PgDn"), + (vk::HOME, "Home"), + (vk::END, "End"), + (vk::RETURN, "Enter"), + (vk::ESCAPE, "Esc"), + (vk::TAB, "Tab"), + (vk::BACK, "Backspace"), + (vk::INSERT, "Ins"), + (vk::DELETE, "Delete"), + (vk::SPACE, "Space"), + (vk::A, "A"), + (vk::B, "B"), + (vk::C, "C"), + (vk::D, "D"), + (vk::E, "E"), + (vk::F, "F"), + (vk::G, "G"), + (vk::H, "H"), + (vk::I, "I"), + (vk::J, "J"), + (vk::K, "K"), + (vk::L, "L"), + (vk::M, "M"), + (vk::N, "N"), + (vk::O, "O"), + (vk::P, "P"), + (vk::Q, "Q"), + (vk::R, "R"), + (vk::S, "S"), + (vk::T, "T"), + (vk::U, "U"), + (vk::V, "V"), + (vk::W, "W"), + (vk::X, "X"), + (vk::Y, "Y"), + (vk::Z, "Z"), +]; + // ── Help text generation ─────────────────────────────────────────────────── /// Format a key for display (e.g. "Ctrl+F3", "F5", "Tab"). @@ -307,192 +403,71 @@ pub fn key_display_name(key: InputKey) -> String { prefix.push_str("Shift+"); } - let name: &str = match base { - k if k == vk::F1 => "F1", - k if k == vk::F2 => "F2", - k if k == vk::F3 => "F3", - k if k == vk::F4 => "F4", - k if k == vk::F5 => "F5", - k if k == vk::F6 => "F6", - k if k == vk::F7 => "F7", - k if k == vk::F8 => "F8", - k if k == vk::F9 => "F9", - k if k == vk::F10 => "F10", - k if k == vk::F11 => "F11", - k if k == vk::F12 => "F12", - k if k == vk::UP => "Up", - k if k == vk::DOWN => "Down", - k if k == vk::LEFT => "Left", - k if k == vk::RIGHT => "Right", - k if k == vk::PRIOR => "PgUp", - k if k == vk::NEXT => "PgDn", - k if k == vk::HOME => "Home", - k if k == vk::END => "End", - k if k == vk::RETURN => "Enter", - k if k == vk::ESCAPE => "Esc", - k if k == vk::TAB => "Tab", - k if k == vk::BACK => "Backspace", - k if k == vk::INSERT => "Ins", - k if k == vk::DELETE => "Delete", - k if k == vk::SPACE => "Space", - k if k == vk::A => "A", - k if k == vk::B => "B", - k if k == vk::C => "C", - k if k == vk::D => "D", - k if k == vk::E => "E", - k if k == vk::F => "F", - k if k == vk::G => "G", - k if k == vk::H => "H", - k if k == vk::I => "I", - k if k == vk::J => "J", - k if k == vk::K => "K", - k if k == vk::L => "L", - k if k == vk::M => "M", - k if k == vk::N => "N", - k if k == vk::O => "O", - k if k == vk::P => "P", - k if k == vk::Q => "Q", - k if k == vk::R => "R", - k if k == vk::S => "S", - k if k == vk::T => "T", - k if k == vk::U => "U", - k if k == vk::V => "V", - k if k == vk::W => "W", - k if k == vk::X => "X", - k if k == vk::Y => "Y", - k if k == vk::Z => "Z", - _ => return format!("{prefix}?"), - }; - format!("{prefix}{name}") + match KEY_NAMES.iter().find(|(k, _)| *k == base) { + Some((_, name)) => format!("{prefix}{name}"), + None => format!("{prefix}?"), + } } -/// Action display name for help text. -pub fn action_label(action: Action) -> &'static str { +/// The stable settings name and the human-readable label for an action. Single +/// exhaustive match so the compiler flags any new [`Action`] variant that is +/// missing here. [`action_str`] and [`action_label`] read from it. +fn action_meta(action: Action) -> (&'static str, &'static str) { match action { - Action::Help => "Help", - Action::SaveSettings => "Save settings", - Action::ToggleQuickView => "Toggle quick view", - Action::Rename => "Rename", - Action::Copy => "Copy", - Action::Move => "Rename / Move", - Action::MkDir => "Make directory", - Action::Delete => "Delete", - Action::FocusMenu => "Focus menubar", - Action::Quit => "Quit", - Action::CursorUp => "Navigate up", - Action::CursorDown => "Navigate down", - Action::PageUp => "Page up", - Action::PageDown => "Page down", - Action::CursorHome => "First entry", - Action::CursorEnd => "Last entry", - Action::OpenOrEnter => "Open / enter directory", - Action::SwitchPanel => "Switch panel", - Action::ParentDir => "Parent directory", - Action::ToggleSelect => "Toggle selection", - Action::SelectGroup => "Select group", - Action::DeselectGroup => "Deselect group", - Action::InvertSelection => "Invert selection", - Action::SelectAll => "Select all", - Action::DeselectAll => "Deselect all", - Action::ToggleHidden => "Toggle hidden files", - Action::SortBy(SortBy::Name) => "Sort by name", - Action::SortBy(SortBy::Extension) => "Sort by extension", - Action::SortBy(SortBy::Modified) => "Sort by date", - Action::SortBy(SortBy::Size) => "Sort by size", - Action::ChooseSort => "Choose sort mode", - Action::Refresh => "Refresh panels", - Action::ChangeRoot => "Change root", - Action::DirHistory => "Directory history", - Action::CmdHistory => "Command history", - Action::LastOutput => "Last output", + Action::CursorUp => ("cursor_up", "Navigate up"), + Action::CursorDown => ("cursor_down", "Navigate down"), + Action::PageUp => ("page_up", "Page up"), + Action::PageDown => ("page_down", "Page down"), + Action::CursorHome => ("cursor_home", "First entry"), + Action::CursorEnd => ("cursor_end", "Last entry"), + Action::OpenOrEnter => ("open_or_enter", "Open / enter directory"), + Action::ParentDir => ("parent_dir", "Parent directory"), + Action::SwitchPanel => ("switch_panel", "Switch panel"), + Action::ToggleSelect => ("toggle_select", "Toggle selection"), + Action::SelectGroup => ("select_group", "Select group"), + Action::DeselectGroup => ("deselect_group", "Deselect group"), + Action::InvertSelection => ("invert_selection", "Invert selection"), + Action::SelectAll => ("select_all", "Select all"), + Action::DeselectAll => ("deselect_all", "Deselect all"), + Action::Copy => ("copy", "Copy"), + Action::Move => ("move", "Rename / Move"), + Action::Rename => ("rename", "Rename"), + Action::Delete => ("delete", "Delete"), + Action::MkDir => ("mkdir", "Make directory"), + Action::ToggleQuickView => ("toggle_quick_view", "Toggle quick view"), + Action::ToggleHidden => ("toggle_hidden", "Toggle hidden files"), + Action::SortBy(SortBy::Name) => ("sort_by_name", "Sort by name"), + Action::SortBy(SortBy::Extension) => ("sort_by_extension", "Sort by extension"), + Action::SortBy(SortBy::Modified) => ("sort_by_modified", "Sort by date"), + Action::SortBy(SortBy::Size) => ("sort_by_size", "Sort by size"), + Action::ChooseSort => ("choose_sort", "Choose sort mode"), + Action::Help => ("help", "Help"), + Action::SaveSettings => ("save_settings", "Save settings"), + Action::Refresh => ("refresh", "Refresh panels"), + Action::ChangeRoot => ("change_root", "Change root"), + Action::DirHistory => ("dir_history", "Directory history"), + Action::CmdHistory => ("cmd_history", "Command history"), + Action::FocusMenu => ("focus_menu", "Focus menubar"), + Action::Quit => ("quit", "Quit"), } } +/// Action display name for help text. +pub fn action_label(action: Action) -> &'static str { + action_meta(action).1 +} + // ── Serialization helpers ───────────────────────────────────────────────── /// Stable string name for an action, used in settings files. pub fn action_str(action: Action) -> &'static str { - match action { - Action::CursorUp => "cursor_up", - Action::CursorDown => "cursor_down", - Action::PageUp => "page_up", - Action::PageDown => "page_down", - Action::CursorHome => "cursor_home", - Action::CursorEnd => "cursor_end", - Action::OpenOrEnter => "open_or_enter", - Action::ParentDir => "parent_dir", - Action::SwitchPanel => "switch_panel", - Action::ToggleSelect => "toggle_select", - Action::SelectGroup => "select_group", - Action::DeselectGroup => "deselect_group", - Action::InvertSelection => "invert_selection", - Action::SelectAll => "select_all", - Action::DeselectAll => "deselect_all", - Action::Copy => "copy", - Action::Move => "move", - Action::Rename => "rename", - Action::Delete => "delete", - Action::MkDir => "mkdir", - Action::ToggleQuickView => "toggle_quick_view", - Action::ToggleHidden => "toggle_hidden", - Action::SortBy(SortBy::Name) => "sort_by_name", - Action::SortBy(SortBy::Extension) => "sort_by_extension", - Action::SortBy(SortBy::Modified) => "sort_by_modified", - Action::SortBy(SortBy::Size) => "sort_by_size", - Action::ChooseSort => "choose_sort", - Action::Help => "help", - Action::SaveSettings => "save_settings", - Action::Refresh => "refresh", - Action::ChangeRoot => "change_root", - Action::DirHistory => "dir_history", - Action::CmdHistory => "cmd_history", - Action::FocusMenu => "focus_menu", - Action::Quit => "quit", - Action::LastOutput => "last_output", - } + action_meta(action).0 } /// Parse an action name from a settings file. Returns `None` for unknown names. +/// Inverse of [`action_str`], derived from it so the two cannot drift. pub fn parse_action(s: &str) -> Option { - Some(match s { - "cursor_up" => Action::CursorUp, - "cursor_down" => Action::CursorDown, - "page_up" => Action::PageUp, - "page_down" => Action::PageDown, - "cursor_home" => Action::CursorHome, - "cursor_end" => Action::CursorEnd, - "open_or_enter" => Action::OpenOrEnter, - "parent_dir" => Action::ParentDir, - "switch_panel" => Action::SwitchPanel, - "toggle_select" => Action::ToggleSelect, - "select_group" => Action::SelectGroup, - "deselect_group" => Action::DeselectGroup, - "invert_selection" => Action::InvertSelection, - "select_all" => Action::SelectAll, - "deselect_all" => Action::DeselectAll, - "copy" => Action::Copy, - "move" => Action::Move, - "rename" => Action::Rename, - "delete" => Action::Delete, - "mkdir" => Action::MkDir, - "toggle_quick_view" => Action::ToggleQuickView, - "toggle_hidden" => Action::ToggleHidden, - "sort_by_name" => Action::SortBy(SortBy::Name), - "sort_by_extension" => Action::SortBy(SortBy::Extension), - "sort_by_modified" => Action::SortBy(SortBy::Modified), - "sort_by_size" => Action::SortBy(SortBy::Size), - "choose_sort" => Action::ChooseSort, - "help" => Action::Help, - "save_settings" => Action::SaveSettings, - "refresh" => Action::Refresh, - "change_root" => Action::ChangeRoot, - "dir_history" => Action::DirHistory, - "cmd_history" => Action::CmdHistory, - "focus_menu" => Action::FocusMenu, - "quit" => Action::Quit, - "last_output" => Action::LastOutput, - _ => return None, - }) + ALL_ACTIONS.iter().copied().find(|&a| action_str(a) == s) } /// Parse a human-readable key name (e.g. "Ctrl+F3", "F5", "Tab") into an InputKey. @@ -518,62 +493,10 @@ pub fn parse_key_name(s: &str) -> Option { } } - let base = match remaining { - "F1" => vk::F1, - "F2" => vk::F2, - "F3" => vk::F3, - "F4" => vk::F4, - "F5" => vk::F5, - "F6" => vk::F6, - "F7" => vk::F7, - "F8" => vk::F8, - "F9" => vk::F9, - "F10" => vk::F10, - "F11" => vk::F11, - "F12" => vk::F12, - "Up" => vk::UP, - "Down" => vk::DOWN, - "Left" => vk::LEFT, - "Right" => vk::RIGHT, - "PgUp" => vk::PRIOR, - "PgDn" => vk::NEXT, - "Home" => vk::HOME, - "End" => vk::END, - "Enter" => vk::RETURN, - "Esc" => vk::ESCAPE, - "Tab" => vk::TAB, - "Backspace" => vk::BACK, - "Ins" => vk::INSERT, - "Delete" => vk::DELETE, - "Space" => vk::SPACE, - "A" => vk::A, - "B" => vk::B, - "C" => vk::C, - "D" => vk::D, - "E" => vk::E, - "F" => vk::F, - "G" => vk::G, - "H" => vk::H, - "I" => vk::I, - "J" => vk::J, - "K" => vk::K, - "L" => vk::L, - "M" => vk::M, - "N" => vk::N, - "O" => vk::O, - "P" => vk::P, - "Q" => vk::Q, - "R" => vk::R, - "S" => vk::S, - "T" => vk::T, - "U" => vk::U, - "V" => vk::V, - "W" => vk::W, - "X" => vk::X, - "Y" => vk::Y, - "Z" => vk::Z, - _ => return None, - }; + let base = KEY_NAMES + .iter() + .find(|(_, name)| *name == remaining) + .map(|(k, _)| *k)?; Some(base | mods) } @@ -594,7 +517,6 @@ pub fn build_help_text(bindings: &[Binding]) -> Vec<(String, &'static str, Actio Some(Action::Delete), Some(Action::FocusMenu), Some(Action::Quit), - Some(Action::LastOutput), None, // separator Some(Action::CursorUp), Some(Action::CursorDown), diff --git a/crates/ruf4/src/draw.rs b/crates/ruf4/src/draw.rs index b15af2f..ff4c441 100644 --- a/crates/ruf4/src/draw.rs +++ b/crates/ruf4/src/draw.rs @@ -928,11 +928,6 @@ fn draw_dialog(ctx: &mut Context, state: &mut State, theme: &Theme, size: Size) } Dialog::Info { message } => draw_info_dialog(ctx, theme, message, size), Dialog::Error { message } => draw_error_dialog(ctx, theme, message, size), - Dialog::ShellOutput { - command, - output, - scroll, - } => draw_shell_output_dialog(ctx, theme, command, output, *scroll, size), Dialog::ConfirmQuit { save_settings } => { draw_confirm_quit_dialog(ctx, theme, save_settings, size) } @@ -964,7 +959,97 @@ fn draw_dialog(ctx: &mut Context, state: &mut State, theme: &Theme, size: Size) is_copy, .. } => draw_confirm_overwrite_dialog(ctx, theme, target_name, *is_copy, size), + Dialog::Progress { + title, + current, + files_done, + files_total, + bytes_done, + bytes_total, + cancelling, + } => draw_progress_dialog( + ctx, + theme, + title, + current, + (*files_done, *files_total), + (*bytes_done, *bytes_total), + *cancelling, + size, + ), + } +} + +#[allow(clippy::too_many_arguments)] +fn draw_progress_dialog( + ctx: &mut Context, + theme: &Theme, + title: &str, + current: &str, + files: (u64, u64), + bytes: (u64, u64), + cancelling: bool, + size: Size, +) { + let (files_done, files_total) = files; + let (bytes_done, bytes_total) = bytes; + let spec = DialogSpec { + bg: theme.dialog_info_bg, + ..DIALOG_BLUE_60 + }; + let caption = if cancelling { + arena_format!(ctx.arena(), "{title} - cancelling...") + } else { + arena_format!(ctx.arena(), "{title} - Esc=Cancel") + }; + let w = dialog_begin(ctx, theme, &spec, &caption, 8, size); + { + let content_w = (w - 4).max(1) as usize; + + let cur = truncate_to_display_width(current, content_w); + dialog_prompt(ctx, "cur", cur); + dialog_spacer(ctx, "sp-mid"); + + let frac = if bytes_total > 0 { + bytes_done as f64 / bytes_total as f64 + } else if files_total > 0 { + files_done as f64 / files_total as f64 + } else { + 0.0 + }; + let gauge = gauge_text(frac, content_w); + dialog_prompt(ctx, "gauge", &gauge); + + let counts = if bytes_total > 0 { + arena_format!( + ctx.arena(), + "{files_done}/{files_total} files {} / {}", + crate::panel::format_size(bytes_done), + crate::panel::format_size(bytes_total), + ) + } else { + arena_format!(ctx.arena(), "{files_done}/{files_total} files") + }; + dialog_prompt(ctx, "counts", &counts); + } + dialog_end(ctx); +} + +/// Render a fixed-width gauge such as `████████░░░░░░ 57%`. +fn gauge_text(frac: f64, width: usize) -> String { + let frac = frac.clamp(0.0, 1.0); + // Reserve 5 trailing columns for " 100%". + let bar_w = width.saturating_sub(5).max(1); + let filled = (frac * bar_w as f64).round() as usize; + let mut s = String::with_capacity(width + 4); + for _ in 0..filled { + s.push('█'); } + for _ in filled..bar_w { + s.push('░'); + } + s.push_str(&format!(" {:>3}%", (frac * 100.0).round() as u32)); + s } // Dialog framework @@ -1335,80 +1420,6 @@ fn draw_help_dialog( dialog_end(ctx); } -fn draw_shell_output_dialog( - ctx: &mut Context, - theme: &Theme, - command: &str, - output: &str, - scroll: usize, - size: Size, -) { - let w = (size.width - 4).max(20); - let h = (size.height - 4).max(8); - - let title = arena_format!(ctx.arena(), "$ {command} - Ctrl+C=Copy Esc/Enter=Close"); - ctx.modal_begin("shell-dialog", &title); - ctx.attr_intrinsic_size(Size { - width: w, - height: h, - }); - ctx.attr_background_rgba(ctx.indexed(theme.dialog_shell_bg)); - ctx.attr_foreground_rgba(ctx.indexed(theme.dialog_shell_fg)); - { - let lines: Vec<&str> = output.lines().collect(); - let visible = (h - 2).max(1) as usize; - let max_scroll = lines.len().saturating_sub(visible); - let scroll = scroll.min(max_scroll); - let end = (scroll + visible).min(lines.len()); - let thumb = scrollbar_thumb(lines.len(), visible, scroll); - let content_w = if thumb.is_some() { w - 5 } else { w - 4 }; - - for (i, line) in lines[scroll..end].iter().enumerate() { - ctx.next_block_id_mixin(i as u64); - ctx.block_begin("out-line"); - ctx.attr_intrinsic_size(Size { - width: if thumb.is_some() { w - 3 } else { w - 4 }, - height: 1, - }); - ctx.attr_foreground_rgba(ctx.indexed(theme.dialog_shell_text)); - - if thumb.is_some() { - let line_display = truncate_to_display_width(line, content_w as usize); - let pad = content_w as usize - str_display_width(line_display); - let sb = sb_char(thumb, i); - let text = arena_format!(ctx.arena(), "{line_display}{:pad$} {sb}", ""); - ctx.label("out-text", &text); - } else { - ctx.label("out-text", line); - } - ctx.block_end(); - } - - // Fill remaining visible rows with scrollbar track. - if thumb.is_some() { - for i in (end - scroll)..visible { - ctx.next_block_id_mixin((i + lines.len()) as u64); - ctx.block_begin("out-line"); - ctx.attr_intrinsic_size(Size { - width: w - 3, - height: 1, - }); - ctx.attr_foreground_rgba(ctx.indexed(theme.dialog_shell_text)); - let sb = sb_char(thumb, i); - let text = arena_format!(ctx.arena(), "{:pad$} {sb}", "", pad = content_w as usize); - ctx.label("out-text", &text); - ctx.block_end(); - } - - let indicator = - arena_format!(ctx.arena(), " [{}-{} of {}]", scroll + 1, end, lines.len()); - ctx.attr_foreground_rgba(ctx.indexed(theme.dialog_shell_scroll_info)); - ctx.label("scroll-info", &indicator); - } - } - ctx.modal_end(); -} - fn draw_select_group_dialog( ctx: &mut Context, theme: &Theme, diff --git a/crates/ruf4/src/fileops.rs b/crates/ruf4/src/fileops.rs index 03d511a..564571d 100644 --- a/crates/ruf4/src/fileops.rs +++ b/crates/ruf4/src/fileops.rs @@ -6,12 +6,27 @@ //! Pure operations (`ops_*`) take paths and return errors. State wrappers //! (`do_*`) read from `State`, call through, and refresh panels. +use std::borrow::Cow; use std::fs; use std::path::{Path, PathBuf}; use crate::platform; use crate::state::{Dialog, State}; +/// The final path component as a lossy string, or empty if there is none. +pub fn base_name(path: &Path) -> Cow<'_, str> { + path.file_name().unwrap_or_default().to_string_lossy() +} + +/// The "copy/move onto itself" error message for `name`. +pub fn same_file_error(name: &str, is_copy: bool) -> String { + if is_copy { + format!("{name}: cannot copy file to itself") + } else { + format!("{name}: source and destination are the same") + } +} + pub fn ops_mkdir(path: &Path) -> Result<(), String> { fs::create_dir_all(path).map_err(|e| format!("Cannot create \"{}\": {e}", path.display())) } @@ -219,20 +234,31 @@ pub fn do_mkdir(state: &mut State, name: &str) { pub fn do_delete(state: &mut State) { let files = state.active_panel().selected_or_current(); - let errors = ops_delete(&files); - finish_operation(state, errors, true); + if files.is_empty() { + state.dialog = Dialog::None; + return; + } + state.start_job(crate::job::spawn_delete(files)); } pub fn do_copy(state: &mut State, dest: &str) { let sources = state.active_panel().selected_or_current(); let pairs = ops_build_pairs(&sources, dest); - continue_copy_move(state, pairs, Vec::new(), true); + if pairs.is_empty() { + state.dialog = Dialog::None; + return; + } + state.start_job(crate::job::spawn_copy_move(pairs, true)); } pub fn do_move(state: &mut State, dest: &str) { let sources = state.active_panel().selected_or_current(); let pairs = ops_build_pairs(&sources, dest); - continue_copy_move(state, pairs, Vec::new(), false); + if pairs.is_empty() { + state.dialog = Dialog::None; + return; + } + state.start_job(crate::job::spawn_copy_move(pairs, false)); } pub fn do_rename(state: &mut State, new_name: &str) { @@ -272,11 +298,7 @@ pub fn execute_command(state: &mut State) { let dest = fs::canonicalize(&raw).unwrap_or(raw); if dest.is_dir() { state.record_dir_change(&dest); - let panel = state.active_panel_mut(); - panel.path = dest; - panel.cursor = 0; - panel.scroll_offset = 0; - panel.refresh(); + state.active_panel_mut().navigate_to(dest); } else { state.dialog = Dialog::Error { message: format!("cd: not a directory: {}", dest.display()), @@ -286,21 +308,15 @@ pub fn execute_command(state: &mut State) { return; } - match platform::run_command(&cmd, &cwd) { - Ok((text, _code)) => { - state.last_output = Some((cmd.clone(), text.clone())); - state.dialog = Dialog::ShellOutput { - command: cmd, - output: text, - scroll: 0, - }; - } - Err(msg) => { - state.dialog = Dialog::Error { message: msg }; - } - } - state.command_line.clear(); + + // External commands run in the foreground with the terminal handed back to + // them, so interactive programs work. The TUI is suspended and restored by + // `run_interactive`; the screen must be fully repainted afterwards. + if let Err(msg) = platform::run_interactive(&cmd, &cwd) { + state.dialog = Dialog::Error { message: msg }; + } + state.request_repaint(); state.left.refresh(); state.right.refresh(); } @@ -316,53 +332,6 @@ fn parse_cd_command(cmd: &str) -> Option { } } -pub fn continue_copy_move( - state: &mut State, - mut pending: Vec<(PathBuf, PathBuf)>, - mut errors: Vec, - is_copy: bool, -) { - while !pending.is_empty() { - let (src, target) = &pending[0]; - - if same_file(src, target) { - let name = src.file_name().unwrap_or_default().to_string_lossy(); - let msg = if is_copy { - format!("{name}: cannot copy file to itself") - } else { - format!("{name}: source and destination are the same") - }; - errors.push(msg); - pending.remove(0); - continue; - } - - if target.exists() { - let target_name = target - .file_name() - .unwrap_or_default() - .to_string_lossy() - .into_owned(); - state.dialog = Dialog::ConfirmOverwrite { - target_name, - pending, - errors, - is_copy, - }; - return; - } - - ops_execute_one(src, target, is_copy, &mut errors); - pending.remove(0); - } - - finish_operation(state, errors, false); -} - -pub fn execute_file_op(src: &Path, target: &Path, is_copy: bool, errors: &mut Vec) { - ops_execute_one(src, target, is_copy, errors); -} - pub fn finish_operation(state: &mut State, errors: Vec, active_only: bool) { if errors.is_empty() { state.dialog = Dialog::None; diff --git a/crates/ruf4/src/job.rs b/crates/ruf4/src/job.rs new file mode 100644 index 0000000..d1f963e --- /dev/null +++ b/crates/ruf4/src/job.rs @@ -0,0 +1,401 @@ +// Copyright (c) 2026 ruf4 contributors. +// Licensed under the MIT License. + +//! Background execution of long-running operations. +//! +//! A [`Job`] runs one operation (copy, move, delete, or an external command) on a +//! worker thread so the UI thread stays responsive. The worker communicates only +//! through channels and an atomic cancel flag; it never touches `State`, `Panel`, +//! the terminal (`sys`), or the scratch arena. The pure file primitives in +//! `fileops` are reused for the actual work. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{Receiver, RecvError, Sender, channel}; +use std::thread::JoinHandle; + +use crate::fileops; +use crate::platform; + +/// The operation a [`Job`] performs. Drives completion handling (which panels to +/// refresh) and the progress-dialog title. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum JobKind { + Copy, + Move, + Delete, +} + +impl JobKind { + pub fn title(self) -> &'static str { + match self { + JobKind::Copy => "Copying", + JobKind::Move => "Moving", + JobKind::Delete => "Deleting", + } + } +} + +/// A progress snapshot mirrored from the worker into the UI each frame. +#[derive(Clone, Default)] +pub struct Progress { + pub current: String, + pub files_done: u64, + pub files_total: u64, + pub bytes_done: u64, + pub bytes_total: u64, +} + +/// Worker -> UI messages. +enum JobEvent { + Progress(Progress), + /// The worker hit an existing target and is blocked waiting for a [`Decision`]. + NeedOverwrite(String), + /// Terminal message; carries the accumulated per-file errors. + Finished { + errors: Vec, + }, +} + +/// UI -> worker answer to an overwrite prompt. +#[derive(Clone, Copy)] +pub enum Decision { + Overwrite, + Skip, + OverwriteAll, + Cancel, +} + +/// A handle to a running background operation, owned by the UI thread. +pub struct Job { + pub kind: JobKind, + pub progress: Progress, + /// `Some(name)` while the worker is blocked on an overwrite decision. + pub awaiting_overwrite: Option, + pub cancelling: bool, + handle: Option>, + events: Receiver, + decisions: Sender, + cancel: Arc, +} + +impl Job { + pub fn is_copy(&self) -> bool { + self.kind == JobKind::Copy + } + + /// Request cancellation. The worker stops at the next file boundary. + pub fn cancel(&mut self) { + self.cancelling = true; + self.cancel.store(true, Ordering::Relaxed); + // If the worker is blocked on an overwrite decision, release it. + if self.awaiting_overwrite.take().is_some() { + let _ = self.decisions.send(Decision::Cancel); + } + } + + /// Answer a pending overwrite prompt. + pub fn answer_overwrite(&mut self, decision: Decision) { + if self.awaiting_overwrite.take().is_some() { + if matches!(decision, Decision::Cancel) { + self.cancelling = true; + self.cancel.store(true, Ordering::Relaxed); + } + let _ = self.decisions.send(decision); + } + } + + /// Drain pending worker messages. Returns the terminal errors once the worker + /// has finished (the job should then be dropped by the caller); `None` while + /// it is still running. + pub fn poll(&mut self) -> Option> { + let mut finished = None; + while let Ok(ev) = self.events.try_recv() { + match ev { + JobEvent::Progress(p) => self.progress = p, + JobEvent::NeedOverwrite(name) => self.awaiting_overwrite = Some(name), + JobEvent::Finished { errors } => finished = Some(errors), + } + } + if finished.is_some() + && let Some(handle) = self.handle.take() + { + let _ = handle.join(); + } + finished + } +} + +/// Spawn a copy or move job over the given (source, target) pairs. +pub fn spawn_copy_move(pairs: Vec<(PathBuf, PathBuf)>, is_copy: bool) -> Job { + let kind = if is_copy { + JobKind::Copy + } else { + JobKind::Move + }; + spawn(kind, move |tx, rx, cancel| { + run_copy_move(pairs, is_copy, &tx, &rx, &cancel) + }) +} + +/// Spawn a delete job over the given paths. +pub fn spawn_delete(paths: Vec) -> Job { + spawn(JobKind::Delete, move |tx, _rx, cancel| { + run_delete(paths, &tx, &cancel) + }) +} + +fn spawn(kind: JobKind, body: F) -> Job +where + F: FnOnce(Sender, Receiver, Arc) + Send + 'static, +{ + let (event_tx, event_rx) = channel(); + let (decision_tx, decision_rx) = channel(); + let cancel = Arc::new(AtomicBool::new(false)); + let worker_cancel = cancel.clone(); + let handle = std::thread::spawn(move || body(event_tx, decision_rx, worker_cancel)); + Job { + kind, + progress: Progress::default(), + awaiting_overwrite: None, + cancelling: false, + handle: Some(handle), + events: event_rx, + decisions: decision_tx, + cancel, + } +} + +// ── Workers ───────────────────────────────────────────────────────────────── + +fn cancelled(cancel: &AtomicBool) -> bool { + cancel.load(Ordering::Relaxed) +} + +fn run_copy_move( + pairs: Vec<(PathBuf, PathBuf)>, + is_copy: bool, + tx: &Sender, + rx: &Receiver, + cancel: &AtomicBool, +) { + // Pre-scan each pair so the gauge reflects total files and bytes. + let counts: Vec<(u64, u64)> = pairs.iter().map(|(src, _)| scan_tree(src)).collect(); + let mut prog = Progress { + files_total: counts.iter().map(|c| c.0).sum(), + bytes_total: counts.iter().map(|c| c.1).sum(), + ..Progress::default() + }; + + let mut errors = Vec::new(); + let mut overwrite_all = false; + + for (i, (src, target)) in pairs.iter().enumerate() { + if cancelled(cancel) { + break; + } + + if fileops::same_file(src, target) { + errors.push(fileops::same_file_error(&fileops::base_name(src), is_copy)); + advance(&mut prog, counts[i]); + let _ = tx.send(JobEvent::Progress(prog.clone())); + continue; + } + + if target.exists() && !overwrite_all { + let name = fileops::base_name(target).into_owned(); + let _ = tx.send(JobEvent::NeedOverwrite(name)); + match rx.recv() { + Ok(Decision::Overwrite) => {} + Ok(Decision::OverwriteAll) => overwrite_all = true, + Ok(Decision::Skip) => { + advance(&mut prog, counts[i]); + let _ = tx.send(JobEvent::Progress(prog.clone())); + continue; + } + Ok(Decision::Cancel) | Err(RecvError) => break, + } + } + + copy_move_one(src, target, is_copy, &mut prog, tx, cancel, &mut errors); + } + + let _ = tx.send(JobEvent::Finished { errors }); +} + +/// Execute one top-level (src, target) pair, reporting per-file progress. +fn copy_move_one( + src: &Path, + target: &Path, + is_copy: bool, + prog: &mut Progress, + tx: &Sender, + cancel: &AtomicBool, + errors: &mut Vec, +) { + let name = fileops::base_name(src).into_owned(); + if is_copy { + if let Err(e) = copy_tree(src, target, prog, tx, cancel) { + errors.push(format!("{name}: {e}")); + } + return; + } + + // Move: try a rename first; it is atomic and instant for same-filesystem moves. + match std::fs::rename(src, target) { + Ok(()) => { + prog.current = name; + prog.files_done += count_files(src).max(1); + let _ = tx.send(JobEvent::Progress(prog.clone())); + } + Err(e) if is_cross_device(&e) => { + // Fall back to copy + remove with progress. + if let Err(e) = copy_tree(src, target, prog, tx, cancel) { + errors.push(format!("{name}: {e}")); + return; + } + if cancelled(cancel) { + return; + } + let removed = if src.is_dir() { + std::fs::remove_dir_all(src) + } else { + std::fs::remove_file(src) + }; + if let Err(e) = removed { + errors.push(format!("{name}: {e}")); + } + } + Err(e) => errors.push(format!("{name}: {e}")), + } +} + +/// Recursively copy `src` to `dst`, reporting progress and honouring cancellation. +/// Mirrors `fileops::copy_dir_recursive` but per file, so a large copy stays +/// cancellable and the gauge advances smoothly. +fn copy_tree( + src: &Path, + dst: &Path, + prog: &mut Progress, + tx: &Sender, + cancel: &AtomicBool, +) -> std::io::Result<()> { + if cancelled(cancel) { + return Ok(()); + } + + let meta = std::fs::symlink_metadata(src)?; + if meta.file_type().is_symlink() { + platform::copy_symlink(src, dst)?; + bump(prog, src, 0, tx); + return Ok(()); + } + + if meta.is_dir() { + // Guard against copying a directory into itself. + if let Ok(cs) = std::fs::canonicalize(src) + && let Ok(cd) = std::fs::canonicalize(dst) + && cd.starts_with(&cs) + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "cannot copy directory into itself", + )); + } + std::fs::create_dir_all(dst)?; + for entry in std::fs::read_dir(src)? { + if cancelled(cancel) { + return Ok(()); + } + let entry = entry?; + copy_tree( + &entry.path(), + &dst.join(entry.file_name()), + prog, + tx, + cancel, + )?; + } + return Ok(()); + } + + std::fs::copy(src, dst)?; + bump(prog, src, meta.len(), tx); + Ok(()) +} + +fn run_delete(paths: Vec, tx: &Sender, cancel: &AtomicBool) { + let mut prog = Progress { + files_total: paths.iter().map(|p| count_files(p)).sum(), + ..Progress::default() + }; + let mut errors = Vec::new(); + + for path in &paths { + if cancelled(cancel) { + break; + } + prog.current = fileops::base_name(path).into_owned(); + let errs = fileops::ops_delete(std::slice::from_ref(path)); + errors.extend(errs); + prog.files_done += count_files(path).max(1); + let _ = tx.send(JobEvent::Progress(prog.clone())); + } + + let _ = tx.send(JobEvent::Finished { errors }); +} + +// ── Progress helpers ───────────────────────────────────────────────────────── + +fn bump(prog: &mut Progress, src: &Path, bytes: u64, tx: &Sender) { + prog.current = fileops::base_name(src).into_owned(); + prog.files_done += 1; + prog.bytes_done += bytes; + let _ = tx.send(JobEvent::Progress(prog.clone())); +} + +fn advance(prog: &mut Progress, count: (u64, u64)) { + prog.files_done += count.0; + prog.bytes_done += count.1; +} + +/// Count files and total bytes under `path` (the path itself counts as one entry +/// when it is a file or symlink). +fn scan_tree(path: &Path) -> (u64, u64) { + let meta = match std::fs::symlink_metadata(path) { + Ok(m) => m, + Err(_) => return (1, 0), + }; + if meta.file_type().is_symlink() { + return (1, 0); + } + if meta.is_dir() { + let mut files = 0; + let mut bytes = 0; + if let Ok(rd) = std::fs::read_dir(path) { + for entry in rd.flatten() { + let (f, b) = scan_tree(&entry.path()); + files += f; + bytes += b; + } + } + (files, bytes) + } else { + (1, meta.len()) + } +} + +fn count_files(path: &Path) -> u64 { + scan_tree(path).0 +} + +fn is_cross_device(e: &std::io::Error) -> bool { + #[cfg(unix)] + const CROSS_DEVICE: i32 = libc::EXDEV; + #[cfg(windows)] + const CROSS_DEVICE: i32 = 17; // ERROR_NOT_SAME_DEVICE + #[cfg(not(any(unix, windows)))] + const CROSS_DEVICE: i32 = -1; + e.raw_os_error() == Some(CROSS_DEVICE) +} diff --git a/crates/ruf4/src/lib.rs b/crates/ruf4/src/lib.rs index 28d1203..2a9e587 100644 --- a/crates/ruf4/src/lib.rs +++ b/crates/ruf4/src/lib.rs @@ -8,6 +8,7 @@ pub mod action; pub mod draw; pub mod fileops; +pub mod job; pub mod lsh; pub mod panel; pub mod platform; diff --git a/crates/ruf4/src/main.rs b/crates/ruf4/src/main.rs index baa2d2a..5641967 100644 --- a/crates/ruf4/src/main.rs +++ b/crates/ruf4/src/main.rs @@ -73,10 +73,17 @@ fn run() -> std::io::Result<()> { loop { let scratch = scratch_arena(None); + // While a background job runs, poll often so the progress dialog stays live. + let job_timeout = if state.job_active() { + Duration::from_millis(50) + } else { + Duration::MAX + }; let read_timeout = vt_parser .read_timeout() .min(tui.read_timeout()) - .min(Duration::from_secs(1)); + .min(Duration::from_secs(1)) + .min(job_timeout); let input = match sys::read_stdin(&scratch, read_timeout) { Some(input) => input, @@ -90,7 +97,7 @@ fn run() -> std::io::Result<()> { if state.handle_global_input(&ev) { break; } - state.finalize_dialog(); + state.poll_job(); state.update_preview(); @@ -101,7 +108,10 @@ fn run() -> std::io::Result<()> { state.update_preview(); } + // A timeout (empty read) is also our background-job heartbeat: pull the + // latest worker progress and repaint. if input.is_empty() { + state.poll_job(); let mut ctx = tui.create_context(None); let r = draw::draw(&mut ctx, &mut state); state.apply_draw_result(r.term_size, r.menu_active); @@ -117,6 +127,12 @@ fn run() -> std::io::Result<()> { state.apply_draw_result(r.term_size, r.menu_active); } + // After an external program took over the terminal, the incremental + // diff would draw nothing onto the freshly re-entered alternate screen. + if state.take_repaint_request() { + tui.request_full_redraw(); + } + let scratch = scratch_arena(None); let output = tui.render(&scratch); sys::write_stdout(&output); diff --git a/crates/ruf4/src/panel.rs b/crates/ruf4/src/panel.rs index 6de2e85..725571b 100644 --- a/crates/ruf4/src/panel.rs +++ b/crates/ruf4/src/panel.rs @@ -82,6 +82,73 @@ impl FileEntry { } } +/// Read a directory into a list of [`FileEntry`], prepending `..` when a parent +/// exists. Hidden entries are dropped unless `show_hidden`. The result is +/// unsorted. This performs only filesystem and `platform` calls, so it is safe to +/// run on a worker thread (it touches no shared application state). +pub fn scan_dir(path: &Path, show_hidden: bool) -> Vec { + let mut entries = Vec::new(); + + if path.parent().is_some() { + entries.push(FileEntry { + name: "..".to_string(), + is_dir: true, + is_symlink: false, + is_hardlink: false, + is_executable: false, + is_readonly: false, + is_hidden: false, + size: 0, + modified: None, + selected: false, + }); + } + + if let Ok(iter) = fs::read_dir(path) { + for entry in iter.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + + let is_symlink = entry.file_type().map(|t| t.is_symlink()).unwrap_or(false); + let metadata = if is_symlink { + fs::metadata(entry.path()).ok() + } else { + entry.metadata().ok() + }; + + let is_hidden = platform::is_hidden(&name, metadata.as_ref()); + if !show_hidden && is_hidden { + continue; + } + + let is_dir = metadata.as_ref().map(|m| m.is_dir()).unwrap_or(false); + let size = metadata.as_ref().map(|m| m.len()).unwrap_or(0); + let modified = metadata.as_ref().and_then(|m| m.modified().ok()); + let is_readonly = metadata + .as_ref() + .map(|m| m.permissions().readonly()) + .unwrap_or(false); + + let (is_hardlink, is_executable) = + platform::detect_hardlink_executable(metadata.as_ref(), is_dir, is_symlink, &name); + + entries.push(FileEntry { + name, + is_dir, + is_symlink, + is_hardlink, + is_executable, + is_readonly, + is_hidden, + size, + modified, + selected: false, + }); + } + } + + entries +} + pub struct Panel { pub path: PathBuf, pub entries: Vec, @@ -124,74 +191,19 @@ impl Panel { pub fn refresh(&mut self) { self.last_refresh = platform::format_current_time(); - self.entries.clear(); - - // ".." entry to go up. - if self.path.parent().is_some() { - self.entries.push(FileEntry { - name: "..".to_string(), - is_dir: true, - is_symlink: false, - is_hardlink: false, - is_executable: false, - is_readonly: false, - is_hidden: false, - size: 0, - modified: None, - selected: false, - }); - } - - if let Ok(iter) = fs::read_dir(&self.path) { - for entry in iter.flatten() { - let name = entry.file_name().to_string_lossy().into_owned(); - - let is_symlink = entry.file_type().map(|t| t.is_symlink()).unwrap_or(false); - let metadata = if is_symlink { - fs::metadata(entry.path()).ok() - } else { - entry.metadata().ok() - }; - - let is_hidden = platform::is_hidden(&name, metadata.as_ref()); - if !self.show_hidden && is_hidden { - continue; - } - - let is_dir = metadata.as_ref().map(|m| m.is_dir()).unwrap_or(false); - let size = metadata.as_ref().map(|m| m.len()).unwrap_or(0); - let modified = metadata.as_ref().and_then(|m| m.modified().ok()); - let is_readonly = metadata - .as_ref() - .map(|m| m.permissions().readonly()) - .unwrap_or(false); - - let (is_hardlink, is_executable) = platform::detect_hardlink_executable( - metadata.as_ref(), - is_dir, - is_symlink, - &name, - ); - - self.entries.push(FileEntry { - name, - is_dir, - is_symlink, - is_hardlink, - is_executable, - is_readonly, - is_hidden, - size, - modified, - selected: false, - }); - } - } - + self.entries = scan_dir(&self.path, self.show_hidden); self.sort(); self.cursor = self.cursor.min(self.entries.len().saturating_sub(1)); } + /// Move this panel to `path`, resetting cursor and scroll, and reload entries. + pub fn navigate_to(&mut self, path: PathBuf) { + self.path = path; + self.cursor = 0; + self.scroll_offset = 0; + self.refresh(); + } + fn sort(&mut self) { let start = if self.entries.first().is_some_and(|e| e.name == "..") { 1 diff --git a/crates/ruf4/src/platform.rs b/crates/ruf4/src/platform.rs index 26eac8d..fe6060f 100644 --- a/crates/ruf4/src/platform.rs +++ b/crates/ruf4/src/platform.rs @@ -449,6 +449,56 @@ pub fn settings_path() -> Option { // ── Shell commands ───────────────────────────────────────────────────────── +/// Run an external command in the foreground with the terminal handed back to +/// it, so interactive programs (a shell, `python`, `vim`, `less`) work normally. +/// The TUI is suspended for the duration and restored afterwards. +/// +/// stdin/stdout/stderr are inherited (not captured); a full-screen program owns +/// the screen and its output scrolls the real terminal. After it exits we wait +/// for the user to acknowledge so the output stays readable, then repaint. +pub fn run_interactive(cmd: &str, cwd: &Path) -> Result<(), String> { + use ruf4_tui::sys; + use std::io::{BufRead, Write}; + + // Leave the TUI: reset cursor style, show cursor, reset attributes, disable + // mouse tracking, then leave the alternate screen (matches the startup guard). + sys::write_stdout("\x1b[0 q\x1b[?25h\x1b[m\x1b[?1003;1006l\x1b[?1049l"); + sys::suspend(); + + #[cfg(windows)] + let status = Command::new("cmd.exe") + .arg("/C") + .arg(cmd) + .current_dir(cwd) + .status(); + #[cfg(not(windows))] + let status = Command::new("sh") + .arg("-c") + .arg(cmd) + .current_dir(cwd) + .status(); + + let result = match status { + Ok(_) => Ok(()), + Err(e) => Err(format!("Failed to execute \"{cmd}\": {e}")), + }; + + // Keep the output on screen until acknowledged. + let mut out = std::io::stdout(); + let _ = write!(out, "\n[Press Enter to return to ruf4] "); + let _ = out.flush(); + let mut line = String::new(); + let _ = std::io::stdin().lock().read_line(&mut line); + + // Restore the TUI: raw mode, alternate screen, mouse tracking, then force a + // full repaint by injecting a window-size event. + sys::resume(); + sys::write_stdout("\x1b[?1049h\x1b[?1003;1006h"); + sys::inject_window_size_into_stdin(); + + result +} + pub fn run_command(cmd: &str, cwd: &Path) -> Result<(String, i32), String> { #[cfg(unix)] let output = Command::new("sh") diff --git a/crates/ruf4/src/state.rs b/crates/ruf4/src/state.rs index 500049f..9fea241 100644 --- a/crates/ruf4/src/state.rs +++ b/crates/ruf4/src/state.rs @@ -11,6 +11,7 @@ use ruf4_tui::input::{Input, InputKey, InputMouseState, kbmod, vk}; use crate::action::{self, Action, Binding}; use crate::fileops; +use crate::job::{Decision, Job, JobKind}; use crate::panel::{Panel, SortBy}; use crate::platform; use crate::preview::{self, Preview}; @@ -83,11 +84,6 @@ pub enum Dialog { files: Vec, dest: String, }, - ShellOutput { - command: String, - output: String, - scroll: usize, - }, ConfirmQuit { save_settings: bool, }, @@ -106,13 +102,22 @@ pub enum Dialog { }, ConfirmOverwrite { target_name: String, - pending: Vec<(PathBuf, PathBuf)>, - errors: Vec, is_copy: bool, }, Rename { name: String, }, + /// Shown while a background [`Job`] runs. Fields mirror the worker's progress + /// and are refreshed every frame by [`State::poll_job`]. + Progress { + title: &'static str, + current: String, + files_done: u64, + files_total: u64, + bytes_done: u64, + bytes_total: u64, + cancelling: bool, + }, } pub struct State { @@ -138,8 +143,11 @@ pub struct State { pub bindings: Vec, pub help_text: Vec<(String, &'static str, Action)>, pub theme: Theme, - pub last_output: Option<(String, String)>, // (command, output) last_click: Option<(Instant, Point)>, + /// The active background operation, if any. + pub job: Option, + /// Set after an external program returns, to force a full screen repaint. + repaint_requested: bool, } // ── Construction ──────────────────────────────────────────────────────────── @@ -182,8 +190,9 @@ impl State { bindings, help_text, theme, - last_output: None, last_click: None, + job: None, + repaint_requested: false, }; if let Some(s) = settings::Settings::load() { @@ -231,8 +240,9 @@ impl State { bindings, help_text, theme, - last_output: None, last_click: None, + job: None, + repaint_requested: false, } } @@ -372,15 +382,6 @@ impl State { Action::ChangeRoot => self.open_choose_root(), Action::DirHistory => self.open_dir_history(), Action::CmdHistory => self.open_cmd_history(), - Action::LastOutput => { - if let Some((ref cmd, ref out)) = self.last_output { - self.dialog = Dialog::ShellOutput { - command: cmd.clone(), - output: out.clone(), - scroll: 0, - }; - } - } Action::FocusMenu => self.want_menu_focus = true, Action::Quit => { self.dialog = Dialog::ConfirmQuit { @@ -543,11 +544,7 @@ impl State { fn choose_root(&mut self, path: PathBuf) { self.dialog = Dialog::None; self.record_dir_change(&path); - let panel = self.active_panel_mut(); - panel.path = path; - panel.cursor = 0; - panel.scroll_offset = 0; - panel.refresh(); + self.active_panel_mut().navigate_to(path); } // ── Layout feedback from draw pass ────────────────────────────────── @@ -582,12 +579,91 @@ impl State { return; } self.preview_path = current.clone(); + self.preview_scroll = 0; // A new file starts at the top. self.preview = match current { Some(path) => preview::generate(&path), None => Preview::empty(), }; } + // ── Background jobs ────────────────────────────────────────────────── + + pub fn job_active(&self) -> bool { + self.job.is_some() + } + + /// Request a full screen repaint on the next frame (e.g. after an external + /// program returned the terminal). + pub fn request_repaint(&mut self) { + self.repaint_requested = true; + } + + /// Consume a pending repaint request. + pub fn take_repaint_request(&mut self) -> bool { + std::mem::take(&mut self.repaint_requested) + } + + /// Start a background operation and show its progress dialog. + pub fn start_job(&mut self, job: Job) { + self.dialog = Dialog::Progress { + title: job.kind.title(), + current: String::new(), + files_done: 0, + files_total: 0, + bytes_done: 0, + bytes_total: 0, + cancelling: false, + }; + self.job = Some(job); + } + + /// Poll the active job each frame: refresh the progress dialog, surface + /// overwrite prompts, and apply completion. No-op when no job runs. + pub fn poll_job(&mut self) { + let Some(job) = self.job.as_mut() else { + return; + }; + let finished = job.poll(); + let kind = job.kind; + let is_copy = job.is_copy(); + let cancelling = job.cancelling; + let awaiting = job.awaiting_overwrite.clone(); + let progress = job.progress.clone(); + + if let Some(errors) = finished { + self.job = None; + self.complete_job(kind, errors); + return; + } + + if let Some(name) = awaiting { + // Reuse the overwrite dialog; the worker owns the remaining work. + if !matches!(self.dialog, Dialog::ConfirmOverwrite { .. }) { + self.dialog = Dialog::ConfirmOverwrite { + target_name: name, + is_copy, + }; + } + } else { + self.dialog = Dialog::Progress { + title: kind.title(), + current: progress.current, + files_done: progress.files_done, + files_total: progress.files_total, + bytes_done: progress.bytes_done, + bytes_total: progress.bytes_total, + cancelling, + }; + } + } + + fn complete_job(&mut self, kind: JobKind, errors: Vec) { + match kind { + JobKind::Delete => fileops::finish_operation(self, errors, true), + JobKind::Copy | JobKind::Move => fileops::finish_operation(self, errors, false), + } + } + // ── Query helpers ─────────────────────────────────────────────────── pub fn dialog_is_none(&self) -> bool { @@ -674,19 +750,35 @@ impl State { self.handle_mouse_click(mouse.position); } else if mouse.state == InputMouseState::Scroll { let panel_width = self.term_size.width / 2; - let panel = if mouse.position.x < panel_width { - &mut self.left + let on_left = mouse.position.x < panel_width; + // The quick view occupies the inactive side; scroll it there. + if self.quick_view && on_left == (self.active == ActivePanel::Right) { + self.scroll_preview(mouse.scroll.y); } else { - &mut self.right - }; - if mouse.scroll.y < 0 { - panel.cursor_up(MOUSE_SCROLL); - } else if mouse.scroll.y > 0 { - panel.cursor_down(MOUSE_SCROLL); + let panel = if on_left { + &mut self.left + } else { + &mut self.right + }; + if mouse.scroll.y < 0 { + panel.cursor_up(MOUSE_SCROLL); + } else if mouse.scroll.y > 0 { + panel.cursor_down(MOUSE_SCROLL); + } } } } + /// Scroll the quick view by a wheel delta. The result is clamped to the + /// content during the next draw (`apply_draw_result`). + fn scroll_preview(&mut self, delta: CoordType) { + if delta < 0 { + self.preview_scroll = self.preview_scroll.saturating_sub(MOUSE_SCROLL); + } else if delta > 0 { + self.preview_scroll = self.preview_scroll.saturating_add(MOUSE_SCROLL); + } + } + fn handle_mouse_click(&mut self, pos: Point) { let w = self.term_size.width; let h = self.term_size.height; @@ -831,13 +923,17 @@ impl State { // ── Command line ──────────────────────────────────────────────────── + fn command_field(&mut self) -> TextField<'_> { + TextField { + text: &mut self.command_line, + cursor: &mut self.cmd_cursor, + } + } + fn handle_command_line_input(&mut self, ev: &Input) -> bool { match ev { Input::Text(text) => { - let cur = self.cmd_cursor; - let byte_pos = char_to_byte(&self.command_line, cur); - self.command_line.insert_str(byte_pos, text); - self.cmd_cursor = cur + text.chars().count(); + self.command_field().insert(text); true } Input::Keyboard(key) => { @@ -853,42 +949,23 @@ impl State { self.command_line_active = false; self.cmd_cursor = 0; } else if key == vk::BACK && self.cmd_cursor > 0 { - let cur = self.cmd_cursor; - let byte_pos = char_to_byte(&self.command_line, cur - 1); - let next = self.command_line[byte_pos..] - .char_indices() - .nth(1) - .map_or(self.command_line.len(), |(i, _)| byte_pos + i); - self.command_line.drain(byte_pos..next); - self.cmd_cursor = cur - 1; + self.command_field().backspace(); if self.command_line.is_empty() { self.command_line_active = false; } } else if key == vk::DELETE { - let cur = self.cmd_cursor; - let len = self.command_line.chars().count(); - if cur < len { - let byte_pos = char_to_byte(&self.command_line, cur); - let next = self.command_line[byte_pos..] - .char_indices() - .nth(1) - .map_or(self.command_line.len(), |(i, _)| byte_pos + i); - self.command_line.drain(byte_pos..next); - if self.command_line.is_empty() { - self.command_line_active = false; - } + self.command_field().delete(); + if self.command_line.is_empty() { + self.command_line_active = false; } } else if key == vk::LEFT { - self.cmd_cursor = self.cmd_cursor.saturating_sub(1); + self.command_field().left(); } else if key == vk::RIGHT { - let len = self.command_line.chars().count(); - if self.cmd_cursor < len { - self.cmd_cursor += 1; - } + self.command_field().right(); } else if key == vk::HOME { - self.cmd_cursor = 0; + self.command_field().home(); } else if key == vk::END { - self.cmd_cursor = self.command_line.chars().count(); + self.command_field().end(); } else { // Tab, Up, Down, function keys -- let them fall through. return false; @@ -921,8 +998,17 @@ impl State { self.handle_quit_dialog(ev, save); } Dialog::ConfirmOverwrite { .. } => self.handle_overwrite_dialog(ev), - Dialog::ShellOutput { .. } => self.handle_scrollable_dialog(ev), Dialog::ListSelect { .. } => self.handle_list_select_dialog(ev), + Dialog::Progress { .. } => self.handle_progress_dialog(ev), + } + } + + /// Progress dialog: the only interaction is cancellation. + fn handle_progress_dialog(&mut self, ev: &Input) { + let cancel = matches!(ev, Input::Keyboard(key) if *key == vk::ESCAPE) + || matches!(ev, Input::Text("c" | "C")); + if cancel && let Some(j) = self.job.as_mut() { + j.cancel(); } } @@ -945,12 +1031,9 @@ impl State { fn handle_text_input_dialog(&mut self, ev: &Input) { match ev { Input::Text(text) => { - let cur = self.input_cursor; - if let Some(field) = self.dialog_text_field() { - let byte_pos = char_to_byte(field, cur); - field.insert_str(byte_pos, text); + if let Some(mut field) = self.dialog_field_mut() { + field.insert(text); } - self.input_cursor = cur + text.chars().count(); } Input::Keyboard(key) => { let key = *key; @@ -958,58 +1041,37 @@ impl State { self.dialog = Dialog::None; } else if key == vk::RETURN { self.commit_text_dialog(); - } else if key == vk::BACK && self.input_cursor > 0 { - let cur = self.input_cursor; - if let Some(field) = self.dialog_text_field() { - let byte_pos = char_to_byte(field, cur - 1); - let next = field[byte_pos..] - .char_indices() - .nth(1) - .map_or(field.len(), |(i, _)| byte_pos + i); - field.drain(byte_pos..next); + } else if let Some(mut field) = self.dialog_field_mut() { + if key == vk::BACK { + field.backspace(); + } else if key == vk::DELETE { + field.delete(); + } else if key == vk::LEFT { + field.left(); + } else if key == vk::RIGHT { + field.right(); + } else if key == vk::HOME { + field.home(); + } else if key == vk::END { + field.end(); } - self.input_cursor = cur - 1; - } else if key == vk::DELETE { - let cur = self.input_cursor; - let len = self.dialog_text_field().map_or(0, |f| f.chars().count()); - if cur < len - && let Some(field) = self.dialog_text_field() - { - let byte_pos = char_to_byte(field, cur); - let next = field[byte_pos..] - .char_indices() - .nth(1) - .map_or(field.len(), |(i, _)| byte_pos + i); - field.drain(byte_pos..next); - } - } else if key == vk::LEFT { - self.input_cursor = self.input_cursor.saturating_sub(1); - } else if key == vk::RIGHT { - let cur = self.input_cursor; - let len = self.dialog_text_field().map_or(0, |f| f.chars().count()); - if cur < len { - self.input_cursor = cur + 1; - } - } else if key == vk::HOME { - self.input_cursor = 0; - } else if key == vk::END { - let len = self.dialog_text_field().map_or(0, |f| f.chars().count()); - self.input_cursor = len; } } _ => {} } } - fn dialog_text_field(&mut self) -> Option<&mut String> { - match &mut self.dialog { - Dialog::MkDir { name } => Some(name), - Dialog::Rename { name } => Some(name), - Dialog::Copy { dest, .. } => Some(dest), - Dialog::Move { dest, .. } => Some(dest), - Dialog::SelectGroup { pattern, .. } => Some(pattern), - _ => None, - } + /// The active dialog's editable text field paired with the shared input + /// cursor, as a [`TextField`]. `None` for dialogs without a text field. + fn dialog_field_mut(&mut self) -> Option> { + let cursor = &mut self.input_cursor; + let text = match &mut self.dialog { + Dialog::MkDir { name } | Dialog::Rename { name } => name, + Dialog::Copy { dest, .. } | Dialog::Move { dest, .. } => dest, + Dialog::SelectGroup { pattern, .. } => pattern, + _ => return None, + }; + Some(TextField { text, cursor }) } /// Commit the text-input dialog based on its variant. @@ -1081,97 +1143,20 @@ impl State { } } + /// The overwrite prompt only appears while a worker is blocked on a decision; + /// translate the key and hand it back to the job. fn handle_overwrite_dialog(&mut self, ev: &Input) { - enum OverwriteAction { - Yes, - No, - All, - Cancel, - None, - } - let action = match ev { - Input::Text("y" | "Y") => OverwriteAction::Yes, - Input::Text("n" | "N") => OverwriteAction::No, - Input::Text("a" | "A") => OverwriteAction::All, - Input::Keyboard(key) if *key == vk::RETURN => OverwriteAction::Yes, - Input::Keyboard(key) if *key == vk::ESCAPE => OverwriteAction::Cancel, - _ => OverwriteAction::None, - }; - - let Dialog::ConfirmOverwrite { - pending, - errors, - is_copy, - .. - } = &mut self.dialog - else { - return; - }; - - match action { - OverwriteAction::Yes => { - let is_copy = *is_copy; - let mut pending = std::mem::take(pending); - let mut errors = std::mem::take(errors); - if let Some((src, target)) = pending.first() { - fileops::execute_file_op(src, target, is_copy, &mut errors); - } - pending.remove(0); - fileops::continue_copy_move(self, pending, errors, is_copy); - } - OverwriteAction::No => { - let is_copy = *is_copy; - let mut pending = std::mem::take(pending); - let errors = std::mem::take(errors); - pending.remove(0); - fileops::continue_copy_move(self, pending, errors, is_copy); - } - OverwriteAction::All => { - let is_copy = *is_copy; - let pending = std::mem::take(pending); - let mut errors = std::mem::take(errors); - for (src, target) in &pending { - fileops::execute_file_op(src, target, is_copy, &mut errors); - } - fileops::finish_operation(self, errors, false); - } - OverwriteAction::Cancel => { - let errors = std::mem::take(errors); - fileops::finish_operation(self, errors, false); - } - OverwriteAction::None => {} - } - } - - fn handle_scrollable_dialog(&mut self, ev: &Input) { - let Dialog::ShellOutput { scroll, output, .. } = &mut self.dialog else { - return; + let decision = match ev { + Input::Text("y" | "Y") => Decision::Overwrite, + Input::Text("n" | "N") => Decision::Skip, + Input::Text("a" | "A") => Decision::OverwriteAll, + Input::Keyboard(key) if *key == vk::RETURN => Decision::Overwrite, + Input::Keyboard(key) if *key == vk::ESCAPE => Decision::Cancel, + _ => return, }; - match ev { - Input::Keyboard(key) => { - let key = *key; - if key == (kbmod::CTRL | vk::C) { - platform::copy_to_clipboard(output); - return; - } - if key == vk::ESCAPE || key == vk::RETURN || key == vk::SPACE { - *scroll = usize::MAX; // signal dismiss (finalize_dialog cleans up) - } else if key == vk::UP { - *scroll = scroll.saturating_sub(1); - } else if key == vk::DOWN { - *scroll += 1; - } else if key == vk::PRIOR { - *scroll = scroll.saturating_sub(PAGE_SCROLL); - } else if key == vk::NEXT { - *scroll += PAGE_SCROLL; - } else if key == vk::HOME { - *scroll = 0; - } - } - Input::Mouse(mouse) if mouse.state == InputMouseState::Left => { - *scroll = usize::MAX; - } - _ => {} + match self.job.as_mut() { + Some(j) => j.answer_overwrite(decision), + None => self.dialog = Dialog::None, } } @@ -1237,11 +1222,7 @@ impl State { } ListSelectKind::DirHistory { entries } => { if let Some(path) = entries.into_iter().nth(cursor) { - let panel = self.active_panel_mut(); - panel.path = path; - panel.cursor = 0; - panel.scroll_offset = 0; - panel.refresh(); + self.active_panel_mut().navigate_to(path); } } ListSelectKind::CmdHistory { entries } => { @@ -1325,25 +1306,6 @@ impl State { } } -// After handle_dialog_input, clean up sentinel values. -// ShellOutput uses usize::MAX as a "dismiss" signal because the handler -// receives &mut scroll but cannot call self.dialog = Dialog::None. -impl State { - /// Call after handle_dialog_input to finalize deferred state changes. - pub fn finalize_dialog(&mut self) { - if let Dialog::ShellOutput { - scroll, - command, - output, - } = &self.dialog - && *scroll == usize::MAX - { - self.last_output = Some((command.clone(), output.clone())); - self.dialog = Dialog::None; - } - } -} - // ── Shared helpers ───────────────────────────────────────────────────────── /// Convert a char index to a byte index in a string. @@ -1351,6 +1313,67 @@ fn char_to_byte(s: &str, char_idx: usize) -> usize { s.char_indices().nth(char_idx).map_or(s.len(), |(i, _)| i) } +/// A single-line text editor over a borrowed string and char-index cursor. +/// Shared by the command line and the text-input dialogs so the cursor +/// arithmetic lives in one place. +struct TextField<'a> { + text: &'a mut String, + cursor: &'a mut usize, +} + +impl TextField<'_> { + fn len_chars(&self) -> usize { + self.text.chars().count() + } + + fn insert(&mut self, s: &str) { + let byte_pos = char_to_byte(self.text, *self.cursor); + self.text.insert_str(byte_pos, s); + *self.cursor += s.chars().count(); + } + + /// Remove the character at the cursor (byte range of one char from `at`). + fn remove_char_at(&mut self, at: usize) { + let byte_pos = char_to_byte(self.text, at); + let next = self.text[byte_pos..] + .char_indices() + .nth(1) + .map_or(self.text.len(), |(i, _)| byte_pos + i); + self.text.drain(byte_pos..next); + } + + fn backspace(&mut self) { + if *self.cursor > 0 { + *self.cursor -= 1; + self.remove_char_at(*self.cursor); + } + } + + fn delete(&mut self) { + if *self.cursor < self.len_chars() { + self.remove_char_at(*self.cursor); + } + } + + fn left(&mut self) { + *self.cursor = self.cursor.saturating_sub(1); + } + + fn right(&mut self) { + if *self.cursor < self.len_chars() { + *self.cursor += 1; + } + } + + fn home(&mut self) { + *self.cursor = 0; + } + + fn end(&mut self) { + *self.cursor = self.len_chars(); + } +} + fn push_recent(list: &mut Vec, item: T) { list.retain(|x| x != &item); list.insert(0, item); diff --git a/crates/ruf4/tests/test_action.rs b/crates/ruf4/tests/test_action.rs new file mode 100644 index 0000000..35f240d --- /dev/null +++ b/crates/ruf4/tests/test_action.rs @@ -0,0 +1,125 @@ +// Copyright (c) 2026 ruf4 contributors. +// Licensed under the MIT License. + +//! Round-trip tests locking the action/key name tables. These guard the +//! data-table consolidation in `action.rs`: `action_str`/`action_label` share one +//! match, `parse_action` is derived from `action_str`, and `key_display_name` / +//! `parse_key_name` share one `KEY_NAMES` table. + +use std::collections::HashSet; + +use ruf4::action::{ + ALL_ACTIONS, action_label, action_str, default_bindings, key_display_name, parse_action, + parse_key_name, +}; +use ruf4_tui::input::{kbmod, vk}; + +#[test] +fn action_str_round_trips_for_every_action() { + for &a in ALL_ACTIONS { + let s = action_str(a); + assert!(!s.is_empty(), "empty action_str for {a:?}"); + assert_eq!(parse_action(s), Some(a), "round-trip failed for {a:?}"); + } +} + +#[test] +fn action_strings_and_labels_are_unique() { + let mut strs = HashSet::new(); + let mut labels = HashSet::new(); + for &a in ALL_ACTIONS { + assert!(strs.insert(action_str(a)), "duplicate action_str: {a:?}"); + assert!( + labels.insert(action_label(a)), + "duplicate action_label: {a:?}" + ); + } +} + +#[test] +fn all_actions_has_no_duplicates() { + // `Action` is not `Hash`; compare via the unique stable strings instead. + let mut seen: Vec<&str> = Vec::new(); + for &a in ALL_ACTIONS { + let s = action_str(a); + assert!( + !seen.contains(&s), + "ALL_ACTIONS contains a duplicate: {a:?}" + ); + seen.push(s); + } +} + +#[test] +fn parse_action_rejects_unknown() { + assert_eq!(parse_action("definitely_not_an_action"), None); + assert_eq!(parse_action(""), None); +} + +#[test] +fn every_default_binding_action_is_listed() { + // If a bound action is missing from ALL_ACTIONS it would silently fail to + // parse from settings; catch that here. + for b in default_bindings() { + assert!( + ALL_ACTIONS.contains(&b.action), + "default binding action not in ALL_ACTIONS: {:?}", + b.action + ); + } +} + +#[test] +fn key_names_round_trip() { + let keys = [ + vk::F1, + vk::F12, + vk::UP, + vk::DOWN, + vk::LEFT, + vk::RIGHT, + vk::PRIOR, + vk::NEXT, + vk::HOME, + vk::END, + vk::RETURN, + vk::ESCAPE, + vk::TAB, + vk::BACK, + vk::INSERT, + vk::DELETE, + vk::SPACE, + vk::A, + vk::Z, + ]; + for k in keys { + let name = key_display_name(k); + assert!( + parse_key_name(&name) == Some(k), + "round-trip failed for {name}" + ); + } +} + +#[test] +fn key_names_round_trip_with_modifiers() { + let cases = [ + kbmod::CTRL | vk::F3, + kbmod::ALT | vk::X, + kbmod::SHIFT | vk::F5, + kbmod::CTRL | vk::F4, + ]; + for k in cases { + let name = key_display_name(k); + assert!( + parse_key_name(&name) == Some(k), + "round-trip failed for {name}" + ); + } +} + +#[test] +fn unknown_key_name_is_rejected() { + assert!(parse_key_name("Nope").is_none()); + assert!(parse_key_name("Ctrl+Nope").is_none()); +} diff --git a/crates/ruf4/tests/test_job.rs b/crates/ruf4/tests/test_job.rs new file mode 100644 index 0000000..845283d --- /dev/null +++ b/crates/ruf4/tests/test_job.rs @@ -0,0 +1,193 @@ +// Copyright (c) 2026 ruf4 contributors. +// Licensed under the MIT License. + +//! Integration tests for background jobs. Each test spawns a worker and drives +//! it to completion off the UI thread, mirroring what `State::poll_job` does. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +use ruf4::job::{self, Decision, Job}; +use ruf4::panel::{Panel, make_entry}; +use ruf4::state::{Dialog, State}; +use ruf4_tui::input::{Input, vk}; + +static COUNTER: AtomicU64 = AtomicU64::new(0); + +fn temp_dir() -> PathBuf { + let id = COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("ruf4_job_{}_{id}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir +} + +fn cleanup(dir: &Path) { + let _ = fs::remove_dir_all(dir); +} + +/// Drive a job to completion, answering any overwrite prompt via `on_overwrite`. +fn drive(job: &mut Job, mut on_overwrite: impl FnMut() -> Decision) -> Vec { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if let Some(errors) = job.poll() { + return errors; + } + if job.awaiting_overwrite.is_some() { + let d = on_overwrite(); + job.answer_overwrite(d); + } + assert!(Instant::now() < deadline, "job did not finish in time"); + std::thread::sleep(Duration::from_millis(2)); + } +} + +fn no_overwrite() -> Decision { + panic!("unexpected overwrite prompt"); +} + +#[test] +fn delete_removes_files_and_dirs() { + let root = temp_dir(); + let file = root.join("a.txt"); + let dir = root.join("sub"); + fs::write(&file, b"x").unwrap(); + fs::create_dir(&dir).unwrap(); + fs::write(dir.join("b.txt"), b"y").unwrap(); + + let mut job = job::spawn_delete(vec![file.clone(), dir.clone()]); + let errors = drive(&mut job, no_overwrite); + + assert!(errors.is_empty(), "errors: {errors:?}"); + assert!(!file.exists()); + assert!(!dir.exists()); + cleanup(&root); +} + +#[test] +fn copy_duplicates_a_tree() { + let root = temp_dir(); + let src = root.join("src"); + fs::create_dir(&src).unwrap(); + fs::write(src.join("f1"), b"hello").unwrap(); + fs::create_dir(src.join("nested")).unwrap(); + fs::write(src.join("nested/f2"), b"world").unwrap(); + let dst = root.join("dst"); + + let mut job = job::spawn_copy_move(vec![(src.clone(), dst.clone())], true); + let errors = drive(&mut job, no_overwrite); + + assert!(errors.is_empty(), "errors: {errors:?}"); + assert!(src.join("f1").exists(), "source preserved on copy"); + assert_eq!(fs::read(dst.join("f1")).unwrap(), b"hello"); + assert_eq!(fs::read(dst.join("nested/f2")).unwrap(), b"world"); + // Pre-scan totals were reported. + assert_eq!(job.progress.files_total, 2); + assert_eq!(job.progress.files_done, 2); + cleanup(&root); +} + +#[test] +fn move_relocates_a_file() { + let root = temp_dir(); + let src = root.join("a.txt"); + fs::write(&src, b"data").unwrap(); + let dst = root.join("b.txt"); + + let mut job = job::spawn_copy_move(vec![(src.clone(), dst.clone())], false); + let errors = drive(&mut job, no_overwrite); + + assert!(errors.is_empty(), "errors: {errors:?}"); + assert!(!src.exists(), "source removed on move"); + assert_eq!(fs::read(&dst).unwrap(), b"data"); + cleanup(&root); +} + +#[test] +fn overwrite_prompt_skip_keeps_target() { + let root = temp_dir(); + let src = root.join("a.txt"); + let dst = root.join("b.txt"); + fs::write(&src, b"new").unwrap(); + fs::write(&dst, b"old").unwrap(); + + let mut job = job::spawn_copy_move(vec![(src, dst.clone())], true); + let errors = drive(&mut job, || Decision::Skip); + + assert!(errors.is_empty(), "errors: {errors:?}"); + assert_eq!(fs::read(&dst).unwrap(), b"old", "skip preserves target"); + cleanup(&root); +} + +#[test] +fn overwrite_prompt_overwrite_replaces_target() { + let root = temp_dir(); + let src = root.join("a.txt"); + let dst = root.join("b.txt"); + fs::write(&src, b"new").unwrap(); + fs::write(&dst, b"old").unwrap(); + + let mut job = job::spawn_copy_move(vec![(src, dst.clone())], true); + let errors = drive(&mut job, || Decision::Overwrite); + + assert!(errors.is_empty(), "errors: {errors:?}"); + assert_eq!(fs::read(&dst).unwrap(), b"new", "overwrite replaces target"); + cleanup(&root); +} + +/// Drive `State::poll_job` until the background job clears the progress dialog. +fn poll_state_to_idle(state: &mut State) { + let deadline = Instant::now() + Duration::from_secs(10); + while matches!( + state.dialog, + Dialog::Progress { .. } | Dialog::ConfirmOverwrite { .. } + ) || state.job_active() + { + state.poll_job(); + assert!(Instant::now() < deadline, "state job did not finish"); + std::thread::sleep(Duration::from_millis(2)); + } +} + +#[test] +fn state_delete_flow_runs_and_refreshes() { + let root = temp_dir(); + let file = root.join("doomed.txt"); + fs::write(&file, b"bye").unwrap(); + + let left = Panel::with_entries( + root.clone(), + vec![ + make_entry("..", true, 0), + make_entry("doomed.txt", false, 3), + ], + ); + let right = Panel::with_entries(root.clone(), vec![make_entry("..", true, 0)]); + let mut state = State::for_testing(left, right); + state.active_panel_mut().cursor = 1; // "doomed.txt" + + // Delete key opens the confirm dialog; Enter confirms and spawns the job. + state.handle_global_input(&Input::Keyboard(vk::DELETE)); + assert!(matches!(state.dialog, Dialog::Delete { .. })); + state.handle_global_input(&Input::Keyboard(vk::RETURN)); + assert!(state.job_active(), "delete should have spawned a job"); + + poll_state_to_idle(&mut state); + + assert!(!file.exists(), "file should be deleted"); + assert!( + matches!(state.dialog, Dialog::None), + "dialog should close on success" + ); + // Panel was refreshed: the deleted entry is gone. + assert!( + !state + .active_panel() + .entries + .iter() + .any(|e| e.name == "doomed.txt") + ); + cleanup(&root); +} diff --git a/crates/ruf4/tests/test_state.rs b/crates/ruf4/tests/test_state.rs index c1b9f42..de02447 100644 --- a/crates/ruf4/tests/test_state.rs +++ b/crates/ruf4/tests/test_state.rs @@ -1,8 +1,10 @@ use std::path::PathBuf; use ruf4::panel::{Panel, make_entry}; +use ruf4::preview::Preview; use ruf4::state::{ActivePanel, Dialog, State}; -use ruf4_tui::input::{Input, kbmod, vk}; +use ruf4_tui::helpers::{Point, Size}; +use ruf4_tui::input::{Input, InputMouse, InputMouseState, kbmod, vk}; fn test_panel(names: &[(&str, bool)]) -> Panel { let mut entries = vec![make_entry("..", true, 0)]; @@ -196,6 +198,54 @@ fn test_command_line_backspace_clears() { assert!(!s.command_line_active); // auto-deactivate when empty } +#[test] +fn test_command_line_insert_at_cursor() { + let mut s = test_state(); + s.command_line_active = true; + s.command_line = "ac".to_string(); + s.cmd_cursor = 1; + s.handle_global_input(&Input::Text("b")); + assert_eq!(s.command_line, "abc"); + assert_eq!(s.cmd_cursor, 2); +} + +#[test] +fn test_command_line_delete_and_cursor_moves() { + let mut s = test_state(); + s.command_line_active = true; + s.command_line = "abc".to_string(); + // HOME then DELETE removes the first char (forward delete at cursor 0). + s.cmd_cursor = 3; + s.handle_global_input(&Input::Keyboard(vk::HOME)); + assert_eq!(s.cmd_cursor, 0); + s.handle_global_input(&Input::Keyboard(vk::DELETE)); + assert_eq!(s.command_line, "bc"); + // RIGHT then BACK removes the char before the cursor. + s.handle_global_input(&Input::Keyboard(vk::RIGHT)); + assert_eq!(s.cmd_cursor, 1); + s.handle_global_input(&Input::Keyboard(vk::BACK)); + assert_eq!(s.command_line, "c"); + assert_eq!(s.cmd_cursor, 0); + // END moves to the end. + s.handle_global_input(&Input::Keyboard(vk::END)); + assert_eq!(s.cmd_cursor, 1); +} + +#[test] +fn test_mkdir_dialog_text_editing() { + let mut s = test_state(); + s.handle_global_input(&Input::Keyboard(vk::F7)); // open MkDir + assert!(matches!(s.dialog, Dialog::MkDir { .. })); + s.handle_global_input(&Input::Text("ac")); + s.handle_global_input(&Input::Keyboard(vk::LEFT)); + s.handle_global_input(&Input::Text("b")); + if let Dialog::MkDir { name } = &s.dialog { + assert_eq!(name, "abc"); + } else { + panic!("expected MkDir dialog"); + } +} + // --- Insert / Shift+Space selection --- #[test] @@ -287,3 +337,82 @@ fn test_dialog_blocks_navigation() { // DOWN should be consumed by dialog handler, not navigation assert_eq!(s.active_panel().cursor, cursor_before); } + +// --- Quick view scrolling --- + +fn preview_with_lines(n: usize) -> Preview { + let mut p = Preview::empty(); + p.lines = (0..n).map(|i| format!("line {i}")).collect(); + p +} + +fn scroll_event(x: isize, dy: isize) -> Input<'static> { + Input::Mouse(InputMouse { + state: InputMouseState::Scroll, + modifiers: kbmod::NONE, + position: Point { x, y: 5 }, + scroll: Point { x: 0, y: dy }, + drag: false, + }) +} + +#[test] +fn test_quick_view_wheel_scrolls_preview() { + let mut s = test_state(); // active == Left, so the preview is on the right half + s.quick_view = true; + s.term_size = Size { + width: 80, + height: 24, + }; + s.preview = preview_with_lines(100); + + // Wheel down over the right half (x >= 40) scrolls the preview down. + s.handle_global_input(&scroll_event(60, 1)); + assert_eq!(s.preview_scroll, 3); + s.handle_global_input(&scroll_event(60, 1)); + assert_eq!(s.preview_scroll, 6); + // Wheel up scrolls back, saturating at 0. + s.handle_global_input(&scroll_event(60, -1)); + assert_eq!(s.preview_scroll, 3); + s.handle_global_input(&scroll_event(60, -1)); + s.handle_global_input(&scroll_event(60, -1)); + assert_eq!(s.preview_scroll, 0); +} + +#[test] +fn test_quick_view_wheel_over_file_list_moves_cursor_not_preview() { + let mut s = test_state(); // active == Left; the active file list is the left half + s.quick_view = true; + s.term_size = Size { + width: 80, + height: 24, + }; + s.preview = preview_with_lines(100); + s.preview_scroll = 5; + + let before = s.active_panel().cursor; + s.handle_global_input(&scroll_event(10, 1)); // left half = active file list + assert_eq!( + s.preview_scroll, 5, + "preview must not move when scrolling files" + ); + assert!( + s.active_panel().cursor >= before, + "file cursor should advance" + ); +} + +#[test] +fn test_preview_scroll_resets_on_file_change() { + let mut s = test_state(); + s.quick_view = true; + s.preview_scroll = 5; + s.preview_path = Some(PathBuf::from("/test/previous")); + s.active_panel_mut().cursor = 1; // selects a real different entry + + s.update_preview(); + assert_eq!( + s.preview_scroll, 0, + "a new previewed file starts at the top" + ); +} diff --git a/crates/stdext/src/arena/fs.rs b/crates/stdext/src/arena/fs.rs index 0943d9a..a2e8d8e 100644 --- a/crates/stdext/src/arena/fs.rs +++ b/crates/stdext/src/arena/fs.rs @@ -55,7 +55,7 @@ pub fn read_to_string>(arena: &Arena, path: P) -> io::Result(file: &mut T, buf: &mut [MaybeUninit]) -> io::Result { unsafe { - let buf_slice = from_raw_parts_mut(buf.as_mut_ptr() as *mut u8, buf.len()); + let buf_slice = from_raw_parts_mut(buf.as_mut_ptr().cast(), buf.len()); let n = file.read(buf_slice)?; Ok(n) } diff --git a/crates/stdext/src/arena/release.rs b/crates/stdext/src/arena/release.rs index 7473566..c39e712 100644 --- a/crates/stdext/src/arena/release.rs +++ b/crates/stdext/src/arena/release.rs @@ -228,7 +228,7 @@ impl Allocator for Arena { // Otherwise, we have to allocate a new area and copy it over. unsafe { let new_ptr = self.alloc_raw(new_size, align); - ptr::copy_nonoverlapping(old_ptr.as_ptr(), new_ptr.as_ptr() as *mut _, old_size); + ptr::copy_nonoverlapping(old_ptr.as_ptr(), new_ptr.as_ptr().cast(), old_size); new_ptr } } else { diff --git a/crates/stdext/src/collections/vec.rs b/crates/stdext/src/collections/vec.rs index 7bcd003..a2c44b3 100644 --- a/crates/stdext/src/collections/vec.rs +++ b/crates/stdext/src/collections/vec.rs @@ -149,7 +149,7 @@ impl<'a, T> BVec<'a, T> { #[inline] fn spare_mut_ptr(&mut self) -> *mut MaybeUninit { - unsafe { (self.ptr.as_ptr() as *mut MaybeUninit).add(self.len) } + unsafe { self.ptr.as_ptr().cast::>().add(self.len) } } /// View as a shared slice. @@ -342,7 +342,7 @@ impl<'a, T: Copy> BVec<'a, T> { unsafe { let dst = self.spare_mut_ptr(); self.len += add; - ptr::copy_nonoverlapping(other.as_ptr() as *const _, dst, add); + ptr::copy_nonoverlapping(other.as_ptr().cast(), dst, add); } } diff --git a/crates/stdext/src/helpers.rs b/crates/stdext/src/helpers.rs index 8619dac..8396cfc 100644 --- a/crates/stdext/src/helpers.rs +++ b/crates/stdext/src/helpers.rs @@ -142,13 +142,13 @@ fn vec_replace_impl(dst: &mut Vec, range: Range, src: &[T]) { /// Turns a [`&[u8]`] into a [`&[MaybeUninit]`]. #[inline(always)] pub const fn slice_as_uninit_ref(slice: &[T]) -> &[MaybeUninit] { - unsafe { slice::from_raw_parts(slice.as_ptr() as *const MaybeUninit, slice.len()) } + unsafe { slice::from_raw_parts(slice.as_ptr().cast(), slice.len()) } } /// Turns a [`&mut [T]`] into a [`&mut [MaybeUninit]`]. #[inline(always)] pub const fn slice_as_uninit_mut(slice: &mut [T]) -> &mut [MaybeUninit] { - unsafe { slice::from_raw_parts_mut(slice.as_mut_ptr() as *mut MaybeUninit, slice.len()) } + unsafe { slice::from_raw_parts_mut(slice.as_mut_ptr().cast(), slice.len()) } } /// A stable clone of [`String::from_utf8_lossy_owned`] (`string_from_utf8_lossy_owned`). diff --git a/crates/stdext/src/lib.rs b/crates/stdext/src/lib.rs index c85378b..860b6ad 100644 --- a/crates/stdext/src/lib.rs +++ b/crates/stdext/src/lib.rs @@ -3,6 +3,12 @@ //! Arena allocators. Small and fast. +#![cfg_attr( + target_arch = "loongarch64", + feature(stdarch_loongarch), + allow(clippy::incompatible_msrv) +)] + pub mod alloc; pub mod arena; pub mod collections; diff --git a/crates/stdext/src/simd/memset.rs b/crates/stdext/src/simd/memset.rs index 7e55915..923f563 100644 --- a/crates/stdext/src/simd/memset.rs +++ b/crates/stdext/src/simd/memset.rs @@ -31,27 +31,19 @@ pub fn memset(dst: &mut [T], val: T) { let beg = dst.as_mut_ptr(); let end = beg.add(dst.len()); let val = mem::transmute_copy::<_, u16>(&val); - memset_raw( - beg as *mut u8, - end as *mut u8, - val as u64 * 0x0001000100010001, - ); + memset_raw(beg.cast(), end.cast(), val as u64 * 0x0001000100010001); } 4 => { let beg = dst.as_mut_ptr(); let end = beg.add(dst.len()); let val = mem::transmute_copy::<_, u32>(&val); - memset_raw( - beg as *mut u8, - end as *mut u8, - val as u64 * 0x0000000100000001, - ); + memset_raw(beg.cast(), end.cast(), val as u64 * 0x0000000100000001); } 8 => { let beg = dst.as_mut_ptr(); let end = beg.add(dst.len()); let val = mem::transmute_copy::<_, u64>(&val); - memset_raw(beg as *mut u8, end as *mut u8, val); + memset_raw(beg.cast(), end.cast(), val); } _ => dst.fill(val), } @@ -80,19 +72,19 @@ unsafe fn memset_fallback(mut beg: *mut u8, end: *mut u8, val: u64) { let mut remaining = end.offset_from_unsigned(beg); while remaining >= 8 { - (beg as *mut u64).write_unaligned(val); + beg.cast::().write_unaligned(val); beg = beg.add(8); remaining -= 8; } if remaining >= 4 { // 4-7 bytes remaining - (beg as *mut u32).write_unaligned(val as u32); - (end.sub(4) as *mut u32).write_unaligned(val as u32); + beg.cast::().write_unaligned(val as u32); + end.sub(4).cast::().write_unaligned(val as u32); } else if remaining >= 2 { // 2-3 bytes remaining - (beg as *mut u16).write_unaligned(val as u16); - (end.sub(2) as *mut u16).write_unaligned(val as u16); + beg.cast::().write_unaligned(val as u16); + end.sub(2).cast::().write_unaligned(val as u16); } else if remaining >= 1 { // 1 byte remaining beg.write(val as u8); @@ -368,8 +360,8 @@ unsafe fn memset_neon(mut beg: *mut u8, end: *mut u8, val: u64) { loop { // Compiles to a single `stp` instruction. - vst1q_u64(beg as *mut _, fill); - vst1q_u64(beg.add(16) as *mut _, fill); + vst1q_u64(beg.cast(), fill); + vst1q_u64(beg.add(16).cast(), fill); beg = beg.add(32); remaining -= 32; @@ -382,20 +374,20 @@ unsafe fn memset_neon(mut beg: *mut u8, end: *mut u8, val: u64) { if remaining >= 16 { // 16-31 bytes remaining let fill = vdupq_n_u64(val); - vst1q_u64(beg as *mut _, fill); - vst1q_u64(end.sub(16) as *mut _, fill); + vst1q_u64(beg.cast(), fill); + vst1q_u64(end.sub(16).cast(), fill); } else if remaining >= 8 { // 8-15 bytes remaining - (beg as *mut u64).write_unaligned(val); - (end.sub(8) as *mut u64).write_unaligned(val); + beg.cast::().write_unaligned(val); + end.sub(8).cast::().write_unaligned(val); } else if remaining >= 4 { // 4-7 bytes remaining - (beg as *mut u32).write_unaligned(val as u32); - (end.sub(4) as *mut u32).write_unaligned(val as u32); + beg.cast::().write_unaligned(val as u32); + end.sub(4).cast::().write_unaligned(val as u32); } else if remaining >= 2 { // 2-3 bytes remaining - (beg as *mut u16).write_unaligned(val as u16); - (end.sub(2) as *mut u16).write_unaligned(val as u16); + beg.cast::().write_unaligned(val as u16); + end.sub(2).cast::().write_unaligned(val as u16); } else if remaining >= 1 { // 1 byte remaining beg.write(val as u8); diff --git a/crates/stdext/src/sys/unix.rs b/crates/stdext/src/sys/unix.rs index daa9752..1053c2d 100644 --- a/crates/stdext/src/sys/unix.rs +++ b/crates/stdext/src/sys/unix.rs @@ -26,7 +26,7 @@ pub unsafe fn virtual_reserve(size: usize) -> io::Result> { if ptr.is_null() || ptr::eq(ptr, libc::MAP_FAILED) { Err(io::Error::last_os_error()) } else { - Ok(NonNull::new_unchecked(ptr as *mut u8)) + Ok(NonNull::new_unchecked(ptr.cast())) } } } diff --git a/crates/tui/src/framebuffer.rs b/crates/tui/src/framebuffer.rs index 19bb8e0..40be489 100644 --- a/crates/tui/src/framebuffer.rs +++ b/crates/tui/src/framebuffer.rs @@ -115,6 +115,9 @@ pub struct Framebuffer { contrast_colors: [Cell<(StraightRgba, StraightRgba)>; CACHE_TABLE_SIZE], background_fill: StraightRgba, foreground_fill: StraightRgba, + /// When set, the next `flip` discards the previous frame so the whole screen + /// is re-emitted. Used after the alternate screen was left and re-entered. + force_redraw: bool, } impl Framebuffer { @@ -124,6 +127,7 @@ impl Framebuffer { indexed_colors: DEFAULT_THEME, buffers: Default::default(), frame_counter: 0, + force_redraw: false, auto_colors: [ DEFAULT_THEME[IndexedColor::Black as usize], DEFAULT_THEME[IndexedColor::BrightWhite as usize], @@ -164,18 +168,28 @@ impl Framebuffer { } } + /// Force the next `flip` to re-emit the entire screen, discarding the + /// incremental diff against the previous frame. + pub fn request_full_redraw(&mut self) { + self.force_redraw = true; + } + /// Begins a new frame with the given `size`. pub fn flip(&mut self, size: Size) { - if size != self.buffers[0].bg_bitmap.size { + let size_changed = size != self.buffers[0].bg_bitmap.size; + if size_changed { for buffer in &mut self.buffers { buffer.text = LineBuffer::new(size); buffer.bg_bitmap = Bitmap::new(size); buffer.fg_bitmap = Bitmap::new(size); buffer.attributes = AttributeBuffer::new(size); } + } + if size_changed || self.force_redraw { + self.force_redraw = false; let front = &mut self.buffers[self.frame_counter & 1]; - // Trigger a full redraw. (Yes, it's a hack.) + // Trigger a full redraw by making the previous frame mismatch. front.fg_bitmap.fill(StraightRgba::from_le(1)); // Trigger a cursor update as well, just to be sure. front.cursor = Cursor::new_invalid(); @@ -987,3 +1001,41 @@ impl Cursor { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn frame(fb: &mut Framebuffer, arena: &Arena, size: Size, text: &str) -> bool { + fb.flip(size); + fb.replace_text(0, 0, size.width, text); + !fb.render(arena).is_empty() + } + + #[test] + fn request_full_redraw_reemits_unchanged_frame() { + let arena = Arena::new(4 * stdext::MEBI).unwrap(); + let size = Size { + width: 12, + height: 3, + }; + let mut fb = Framebuffer::new(); + + // First frame emits output; an identical second frame diffs to nothing. + assert!(frame(&mut fb, &arena, size, "hello")); + assert!( + !frame(&mut fb, &arena, size, "hello"), + "unchanged frame should produce no output" + ); + + // After request_full_redraw the identical frame is re-emitted in full. + fb.request_full_redraw(); + assert!( + frame(&mut fb, &arena, size, "hello"), + "forced redraw must re-emit the whole screen" + ); + + // The flag is one-shot: the next unchanged frame is quiet again. + assert!(!frame(&mut fb, &arena, size, "hello")); + } +} diff --git a/crates/tui/src/hash.rs b/crates/tui/src/hash.rs index 84bc4f0..d06837c 100644 --- a/crates/tui/src/hash.rs +++ b/crates/tui/src/hash.rs @@ -91,11 +91,11 @@ unsafe fn wyr3(p: *const u8, k: usize) -> u64 { } unsafe fn wyr4(p: *const u8) -> u64 { - unsafe { (p as *const u32).read_unaligned() as u64 } + unsafe { p.cast::().read_unaligned() as u64 } } unsafe fn wyr8(p: *const u8) -> u64 { - unsafe { (p as *const u64).read_unaligned() } + unsafe { p.cast::().read_unaligned() } } // This is a weak mix function on its own. It may be worth considering diff --git a/crates/tui/src/helpers.rs b/crates/tui/src/helpers.rs index cdfd814..51bd2e0 100644 --- a/crates/tui/src/helpers.rs +++ b/crates/tui/src/helpers.rs @@ -48,6 +48,7 @@ pub const COORD_TYPE_SAFE_MAX: CoordType = (1 << (CoordType::BITS / 2 - 1)) - 1; /// A 2D point. Uses [`CoordType`]. #[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] +#[repr(C)] pub struct Point { pub x: CoordType, pub y: CoordType, @@ -62,6 +63,10 @@ impl Point { x: CoordType::MAX, y: CoordType::MAX, }; + + pub fn as_array(&mut self) -> &mut [CoordType; 2] { + unsafe { &mut *(self as *mut Self as *mut [CoordType; 2]) } + } } impl PartialOrd for Point { @@ -78,6 +83,7 @@ impl Ord for Point { /// A 2D size. Uses [`CoordType`]. #[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] +#[repr(C)] pub struct Size { pub width: CoordType, pub height: CoordType, @@ -96,6 +102,7 @@ impl Size { /// A 2D rectangle. Uses [`CoordType`]. #[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] +#[repr(C)] pub struct Rect { pub left: CoordType, pub top: CoordType, @@ -180,7 +187,7 @@ impl Rect { /// [`Read`] but with [`MaybeUninit`] buffers. pub fn file_read_uninit(file: &mut T, buf: &mut [MaybeUninit]) -> io::Result { unsafe { - let buf_slice = slice::from_raw_parts_mut(buf.as_mut_ptr() as *mut u8, buf.len()); + let buf_slice = slice::from_raw_parts_mut(buf.as_mut_ptr().cast::(), buf.len()); let n = file.read(buf_slice)?; Ok(n) } diff --git a/crates/tui/src/input.rs b/crates/tui/src/input.rs index 9221a64..08d0590 100644 --- a/crates/tui/src/input.rs +++ b/crates/tui/src/input.rs @@ -246,6 +246,8 @@ pub struct InputMouse { pub position: Point, /// Scroll delta. pub scroll: Point, + /// Whether the mouse is being dragged with a button held down. + pub drag: bool, } /// Primary result type of the parser. @@ -440,48 +442,10 @@ impl<'input> Iterator for Stream<'_, '_, 'input> { } } 'm' | 'M' if csi.private_byte == '<' => { - let btn = csi.params[0]; - let mut mouse = InputMouse { - state: InputMouseState::None, - modifiers: kbmod::NONE, - position: Default::default(), - scroll: Default::default(), - }; - - mouse.state = InputMouseState::None; - if (btn & 0x40) != 0 { - mouse.state = InputMouseState::Scroll; - mouse.scroll.y += if (btn & 0x01) != 0 { 3 } else { -3 }; - } else if csi.final_byte == 'M' { - const STATES: [InputMouseState; 4] = [ - InputMouseState::Left, - InputMouseState::Middle, - InputMouseState::Right, - InputMouseState::None, - ]; - mouse.state = STATES[(btn as usize) & 0x03]; - } - - mouse.modifiers = kbmod::NONE; - mouse.modifiers |= if (btn & 0x04) != 0 { - kbmod::SHIFT - } else { - kbmod::NONE - }; - mouse.modifiers |= if (btn & 0x08) != 0 { - kbmod::ALT - } else { - kbmod::NONE - }; - mouse.modifiers |= if (btn & 0x10) != 0 { - kbmod::CTRL - } else { - kbmod::NONE - }; - - mouse.position.x = csi.params[1] as CoordType - 1; - mouse.position.y = csi.params[2] as CoordType - 1; - return Some(Input::Mouse(mouse)); + return Self::parse_xterm_mouse( + &csi.params[..csi.param_count], + csi.final_byte, + ); } 'M' if csi.param_count == 0 => { self.parser.x10_mouse_want = true; @@ -561,38 +525,14 @@ impl<'input> Stream<'_, '_, 'input> { return None; } - let b = self.parser.x10_mouse_buf[0] as u32; - let x = self.parser.x10_mouse_buf[1] as CoordType - 0x21; - let y = self.parser.x10_mouse_buf[2] as CoordType - 0x21; - let action = match b & 0b11 { - 0 => InputMouseState::Left, - 1 => InputMouseState::Middle, - 2 => InputMouseState::Right, - _ => InputMouseState::None, - }; - let modifiers = { - let mut m = kbmod::NONE; - if (b & 0b00100) != 0 { - m |= kbmod::SHIFT; - } - if (b & 0b01000) != 0 { - m |= kbmod::ALT; - } - if (b & 0b10000) != 0 { - m |= kbmod::CTRL; - } - m - }; + let b = self.parser.x10_mouse_buf[0] as u16 - 0x20; + let x = self.parser.x10_mouse_buf[1] as u16 - 0x20; + let y = self.parser.x10_mouse_buf[2] as u16 - 0x20; self.parser.x10_mouse_want = false; self.parser.x10_mouse_len = 0; - Some(Input::Mouse(InputMouse { - state: action, - modifiers, - position: Point { x, y }, - scroll: Default::default(), - })) + Self::parse_xterm_mouse(&[b, x, y], 'M') } fn parse_modifiers(csi: &vt::Csi) -> InputKeyMod { @@ -609,4 +549,67 @@ impl<'input> Stream<'_, '_, 'input> { } modifiers } + + fn parse_xterm_mouse(params: &[u16], final_byte: char) -> Option> { + const SHIFT: u16 = 0x04; + const ALT: u16 = 0x08; + const CTRL: u16 = 0x10; + const MOTION: u16 = 0x20; + const WHEEL: u16 = 0x40; + const MODIFIERS: u16 = SHIFT | ALT | CTRL; + + let &[btn, x, y, ..] = params else { + return None; + }; + + let kind = btn & !MODIFIERS; + let x = x as CoordType - 1; + let y = y as CoordType - 1; + let mut mouse = InputMouse { + state: InputMouseState::None, + modifiers: kbmod::NONE, + position: Point { x, y }, + scroll: Default::default(), + drag: false, + }; + + if final_byte == 'm' { + // M = down, m = release. + // I know there's an InputMouseState::Release, but that's because the internals of tui.rs + // have leaked into intput.rs. input.rs indicates release by the absence of buttons being + // held, which is InputMouseState::None. This makes it more reliable in my opinion. + } else if (WHEEL..WHEEL + 4).contains(&kind) { + let delta = if (kind & 1) != 0 { 3 } else { -3 }; + let idx = if (kind & 2) != 0 { 0 } else { 1 }; + mouse.scroll.as_array()[idx] += delta; + mouse.state = InputMouseState::Scroll; + } else if (kind & !MOTION) < 3 { + match kind & 3 { + 0 => mouse.state = InputMouseState::Left, + 1 => mouse.state = InputMouseState::Middle, + 2 => mouse.state = InputMouseState::Right, + _ => {} + } + mouse.drag = (kind & MOTION) != 0; + } + + mouse.modifiers = kbmod::NONE; + mouse.modifiers |= if (btn & SHIFT) != 0 { + kbmod::SHIFT + } else { + kbmod::NONE + }; + mouse.modifiers |= if (btn & ALT) != 0 { + kbmod::ALT + } else { + kbmod::NONE + }; + mouse.modifiers |= if (btn & CTRL) != 0 { + kbmod::CTRL + } else { + kbmod::NONE + }; + + Some(Input::Mouse(mouse)) + } } diff --git a/crates/tui/src/simd/memchr2.rs b/crates/tui/src/simd/memchr2.rs index c005903..bd80423 100644 --- a/crates/tui/src/simd/memchr2.rs +++ b/crates/tui/src/simd/memchr2.rs @@ -207,7 +207,7 @@ unsafe fn memchr2_neon(needle1: u8, needle2: u8, mut beg: *const u8, end: *const let n2 = vdupq_n_u8(needle2); loop { - let v = vld1q_u8(beg as *const _); + let v = vld1q_u8(beg.cast()); let a = vceqq_u8(v, n1); let b = vceqq_u8(v, n2); let c = vorrq_u8(a, b); diff --git a/crates/tui/src/sys/unix.rs b/crates/tui/src/sys/unix.rs index 5b079de..aac92a3 100644 --- a/crates/tui/src/sys/unix.rs +++ b/crates/tui/src/sys/unix.rs @@ -73,60 +73,85 @@ pub fn switch_modes() -> io::Result<()> { // Get the original terminal modes so we can disable raw mode on exit. let mut termios = MaybeUninit::::uninit(); check_int_return(libc::tcgetattr(STATE.stdout, termios.as_mut_ptr()))?; - let mut termios = termios.assume_init(); + let termios = termios.assume_init(); STATE.stdout_initial_termios = Some(termios); - termios.c_iflag &= !( - // When neither IGNBRK... - libc::IGNBRK - // ...nor BRKINT are set, a BREAK reads as a null byte ('\0'), ... - | libc::BRKINT - // ...except when PARMRK is set, in which case it reads as the sequence \377 \0 \0. - | libc::PARMRK - // Disable input parity checking. - | libc::INPCK - // Disable stripping of eighth bit. - | libc::ISTRIP - // Disable mapping of NL to CR on input. - | libc::INLCR - // Disable ignoring CR on input. - | libc::IGNCR - // Disable mapping of CR to NL on input. - | libc::ICRNL - // Disable software flow control. - | libc::IXON - ); - // Disable output processing. - termios.c_oflag &= !libc::OPOST; - termios.c_cflag &= !( - // Reset character size mask. - libc::CSIZE - // Disable parity generation. - | libc::PARENB - ); - // Set character size back to 8 bits. - termios.c_cflag |= libc::CS8; - termios.c_lflag &= !( - // Disable signal generation (SIGINT, SIGTSTP, SIGQUIT). - libc::ISIG - // Disable canonical mode (line buffering). - | libc::ICANON - // Disable echoing of input characters. - | libc::ECHO - // Disable echoing of NL. - | libc::ECHONL - // Disable extended input processing (e.g. Ctrl-V). - | libc::IEXTEN - ); - // Set the terminal to raw mode. - termios.c_lflag &= !(libc::ICANON | libc::ECHO); - check_int_return(libc::tcsetattr(STATE.stdout, libc::TCSANOW, &termios))?; + let raw = raw_termios(termios); + check_int_return(libc::tcsetattr(STATE.stdout, libc::TCSANOW, &raw))?; Ok(()) } } +/// Derive raw-mode terminal settings from the initial (cooked) `termios`. +fn raw_termios(mut termios: libc::termios) -> libc::termios { + termios.c_iflag &= !( + // When neither IGNBRK... + libc::IGNBRK + // ...nor BRKINT are set, a BREAK reads as a null byte ('\0'), ... + | libc::BRKINT + // ...except when PARMRK is set, in which case it reads as the sequence \377 \0 \0. + | libc::PARMRK + // Disable input parity checking. + | libc::INPCK + // Disable stripping of eighth bit. + | libc::ISTRIP + // Disable mapping of NL to CR on input. + | libc::INLCR + // Disable ignoring CR on input. + | libc::IGNCR + // Disable mapping of CR to NL on input. + | libc::ICRNL + // Disable software flow control. + | libc::IXON + ); + // Disable output processing. + termios.c_oflag &= !libc::OPOST; + termios.c_cflag &= !( + // Reset character size mask. + libc::CSIZE + // Disable parity generation. + | libc::PARENB + ); + // Set character size back to 8 bits. + termios.c_cflag |= libc::CS8; + termios.c_lflag &= !( + // Disable signal generation (SIGINT, SIGTSTP, SIGQUIT). + libc::ISIG + // Disable canonical mode (line buffering). + | libc::ICANON + // Disable echoing of input characters. + | libc::ECHO + // Disable echoing of NL. + | libc::ECHONL + // Disable extended input processing (e.g. Ctrl-V). + | libc::IEXTEN + ); + termios +} + +/// Restore the terminal to the modes present before [`switch_modes`], so an +/// external program can run with normal line discipline. Pair with [`resume`]. +pub fn suspend() { + unsafe { + #[allow(static_mut_refs)] + if let Some(termios) = STATE.stdout_initial_termios.as_ref() { + libc::tcsetattr(STATE.stdout, libc::TCSAFLUSH, termios); + } + } +} + +/// Re-enter raw mode after [`suspend`]. +pub fn resume() { + unsafe { + if let Some(termios) = STATE.stdout_initial_termios { + let raw = raw_termios(termios); + libc::tcsetattr(STATE.stdout, libc::TCSANOW, &raw); + } + } +} + pub struct Deinit; impl Drop for Deinit { diff --git a/crates/tui/src/sys/windows.rs b/crates/tui/src/sys/windows.rs index 430ac90..ce22599 100644 --- a/crates/tui/src/sys/windows.rs +++ b/crates/tui/src/sys/windows.rs @@ -229,6 +229,38 @@ impl Drop for Deinit { } } +/// Restore the console modes present before [`switch_modes`], so an external +/// program can run with normal line discipline. Pair with [`resume`]. +pub fn suspend() { + unsafe { + if STATE.stdin_mode_old != INVALID_CONSOLE_MODE { + Console::SetConsoleMode(STATE.stdin, STATE.stdin_mode_old); + } + if STATE.stdout_mode_old != INVALID_CONSOLE_MODE { + Console::SetConsoleMode(STATE.stdout, STATE.stdout_mode_old); + } + } +} + +/// Re-enter raw/VT console modes after [`suspend`]. +pub fn resume() { + unsafe { + Console::SetConsoleMode( + STATE.stdin, + Console::ENABLE_WINDOW_INPUT + | Console::ENABLE_EXTENDED_FLAGS + | Console::ENABLE_VIRTUAL_TERMINAL_INPUT, + ); + Console::SetConsoleMode( + STATE.stdout, + Console::ENABLE_PROCESSED_OUTPUT + | Console::ENABLE_WRAP_AT_EOL_OUTPUT + | Console::ENABLE_VIRTUAL_TERMINAL_PROCESSING + | Console::DISABLE_NEWLINE_AUTO_RETURN, + ); + } +} + /// During startup we need to get the window size from the terminal. /// Because I didn't want to type a bunch of code, this function tells /// [`read_stdin`] to inject a fake sequence, which gets picked up by diff --git a/crates/tui/src/tui.rs b/crates/tui/src/tui.rs index bf384ef..47a2ae7 100644 --- a/crates/tui/src/tui.rs +++ b/crates/tui/src/tui.rs @@ -861,6 +861,13 @@ impl Tui { self.settling_want = (self.settling_have + 1).min(20); } + /// Force the next [`Tui::render`] to re-emit the whole screen. Use after the + /// terminal was taken over by another program (the alternate screen was left + /// and re-entered), where the incremental diff would otherwise draw nothing. + pub fn request_full_redraw(&mut self) { + self.framebuffer.request_full_redraw(); + } + /// Renders the last frame into the framebuffer and returns the VT output. pub fn render<'a>(&mut self, arena: &'a Arena) -> BString<'a> { self.framebuffer.flip(self.size);