From e310b76794cbc89044739664844e0977043d8752 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sun, 18 Oct 2026 01:28:26 +0200 Subject: [PATCH] fix: honor configured wrap bindings in shell output --- docs/editing.md | 2 +- src/editor.rs | 61 ++++++++++++++++++++++++++++++++++++----------- src/tui.rs | 2 +- tests/unit/tui.rs | 47 +++++++++++++++++++++++++++++++++++- 4 files changed, 95 insertions(+), 17 deletions(-) diff --git a/docs/editing.md b/docs/editing.md index 48e9f1c..b4b0110 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -202,7 +202,7 @@ Use `:!COMMAND` (for example `:!pwd` or `:!echo "$PATH"`) to run a shell command While running, the default top bar shows the first three command graphemes, `...` when truncated, and an animated spinner to the right of the battery. Completion opens a read-only floating window with the command, exit status (or launch error), stdout, and separately marked stderr. Arrows/Ctrl+h/j/k/l and the mouse wheel scroll; Home resets, End goes to the last line, and Enter/Escape dismisses it and resumes the underlying editing mode. Output is decoded as UTF-8 with replacements; controls are displayed as text, never interpreted as terminal escapes. Capture retains up to 4 MiB per stream with an explicit truncation notice. There is no command timeout; exiting Vamp kills the running process group, and background jobs in that group are also stopped when the command exits. Shell execution currently requires Unix. -In the shell output window, `w` toggles soft wrapping without changing output bytes or the source buffer's wrap setting. A single-key custom binding to `config.core.word_wrap.toggle` also toggles the popup. Wrapping starts off for each command; toggling resets scrolling to the start. With wrapping on, vertical scrolling follows visual rows and horizontal scrolling is disabled. +In the shell output window, your configured `config.core.word_wrap.toggle` binding toggles soft wrapping, including leader and multi-key sequences. Bindings use Normal mode while the popup is focused; there is no additional default shortcut. This does not change output bytes or the source buffer's wrap setting. Wrapping starts off for each command; toggling resets scrolling to the start. With wrapping on, vertical scrolling follows visual rows and horizontal scrolling is disabled. The latest reported operation result appears in a non-focusable popup at the workspace's bottom-right, just above the bottom bar when visible, with one column of inner padding on each side when space permits. It remains until dismissed or replaced, with no timer. In Normal mode, the first Escape dismisses it without collapsing carets; a subsequent Escape retains ordinary Normal behavior. Existing modal windows, focused explorers/diff selectors, and pending prefixes retain Escape priority. New operation messages reopen the popup; routine successful buffer switches do not duplicate the buffer/path information already shown in the bar. diff --git a/src/editor.rs b/src/editor.rs index 607bfda..ebc6bbc 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -4695,6 +4695,9 @@ impl Editor { } fn binding_mode(&self) -> Mode { + if self.command_output.is_some() { + return Mode::Normal; + } if self.diff.is_some() && !self.diff_interacting() && !matches!(self.active.view.mode, Mode::Command | Mode::Search) @@ -7008,22 +7011,29 @@ impl Editor { } fn handle_command_output(&mut self, input: &Input) -> bool { - let toggle = input == &Input::Char('w') - || matches!( - self.bindings - .get(&self.binding_mode()) - .and_then(|keys| keys.get(input)), - Some(Binding::Action(Action::WordWrapToggle)) - ); - let Some(output) = &mut self.command_output else { + if self.command_output.is_none() { return false; - }; - if toggle { - output.wrapping = !output.wrapping; - output.content.top = 0; - output.content.left = 0; + } + if self.sequence_pending().is_some() { + if matches!(input, Input::Escape | Input::Paste(_)) { + self.cancel_sequence(); + } else { + self.handle_sequence(input.clone()); + } return true; } + let mode = self.binding_mode(); + if self.start_sequence(mode, input) { + return true; + } + if matches!( + self.bindings.get(&mode).and_then(|keys| keys.get(input)), + Some(Binding::Action(Action::WordWrapToggle)) + ) { + self.act(Action::WordWrapToggle); + return true; + } + let output = self.command_output.as_mut().unwrap(); let rows = if output.wrapping { output.wrapped_rows.get() } else { @@ -8537,8 +8547,18 @@ impl Editor { self.pending_sequence.keys.push(input); let bindings = self.sequence_bindings.get(&self.binding_mode()); match bindings.and_then(|keys| keys.get(&self.pending_sequence)) { - Some(Binding::Action(action)) => return self.act(*action), + Some(Binding::Action(action)) => { + if self.command_output.is_some() && *action != Action::WordWrapToggle { + self.cancel_sequence(); + return None; + } + return self.act(*action); + } Some(Binding::Command(command)) => { + if self.command_output.is_some() { + self.cancel_sequence(); + return None; + } let command = command.clone(); return self.execute(&command); } @@ -9749,6 +9769,19 @@ impl Editor { } fn execute_action(&mut self, entry: &CoreAction, args: &str) -> Option { + if entry.action == Action::WordWrapToggle + && let Some(output) = &mut self.command_output + { + if args.is_empty() { + output.wrapping = !output.wrapping; + output.content.top = 0; + output.content.left = 0; + } else { + self.set_message("The word_wrap.toggle command takes no arguments"); + } + self.clear_sequence(); + return None; + } if matches!( entry.action, Action::DiffComment diff --git a/src/tui.rs b/src/tui.rs index 3a693e7..a966a5f 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -4715,7 +4715,7 @@ fn draw_diagnostic_panel( Paragraph::new(if startup { "Arrows scroll · Home reset · Enter/Esc continue" } else { - "w wrap · Arrows scroll · Home reset · Enter/Esc close" + "Arrows scroll · Home reset · Enter/Esc close" }) .style(Style::default().fg(Color::DarkGray)), help_area, diff --git a/tests/unit/tui.rs b/tests/unit/tui.rs index 8510037..7ef6cd6 100644 --- a/tests/unit/tui.rs +++ b/tests/unit/tui.rs @@ -15454,6 +15454,10 @@ fn shell_output_panel_clips_sanitized_content_in_offset_and_tiny_areas() { #[test] fn shell_output_wrap_toggle_reflows_and_scrolls_without_changing_source() { let mut session = EditorSession::open(None, vec![], None).unwrap(); + session + .editor_mut() + .bind("normal", "leader z w", Some("core.word_wrap.toggle".into())) + .unwrap(); let source_wrap = session.editor().word_wrap(); let source = session.editor().lines().to_vec(); session.editor_mut().show_command_output( @@ -15467,6 +15471,10 @@ fn shell_output_wrap_toggle_reflows_and_scrolls_without_changing_source() { .unwrap(); assert!(!screen_text(&terminal).contains("TAIL")); session.handle(Input::Char('w')); + assert!(!session.editor().command_output().unwrap().wrapping); + for ch in " zw".chars() { + session.handle(Input::Char(ch)); + } terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); @@ -15475,7 +15483,9 @@ fn shell_output_wrap_toggle_reflows_and_scrolls_without_changing_source() { assert_eq!(session.editor().command_output().unwrap().content.top, 1); session.handle(Input::Right); assert_eq!(session.editor().command_output().unwrap().content.left, 0); - session.handle(Input::Char('w')); + for ch in " zw".chars() { + session.handle(Input::Char(ch)); + } terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); @@ -15483,3 +15493,38 @@ fn shell_output_wrap_toggle_reflows_and_scrolls_without_changing_source() { assert_eq!(session.editor().word_wrap(), source_wrap); assert_eq!(session.editor().lines(), source); } + +#[test] +fn shell_output_wrap_bindings_respect_remaps_disabled_keys_and_sequence_cancellation() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor + .bind("normal", "ctrl+y", Some("core.word_wrap.toggle".into())) + .unwrap(); + editor + .bind("normal", "ctrl+w z", Some("core.word_wrap.toggle".into())) + .unwrap(); + editor + .bind("normal", "leader x", Some("core.delete".into())) + .unwrap(); + editor.show_command_output("command".into(), Err("output".into())); + let source = editor.lines().to_vec(); + editor.handle(Input::Ctrl('y')); + assert!(editor.command_output().unwrap().wrapping); + editor.bind("normal", "ctrl+y", None).unwrap(); + editor.handle(Input::Ctrl('y')); + assert!(editor.command_output().unwrap().wrapping); + editor.handle(Input::Ctrl('w')); + editor.handle(Input::Escape); + assert!(editor.command_output().is_some()); + assert!(editor.sequence_pending().is_none()); + editor.handle(Input::Ctrl('w')); + editor.handle(Input::Char('z')); + assert!(!editor.command_output().unwrap().wrapping); + editor.handle(Input::Char(' ')); + editor.handle(Input::Char('x')); + assert_eq!(editor.lines(), source); + assert!(editor.command_output().is_some()); + assert!(editor.sequence_pending().is_none()); + editor.handle(Input::Escape); + assert!(editor.command_output().is_none()); +}