diff --git a/README.md b/README.md index 2a86ad3..cbd927f 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,12 @@ Browse Modrinth and CurseForge modpacks or import Modrinth, CurseForge, and Mult --- +## navigation + +Use `Ctrl` + arrow keys to move between the Instances, Content, Accounts, +Settings, and Overview panels. In Content, use plain `Left` / `Right` or `h` / `l` +to switch tabs, and `Tab` to switch between installed content and discovery. + ## authentication rmcl uses its own Microsoft client ID for Minecraft account authentication. diff --git a/src/tui/event.rs b/src/tui/event.rs index 599c65d..001b7bd 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -763,6 +763,19 @@ impl App { if text_input_active { return repeatable_text_key(key); } + if key.modifiers.contains(KeyModifiers::CONTROL) + && matches!(key.code, KeyCode::Left | KeyCode::Right) + && matches!( + self.focused, + FocusedArea::Instances + | FocusedArea::Content + | FocusedArea::Account + | FocusedArea::Settings + | FocusedArea::Overview + ) + { + return true; + } if vertical_navigation { return true; } diff --git a/src/tui/input.rs b/src/tui/input.rs index b4bd383..b410256 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -522,6 +522,48 @@ impl App { } } + if key_event.modifiers.contains(KeyModifiers::CONTROL) + && matches!( + key_event.code, + KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right + ) + && match self.focused { + FocusedArea::Instances => self.instances_state.renaming.is_none(), + FocusedArea::Content => !self.active_discovery_state().is_some_and(|state| { + state.version_popup.is_some() || state.project_page_open() + }), + FocusedArea::Account => { + matches!(self.account_state.add_mode, widgets::account::AddMode::None) + } + FocusedArea::Settings => { + matches!( + self.settings_state.add_mode, + widgets::settings::AddMode::None + ) + } + FocusedArea::Overview => true, + _ => false, + } + { + self.focused = + match (self.focused, key_event.code) { + (FocusedArea::Instances, KeyCode::Right) => FocusedArea::Content, + (FocusedArea::Content, KeyCode::Left) + | (FocusedArea::Account, KeyCode::Left) => FocusedArea::Instances, + (FocusedArea::Content, KeyCode::Down) + | (FocusedArea::Settings, KeyCode::Left) => FocusedArea::Account, + (FocusedArea::Account, KeyCode::Right) + | (FocusedArea::Overview, KeyCode::Left) => FocusedArea::Settings, + (FocusedArea::Settings, KeyCode::Right) => FocusedArea::Overview, + ( + FocusedArea::Account | FocusedArea::Settings | FocusedArea::Overview, + KeyCode::Up, + ) => FocusedArea::Content, + (focused, _) => focused, + }; + return Ok(()); + } + if self.focused == FocusedArea::Content && self.content_mode == widgets::content::ContentMode::Installed && let Some(state) = self.active_discovery_state_mut() diff --git a/src/tui/tests/flows.rs b/src/tui/tests/flows.rs index 6c9b3de..a6a6631 100644 --- a/src/tui/tests/flows.rs +++ b/src/tui/tests/flows.rs @@ -1,7 +1,9 @@ // SPDX-FileCopyrightText: 2026 Constantin Bauer // SPDX-License-Identifier: GPL-3.0-only -use crossterm::event::{KeyCode, MouseButton, MouseEvent, MouseEventKind}; +use crossterm::event::{ + KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, +}; use std::path::{Path, PathBuf}; use super::harness::UiHarness; @@ -34,6 +36,85 @@ fn remote_project_links_use_http_urls_and_encode_shell_metacharacters() { } } +#[test] +fn ctrl_arrows_navigate_adjacent_panels_without_changing_content_tabs() { + let mut ui = UiHarness::new(); + let panels = [ + FocusedArea::Instances, + FocusedArea::Content, + FocusedArea::Account, + FocusedArea::Settings, + FocusedArea::Overview, + ]; + let directions = [KeyCode::Up, KeyCode::Down, KeyCode::Left, KeyCode::Right]; + let destinations = [ + [panels[0], panels[0], panels[0], panels[1]], + [panels[1], panels[2], panels[0], panels[1]], + [panels[1], panels[2], panels[0], panels[3]], + [panels[1], panels[3], panels[2], panels[4]], + [panels[1], panels[4], panels[3], panels[4]], + ]; + for (panel, expected) in panels.into_iter().zip(destinations) { + for (direction, destination) in directions.into_iter().zip(expected) { + for kind in [KeyEventKind::Press, KeyEventKind::Repeat] { + ui.app.focused = panel; + assert!(ui.key_event(KeyEvent::new_with_kind( + direction, + KeyModifiers::CONTROL, + kind, + ))); + assert_eq!(ui.app.focused, destination, "{panel:?} {direction:?}"); + assert_eq!(ui.app.content_tab, ContentTab::Mods); + } + } + } + + ui.app.focused = FocusedArea::Content; + for mode in [ContentMode::Installed, ContentMode::Discover] { + ui.app.content_mode = mode; + ui.key(KeyCode::Right); + assert_eq!(ui.app.content_tab, ContentTab::ResourcePacks); + ui.key(KeyCode::Left); + assert_eq!(ui.app.content_tab, ContentTab::Mods); + assert_eq!(ui.app.focused, FocusedArea::Content); + } +} + +#[test] +fn ctrl_arrows_do_not_escape_modal_input() { + let mut ui = UiHarness::new(); + for panel in [FocusedArea::ConfirmDelete, FocusedArea::OverviewExpanded] { + ui.app.focused = panel; + ui.key_with(KeyCode::Down, KeyModifiers::CONTROL); + assert_eq!(ui.app.focused, panel); + } + + ui.app.focused = FocusedArea::Instances; + ui.key(KeyCode::Char('a')); + ui.key_with(KeyCode::Down, KeyModifiers::CONTROL); + assert_eq!(ui.app.focused, FocusedArea::Popup); + ui.key(KeyCode::Esc); + ui.key(KeyCode::Char('m')); + ui.key_with(KeyCode::Down, KeyModifiers::CONTROL); + assert_eq!(ui.app.focused, FocusedArea::ImportPopup); + ui.key(KeyCode::Esc); + + ui.app.focused = FocusedArea::Account; + ui.key(KeyCode::Char('a')); + ui.key_with(KeyCode::Right, KeyModifiers::CONTROL); + assert_eq!(ui.app.focused, FocusedArea::Account); + + ui.app.focused = FocusedArea::Settings; + ui.key(KeyCode::Char('a')); + ui.key_with(KeyCode::Right, KeyModifiers::CONTROL); + assert_eq!(ui.app.focused, FocusedArea::Settings); + + ui.app.focused = FocusedArea::Instances; + ui.app.instances_state.renaming = Some("Renaming".to_owned()); + ui.key_with(KeyCode::Right, KeyModifiers::CONTROL); + assert_eq!(ui.app.focused, FocusedArea::Instances); +} + #[test] fn global_navigation_returns_from_log_overlay() { let mut ui = UiHarness::new(); @@ -170,6 +251,10 @@ fn installed_popup_navigation_preserves_local_filters() { assert!(ui.app.mods_discovery_state.local_mode); let filters_before = ui.app.mods_discovery_state.filters.clone(); + ui.key_with(KeyCode::Left, KeyModifiers::CONTROL); + assert_eq!(ui.app.focused, FocusedArea::Content); + assert!(ui.app.mods_discovery_state.version_popup.is_some()); + // Navigating the popup versions must not flip the state into discovery // mode (which used to swap the filters and refresh the background list). ui.key(KeyCode::Char('j'));