diff --git a/src/ui.rs b/src/ui.rs index d4d7ff7..07d220a 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -123,8 +123,10 @@ pub enum Event { PageUp, PageDown, FocusPrev, + FocusPrevSameKind, // focus on the previous item of the same kind (i.e. file, section, line) FocusPrevPage, FocusNext, + FocusNextSameKind, // focus on the next item of the same kind FocusNextPage, FocusInner, FocusOuter, @@ -215,6 +217,19 @@ impl From for Event { state: _, }) => Self::FocusNext, + Event::Key(KeyEvent { + code: KeyCode::PageUp, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: _, + }) => Self::FocusPrevSameKind, + Event::Key(KeyEvent { + code: KeyCode::PageDown, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: _, + }) => Self::FocusNextSameKind, + Event::Key(KeyEvent { code: KeyCode::Left | KeyCode::Char('h'), modifiers: KeyModifiers::NONE, @@ -722,6 +737,14 @@ impl<'state, 'input> Recorder<'state, 'input> { label: Cow::Borrowed("Next item (down, j)"), event: Event::FocusNext, }, + MenuItem { + label: Cow::Borrowed("Previous item of the same kind (page-up)"), + event: Event::FocusPrevSameKind, + }, + MenuItem { + label: Cow::Borrowed("Next item of the same kind (page-down)"), + event: Event::FocusNextSameKind, + }, MenuItem { label: Cow::Borrowed("Outer item (left, h)"), event: Event::FocusOuter, @@ -1040,6 +1063,8 @@ impl<'state, 'input> Recorder<'state, 'input> { | Event::PageDown | Event::FocusPrev | Event::FocusNext + | Event::FocusPrevSameKind + | Event::FocusNextSameKind | Event::FocusPrevPage | Event::FocusNextPage | Event::ToggleAll @@ -1082,6 +1107,22 @@ impl<'state, 'input> Recorder<'state, 'input> { ensure_in_viewport: true, } } + (None, Event::FocusPrevSameKind) => { + let selection_key = + self.select_prev_or_next_of_same_kind(/*select_previous=*/ true); + StateUpdate::SelectItem { + selection_key, + ensure_in_viewport: true, + } + } + (None, Event::FocusNextSameKind) => { + let selection_key = + self.select_prev_or_next_of_same_kind(/*select_previous=*/ false); + StateUpdate::SelectItem { + selection_key, + ensure_in_viewport: true, + } + } (None, Event::FocusPrevPage) => { let selection_key = self.select_prev_page(term_height, drawn_rects); StateUpdate::SelectItem { @@ -1293,6 +1334,34 @@ impl<'state, 'input> Recorder<'state, 'input> { } } + // Returns the previous or next SelectionKey of the same kind as the current + // selection key. If there are no other keys of the same kind, the current + // key is returned instead. If `select_previous` is true, the previous key + // is returned. Otherwise, the next key is returned. + fn select_prev_or_next_of_same_kind(&self, select_previous: bool) -> SelectionKey { + let (keys, index) = self.find_selection(); + let iterate_keys_with_wrap_around = |i| -> Box> { + let forward_iter = keys[i + 1..] // Skip the current key + .iter() + .chain(keys[..i].iter()); + if select_previous { + return Box::new(forward_iter.rev()); + } + Box::new(forward_iter) + }; + match index { + None => self.first_selection_key(), + Some(index) => { + match iterate_keys_with_wrap_around(index) + .find(|k| std::mem::discriminant(*k) == std::mem::discriminant(&keys[index])) + { + None => keys[index], + Some(key) => *key, + } + } + } + } + fn select_prev_page( &self, term_height: usize, diff --git a/tests/test_scm_record.rs b/tests/test_scm_record.rs index 76e2693..13ab655 100644 --- a/tests/test_scm_record.rs +++ b/tests/test_scm_record.rs @@ -2817,6 +2817,364 @@ fn test_quit_dialog_when_commit_message_provided() -> eyre::Result<()> { Ok(()) } +#[test] +fn test_prev_same_kind() -> eyre::Result<()> { + let initial = TestingScreenshot::default(); + let to_baz = TestingScreenshot::default(); + let to_baz_section = TestingScreenshot::default(); + let to_bar_section = TestingScreenshot::default(); + let to_baz_lines = TestingScreenshot::default(); + let mut input = TestingInput::new( + 80, + 20, + [ + Event::ExpandAll, + initial.event(), + // Moves the current item from foo/bar to baz + Event::FocusPrevSameKind, + to_baz.event(), + Event::FocusInner, + to_baz_section.event(), + Event::FocusPrevSameKind, + to_bar_section.event(), + Event::FocusInner, + Event::FocusPrevSameKind, + Event::FocusPrevSameKind, + to_baz_lines.event(), + Event::QuitAccept, + ], + ); + let state = example_contents(); + let recorder = Recorder::new(state, &mut input); + recorder.run()?; + + insta::assert_snapshot!(initial, @r###" + "[File] [Edit] [Select] [View] " + "(~) foo/bar (-)" + " ⋮ " + " 18 this is some text⏎ " + " 19 this is some text⏎ " + " 20 this is some text⏎ " + " [~] Section 1/1 [-]" + " [×] - before text 1⏎ " + " [×] - before text 2⏎ " + " [×] + after text 1⏎ " + " [ ] + after text 2⏎ " + " 23 this is some trailing text⏎ " + "[×] baz [-]" + " 1 Some leading text 1⏎ " + " 2 Some leading text 2⏎ " + " [×] Section 1/1 [-]" + " [×] - before text 1⏎ " + " [×] - before text 2⏎ " + " [×] + after text 1⏎ " + " [×] + after text 2⏎ " + "###); + insta::assert_snapshot!(to_baz, @r###" + "[File] [Edit] [Select] [View] " + "[~] foo/bar [-]" + " 18 this is some text⏎ " + " 19 this is some text⏎ " + " 20 this is some text⏎ " + " [~] Section 1/1 [-]" + " [×] - before text 1⏎ " + " [×] - before text 2⏎ " + " [×] + after text 1⏎ " + " [ ] + after text 2⏎ " + " 23 this is some trailing text⏎ " + "(×) baz (-)" + " 1 Some leading text 1⏎ " + " 2 Some leading text 2⏎ " + " [×] Section 1/1 [-]" + " [×] - before text 1⏎ " + " [×] - before text 2⏎ " + " [×] + after text 1⏎ " + " [×] + after text 2⏎ " + " 5 this is some trailing text⏎ " + "###); + insta::assert_snapshot!(to_baz_section, @r###" + "[File] [Edit] [Select] [View] " + "[~] foo/bar [-]" + " 18 this is some text⏎ " + " 19 this is some text⏎ " + " 20 this is some text⏎ " + " [~] Section 1/1 [-]" + " [×] - before text 1⏎ " + " [×] - before text 2⏎ " + " [×] + after text 1⏎ " + " [ ] + after text 2⏎ " + " 23 this is some trailing text⏎ " + "[×] baz [-]" + " 1 Some leading text 1⏎ " + " 2 Some leading text 2⏎ " + " (×) Section 1/1 (-)" + " [×] - before text 1⏎ " + " [×] - before text 2⏎ " + " [×] + after text 1⏎ " + " [×] + after text 2⏎ " + " 5 this is some trailing text⏎ " + "###); + insta::assert_snapshot!(to_bar_section, @r###" + "[File] [Edit] [Select] [View] " + "[~] foo/bar [-]" + " 18 this is some text⏎ " + " 19 this is some text⏎ " + " 20 this is some text⏎ " + " (~) Section 1/1 (-)" + " [×] - before text 1⏎ " + " [×] - before text 2⏎ " + " [×] + after text 1⏎ " + " [ ] + after text 2⏎ " + " 23 this is some trailing text⏎ " + "[×] baz [-]" + " 1 Some leading text 1⏎ " + " 2 Some leading text 2⏎ " + " [×] Section 1/1 [-]" + " [×] - before text 1⏎ " + " [×] - before text 2⏎ " + " [×] + after text 1⏎ " + " [×] + after text 2⏎ " + " 5 this is some trailing text⏎ " + "###); + insta::assert_snapshot!(to_baz_lines, @r###" + "[File] [Edit] [Select] [View] " + "[~] foo/bar [-]" + " 18 this is some text⏎ " + " 19 this is some text⏎ " + " 20 this is some text⏎ " + " [~] Section 1/1 [-]" + " [×] - before text 1⏎ " + " [×] - before text 2⏎ " + " [×] + after text 1⏎ " + " [ ] + after text 2⏎ " + " 23 this is some trailing text⏎ " + "[×] baz [-]" + " 1 Some leading text 1⏎ " + " 2 Some leading text 2⏎ " + " [×] Section 1/1 [-]" + " [×] - before text 1⏎ " + " [×] - before text 2⏎ " + " (×) + after text 1⏎ " + " [×] + after text 2⏎ " + " 5 this is some trailing text⏎ " + "###); + Ok(()) +} + +#[test] +fn test_next_same_kind() -> eyre::Result<()> { + let initial = TestingScreenshot::default(); + let to_baz = TestingScreenshot::default(); + let to_baz_section = TestingScreenshot::default(); + let to_bar_section = TestingScreenshot::default(); + let to_bar_lines = TestingScreenshot::default(); + let mut input = TestingInput::new( + 80, + 20, + [ + Event::ExpandAll, + initial.event(), + // Moves the current item from foo/bar to baz + Event::FocusNextSameKind, + to_baz.event(), + Event::FocusInner, + to_baz_section.event(), + Event::FocusNextSameKind, + to_bar_section.event(), + Event::FocusInner, + Event::FocusNextSameKind, + Event::FocusNextSameKind, + to_bar_lines.event(), + Event::QuitAccept, + ], + ); + let state = example_contents(); + let recorder = Recorder::new(state, &mut input); + recorder.run()?; + + insta::assert_snapshot!(initial, @r###" + "[File] [Edit] [Select] [View] " + "(~) foo/bar (-)" + " ⋮ " + " 18 this is some text⏎ " + " 19 this is some text⏎ " + " 20 this is some text⏎ " + " [~] Section 1/1 [-]" + " [×] - before text 1⏎ " + " [×] - before text 2⏎ " + " [×] + after text 1⏎ " + " [ ] + after text 2⏎ " + " 23 this is some trailing text⏎ " + "[×] baz [-]" + " 1 Some leading text 1⏎ " + " 2 Some leading text 2⏎ " + " [×] Section 1/1 [-]" + " [×] - before text 1⏎ " + " [×] - before text 2⏎ " + " [×] + after text 1⏎ " + " [×] + after text 2⏎ " + "###); + insta::assert_snapshot!(to_baz, @r###" + "[File] [Edit] [Select] [View] " + "[~] foo/bar [-]" + " 18 this is some text⏎ " + " 19 this is some text⏎ " + " 20 this is some text⏎ " + " [~] Section 1/1 [-]" + " [×] - before text 1⏎ " + " [×] - before text 2⏎ " + " [×] + after text 1⏎ " + " [ ] + after text 2⏎ " + " 23 this is some trailing text⏎ " + "(×) baz (-)" + " 1 Some leading text 1⏎ " + " 2 Some leading text 2⏎ " + " [×] Section 1/1 [-]" + " [×] - before text 1⏎ " + " [×] - before text 2⏎ " + " [×] + after text 1⏎ " + " [×] + after text 2⏎ " + " 5 this is some trailing text⏎ " + "###); + insta::assert_snapshot!(to_baz_section, @r###" + "[File] [Edit] [Select] [View] " + "[~] foo/bar [-]" + " 18 this is some text⏎ " + " 19 this is some text⏎ " + " 20 this is some text⏎ " + " [~] Section 1/1 [-]" + " [×] - before text 1⏎ " + " [×] - before text 2⏎ " + " [×] + after text 1⏎ " + " [ ] + after text 2⏎ " + " 23 this is some trailing text⏎ " + "[×] baz [-]" + " 1 Some leading text 1⏎ " + " 2 Some leading text 2⏎ " + " (×) Section 1/1 (-)" + " [×] - before text 1⏎ " + " [×] - before text 2⏎ " + " [×] + after text 1⏎ " + " [×] + after text 2⏎ " + " 5 this is some trailing text⏎ " + "###); + insta::assert_snapshot!(to_bar_section, @r###" + "[File] [Edit] [Select] [View] " + "[~] foo/bar [-]" + " 18 this is some text⏎ " + " 19 this is some text⏎ " + " 20 this is some text⏎ " + " (~) Section 1/1 (-)" + " [×] - before text 1⏎ " + " [×] - before text 2⏎ " + " [×] + after text 1⏎ " + " [ ] + after text 2⏎ " + " 23 this is some trailing text⏎ " + "[×] baz [-]" + " 1 Some leading text 1⏎ " + " 2 Some leading text 2⏎ " + " [×] Section 1/1 [-]" + " [×] - before text 1⏎ " + " [×] - before text 2⏎ " + " [×] + after text 1⏎ " + " [×] + after text 2⏎ " + " 5 this is some trailing text⏎ " + "###); + insta::assert_snapshot!(to_bar_lines, @r###" + "[File] [Edit] [Select] [View] " + "[~] foo/bar [-]" + " 18 this is some text⏎ " + " 19 this is some text⏎ " + " 20 this is some text⏎ " + " [~] Section 1/1 [-]" + " [×] - before text 1⏎ " + " [×] - before text 2⏎ " + " (×) + after text 1⏎ " + " [ ] + after text 2⏎ " + " 23 this is some trailing text⏎ " + "[×] baz [-]" + " 1 Some leading text 1⏎ " + " 2 Some leading text 2⏎ " + " [×] Section 1/1 [-]" + " [×] - before text 1⏎ " + " [×] - before text 2⏎ " + " [×] + after text 1⏎ " + " [×] + after text 2⏎ " + " 5 this is some trailing text⏎ " + "###); + Ok(()) +} + +// Test the prev/next same kind keybindings when there is only a single section +// of a given kind. +#[test] +fn test_prev_next_same_kind_single_section() -> eyre::Result<()> { + let initial = TestingScreenshot::default(); + let next = TestingScreenshot::default(); + let prev = TestingScreenshot::default(); + let mut input = TestingInput::new( + 80, + 10, + [ + Event::ExpandAll, + // Move down to the section so the current selection isn't the + // first item. + Event::FocusNext, + initial.event(), + // Moves the current item from foo/bar to baz + Event::FocusNextSameKind, + next.event(), + Event::FocusPrevSameKind, + prev.event(), + Event::QuitAccept, + ], + ); + let mut state = example_contents(); + // Change the example so that there's only a single file. + state.files = vec![state.files[0].clone()]; + let recorder = Recorder::new(state, &mut input); + recorder.run()?; + // Since we start at the foo/bar file section and there are no other + // sections, the current section never changes. + insta::assert_snapshot!(initial, @r###" + "[File] [Edit] [Select] [View] " + "[~] foo/bar [-]" + " 18 this is some text⏎ " + " 19 this is some text⏎ " + " 20 this is some text⏎ " + " (~) Section 1/1 (-)" + " [×] - before text 1⏎ " + " [×] - before text 2⏎ " + " [×] + after text 1⏎ " + " [ ] + after text 2⏎ " + "###); + insta::assert_snapshot!(next, @r###" + "[File] [Edit] [Select] [View] " + "[~] foo/bar [-]" + " 18 this is some text⏎ " + " 19 this is some text⏎ " + " 20 this is some text⏎ " + " (~) Section 1/1 (-)" + " [×] - before text 1⏎ " + " [×] - before text 2⏎ " + " [×] + after text 1⏎ " + " [ ] + after text 2⏎ " + "###); + insta::assert_snapshot!(prev, @r###" + "[File] [Edit] [Select] [View] " + "[~] foo/bar [-]" + " 18 this is some text⏎ " + " 19 this is some text⏎ " + " 20 this is some text⏎ " + " (~) Section 1/1 (-)" + " [×] - before text 1⏎ " + " [×] - before text 2⏎ " + " [×] + after text 1⏎ " + " [ ] + after text 2⏎ " + "###); + Ok(()) +} + #[cfg(feature = "serde")] #[test] fn test_deserialize() -> eyre::Result<()> {