diff --git a/README.md b/README.md index 9d4bb10..0622716 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,10 @@ less-intuitive mouse controls are: | Esc | Cancel dropdown | | j/Down arrow | Move down | | k/Up arrow | Move up | +| PageDown | Move down a page | +| PageUp | Move up a page | +| End | Move to last item | +| Home | Move to first item | | H/Shift+Tab | Select previous tab | | L/Tab | Select next tab | | ` (Backtick) | Set volume 0% | diff --git a/src/app.rs b/src/app.rs index 6c973d2..73bc1ad 100644 --- a/src/app.rs +++ b/src/app.rs @@ -49,6 +49,10 @@ pub enum Action { Exit, MoveUp, MoveDown, + PageUp, + PageDown, + MoveFirst, + MoveLast, ToggleMute, SetRelativeVolume(f32), SetDefault, @@ -72,6 +76,10 @@ impl std::fmt::Display for Action { Action::SelectTab(tab) => write!(f, "Select {tab} tab"), Action::MoveUp => write!(f, "Move cursor up"), Action::MoveDown => write!(f, "Move cursor down"), + Action::PageUp => write!(f, "Move cursor up a page"), + Action::PageDown => write!(f, "Move cursor down a page"), + Action::MoveFirst => write!(f, "Move cursor to first item"), + Action::MoveLast => write!(f, "Move cursor to last item"), Action::TabLeft => write!(f, "Select previous tab"), Action::TabRight => write!(f, "Select next tab"), Action::CloseDropdown => write!(f, "Close menu"), @@ -96,6 +104,13 @@ impl std::fmt::Display for Action { } impl Action { + // The help menu doesn't track how many lines actually fit on screen (it's + // computed inline at render time from the terminal area), so PageUp/ + // PageDown there just jump a fixed number of lines rather than a true + // page - close enough for a scrollable overlay, and avoids threading + // layout information through for a fairly minor case. + const HELP_PAGE_STEP: u16 = 10; + fn format_percentage(vol: f32) -> u16 { (vol * 100.0).trunc() as u16 } @@ -556,6 +571,27 @@ impl Handle for Action { *help_position = help_position.saturating_sub(1); return Ok(true); } + Action::PageDown => { + *help_position = + help_position.saturating_add(Self::HELP_PAGE_STEP); + return Ok(true); + } + Action::PageUp => { + *help_position = + help_position.saturating_sub(Self::HELP_PAGE_STEP); + return Ok(true); + } + Action::MoveLast => { + // Clamped to the real bottom by HelpWidget::render() - + // see the "Fix help_position if we are scrolled beyond + // the bottom of the list" comment there. + *help_position = u16::MAX; + return Ok(true); + } + Action::MoveFirst => { + *help_position = 0; + return Ok(true); + } Action::ActivateDropdown | Action::CloseDropdown | Action::Help => { @@ -585,6 +621,18 @@ impl Handle for Action { Action::MoveUp => { current_list!(app).up(&app.view); } + Action::PageDown => { + current_list!(app).page_down(&app.view); + } + Action::PageUp => { + current_list!(app).page_up(&app.view); + } + Action::MoveFirst => { + current_list!(app).first(&app.view); + } + Action::MoveLast => { + current_list!(app).last(&app.view); + } Action::TabLeft => { app.current_tab_index = app .current_tab_index @@ -1021,6 +1069,48 @@ mod tests { assert_eq!(app.help_position, Some(0)); } + #[test] + fn help_page_underflow() { + let wirehose = mock::WirehoseHandle::default(); + let mut app = fixture(&wirehose); + + assert!(Action::Help.handle(&mut app).unwrap()); + assert_eq!(app.help_position, Some(0)); + + assert!(Action::PageUp.handle(&mut app).unwrap()); + assert_eq!(app.help_position, Some(0)); + } + + #[test] + fn help_page_up_down() { + let wirehose = mock::WirehoseHandle::default(); + let mut app = fixture(&wirehose); + + assert!(Action::Help.handle(&mut app).unwrap()); + assert_eq!(app.help_position, Some(0)); + + assert!(Action::PageDown.handle(&mut app).unwrap()); + assert_eq!(app.help_position, Some(Action::HELP_PAGE_STEP)); + + assert!(Action::PageUp.handle(&mut app).unwrap()); + assert_eq!(app.help_position, Some(0)); + } + + #[test] + fn help_first_last() { + let wirehose = mock::WirehoseHandle::default(); + let mut app = fixture(&wirehose); + + assert!(Action::Help.handle(&mut app).unwrap()); + assert_eq!(app.help_position, Some(0)); + + assert!(Action::MoveLast.handle(&mut app).unwrap()); + assert_eq!(app.help_position, Some(u16::MAX)); + + assert!(Action::MoveFirst.handle(&mut app).unwrap()); + assert_eq!(app.help_position, Some(0)); + } + #[test] fn help_toggle() { let wirehose = mock::WirehoseHandle::default(); diff --git a/src/config/keybinding.rs b/src/config/keybinding.rs index 73334e7..d3a0fa3 100644 --- a/src/config/keybinding.rs +++ b/src/config/keybinding.rs @@ -29,6 +29,10 @@ impl Keybinding { (event(KeyCode::Down), Action::MoveDown), (event(KeyCode::Char('k')), Action::MoveUp), (event(KeyCode::Up), Action::MoveUp), + (event(KeyCode::PageDown), Action::PageDown), + (event(KeyCode::PageUp), Action::PageUp), + (event(KeyCode::End), Action::MoveLast), + (event(KeyCode::Home), Action::MoveFirst), (event(KeyCode::Char('H')), Action::TabLeft), (event(KeyCode::Char('L')), Action::TabRight), ( diff --git a/src/object_list.rs b/src/object_list.rs index c43947c..a868504 100644 --- a/src/object_list.rs +++ b/src/object_list.rs @@ -39,6 +39,11 @@ pub struct ObjectList { pub dropdown_state: ListState, /// Targets pub targets: Vec<(view::Target, String)>, + /// Number of objects visible at once, as of the last `update()` call. + /// Cached here (rather than threaded into every action handler) since + /// it only changes on terminal resize and `update()` already recomputes + /// it every frame from the current area. + page_size: usize, } impl ObjectList { @@ -74,6 +79,68 @@ impl ObjectList { } } + /// Move the selection down by a page (however many objects are visible + /// at once), or to the last object if fewer than a page remain. Leaves + /// the dropdown selection to select_next() the same as `down()`, since + /// a "page" of dropdown targets isn't a meaningful concept - dropdowns + /// are always small. + pub fn page_down(&mut self, view: &view::View) { + if self.dropdown_state.selected().is_some() { + self.dropdown_state.select_next(); + return; + } + let mut new_selected = self.selected; + for _ in 0..self.page_size.max(1) { + match view.next_id(self.list_kind, new_selected) { + Some(id) => new_selected = Some(id), + None => break, + } + } + if new_selected.is_some() { + self.select(new_selected); + } + } + + /// Move the selection up by a page. See `page_down()`. + pub fn page_up(&mut self, view: &view::View) { + if self.dropdown_state.selected().is_some() { + self.dropdown_state.select_previous(); + return; + } + let mut new_selected = self.selected; + for _ in 0..self.page_size.max(1) { + match view.previous_id(self.list_kind, new_selected) { + Some(id) => new_selected = Some(id), + None => break, + } + } + if new_selected.is_some() { + self.select(new_selected); + } + } + + /// Jump the selection straight to the first object in the list. + pub fn first(&mut self, view: &view::View) { + if self.dropdown_state.selected().is_some() { + self.dropdown_state.select_first(); + return; + } + if let Some(&id) = view.object_ids(self.list_kind).first() { + self.select(Some(id)); + } + } + + /// Jump the selection straight to the last object in the list. + pub fn last(&mut self, view: &view::View) { + if self.dropdown_state.selected().is_some() { + self.dropdown_state.select_last(); + return; + } + if let Some(&id) = view.object_ids(self.list_kind).last() { + self.select(Some(id)); + } + } + fn dropdown_open(&mut self, view: &view::View) { let targets = match self.list_kind { ListKind::Node(_) => self @@ -265,6 +332,7 @@ impl ObjectList { let objects_len = view.len(self.list_kind); let visible_count = self.visible_count(&area); + self.page_size = visible_count; // If objects were removed and the viewport is now below the visible // objects, move the viewport up so that the bottom of the object list @@ -691,6 +759,95 @@ mod tests { assert_eq!(object_list.selected, Some(ObjectId::from_raw_id(10))); } + #[test] + fn object_list_page_down_page_up() { + let (state, wirehose) = init(); + let view = View::from( + &wirehose, + &state, + &config::Names::default(), + &Vec::new(), + ); + + let height = NodeWidget::height() + NodeWidget::spacing(); + // + 2 for header and footer; 3 items visible at once + let rect = Rect::new(0, 0, 80, height * 3 + 2); + let mut object_list = + ObjectList::new(ListKind::Node(NodeKind::All), None); + + // Select first object, and let update() compute page_size (3) from + // the 3-item-tall rect above. + object_list.down(&view); + object_list.update(rect, &view); + assert_eq!(object_list.selected, Some(ObjectId::from_raw_id(1))); + + object_list.page_down(&view); + assert_eq!(object_list.selected, Some(ObjectId::from_raw_id(4))); + + object_list.page_up(&view); + assert_eq!(object_list.selected, Some(ObjectId::from_raw_id(1))); + } + + #[test] + fn object_list_page_down_overflow() { + let (state, wirehose) = init(); + let view = View::from( + &wirehose, + &state, + &config::Names::default(), + &Vec::new(), + ); + + let height = NodeWidget::height() + NodeWidget::spacing(); + let rect = Rect::new(0, 0, 80, height * 3 + 2); + let mut object_list = + ObjectList::new(ListKind::Node(NodeKind::All), None); + + object_list.down(&view); + object_list.update(rect, &view); + + // Page down well past the last of the 10 mock nodes. + for _ in 0..10 { + object_list.page_down(&view); + object_list.update(rect, &view); + } + assert_eq!(object_list.selected, Some(ObjectId::from_raw_id(10))); + + for _ in 0..10 { + object_list.page_up(&view); + object_list.update(rect, &view); + } + assert_eq!(object_list.selected, Some(ObjectId::from_raw_id(1))); + } + + #[test] + fn object_list_first_last() { + let (state, wirehose) = init(); + let view = View::from( + &wirehose, + &state, + &config::Names::default(), + &Vec::new(), + ); + + let height = NodeWidget::height() + NodeWidget::spacing(); + let rect = Rect::new(0, 0, 80, height * 3 + 2); + let mut object_list = + ObjectList::new(ListKind::Node(NodeKind::All), None); + + // Start in the middle of the 10 mock nodes. + object_list.down(&view); + object_list.update(rect, &view); + object_list.page_down(&view); + object_list.update(rect, &view); + + object_list.last(&view); + assert_eq!(object_list.selected, Some(ObjectId::from_raw_id(10))); + + object_list.first(&view); + assert_eq!(object_list.selected, Some(ObjectId::from_raw_id(1))); + } + #[test] fn visible_objects_changes_with_scroll() { let (state, wirehose) = init(); diff --git a/wiremix.toml b/wiremix.toml index 218653b..49c1f07 100644 --- a/wiremix.toml +++ b/wiremix.toml @@ -101,6 +101,14 @@ keybindings = [ # Select the previous item { key = { Char = "k" }, action = "MoveUp" }, { key = "Up", action = "MoveUp" }, + # Select the item a page down (however many items are visible at once) + { key = "PageDown", action = "PageDown" }, + # Select the item a page up + { key = "PageUp", action = "PageUp" }, + # Select the last item + { key = "End", action = "MoveLast" }, + # Select the first item + { key = "Home", action = "MoveFirst" }, # Select the next tab { key = { Char = "L" }, action = "TabRight" }, { key = "Tab", action = "TabRight" },