diff --git a/docs/changelog.rst b/docs/changelog.rst index da08fd36b..b6eb9c635 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -214,6 +214,8 @@ Detailed list of changes - Add support for editing files in sublime text and zed at specified line number (:pull:`10224`) +- choose-file kitten: Use a full readline editor for the search box. Also allow remapping the up/down/home/end keys to use for editing instead of navigating the file list (:pull:`10225`) + 0.47.4 [2026-06-15] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/kittens/choose-files.rst b/docs/kittens/choose-files.rst index eec968f17..58e4437c7 100644 --- a/docs/kittens/choose-files.rst +++ b/docs/kittens/choose-files.rst @@ -130,6 +130,80 @@ change into a highlighted directory and :kbd:`Ctrl+Enter` to select the current directory itself. +Editing the search text +---------------------------- + +The search/filter text box uses the same line editing engine as the shell +prompt kitten, supporting the usual Emacs-style editing shortcuts such as +:kbd:`Ctrl+A`/:kbd:`Ctrl+E` to move to the start/end of the text, +:kbd:`Ctrl+Left`/:kbd:`Ctrl+Right` to move by a word, :kbd:`Ctrl+K`/:kbd:`Ctrl+U` +to delete to the end/start of the line, :kbd:`Ctrl+W`/:kbd:`Alt+D` to delete +the previous/next word and so on. + +However, by default, the arrow keys as well as :kbd:`Home`, :kbd:`End`, +:kbd:`Ctrl+Home` and :kbd:`Ctrl+End` are bound to actions that navigate the +list of matched results, rather than moving the cursor within the search +text, since that is the more frequently needed behavior when quickly +filtering a list of files. If you prefer these keys to edit the search text +instead, as in a regular text input, you can rebind them to one of the +following actions in :file:`choose-files.conf`, which forward the key press +to the search text editor: + +.. list-table:: + :widths: auto + :header-rows: 1 + + * - Action + - Editing operation it performs + * - :code:`edit_cursor_left` + - Move the cursor one character to the left + * - :code:`edit_cursor_right` + - Move the cursor one character to the right + * - :code:`edit_start_of_line` + - Move the cursor to the start of the search text + * - :code:`edit_end_of_line` + - Move the cursor to the end of the search text + * - :code:`edit_start_of_document` + - Same as :code:`edit_start_of_line`, provided for symmetry with the shell prompt's line editor + * - :code:`edit_end_of_document` + - Same as :code:`edit_end_of_line`, provided for symmetry with the shell prompt's line editor + * - :code:`edit_forward_word` + - Move the cursor forward by one word + * - :code:`edit_backward_word` + - Move the cursor backward by one word + * - :code:`edit_backspace` + - Delete the character before the cursor + * - :code:`edit_delete` + - Delete the character after the cursor + * - :code:`edit_kill_to_start_of_line` + - Delete everything from the start of the line to the cursor + * - :code:`edit_kill_to_end_of_line` + - Delete everything from the cursor to the end of the line + * - :code:`edit_kill_word_left` + - Delete the word before the cursor + * - :code:`edit_kill_word_right` + - Delete the word after the cursor + * - :code:`edit_yank` + - Insert the most recently deleted text + +For example, to make the arrow keys, :kbd:`Home` and :kbd:`End` edit the +search text instead of navigating the results list:: + + map left edit_cursor_left + map right edit_cursor_right + map home edit_start_of_line + map end edit_end_of_line + map ctrl+home edit_start_of_document + map ctrl+end edit_end_of_document + +Note that :kbd:`Up`, :kbd:`Down` and other keys not listed above continue to +be usable for navigating the results list even after applying the above +overrides. Also note that any key not bound to an action at all is +automatically forwarded to the search text editor, which is why keys such as +:kbd:`Backspace`, :kbd:`Delete`, :kbd:`Ctrl+K`, :kbd:`Ctrl+W` etc. already +edit the search text, without needing any of the above actions. + + Configuration ------------------------ diff --git a/kittens/choose_files/main.go b/kittens/choose_files/main.go index 7161a01ac..a70b823e7 100644 --- a/kittens/choose_files/main.go +++ b/kittens/choose_files/main.go @@ -216,6 +216,7 @@ type Handler struct { result_manager *ResultManager lp *loop.Loop rl *readline.Readline + search_rl *readline.Readline err_chan chan error shortcut_tracker config.ShortcutTracker msg_printer *message.Printer @@ -355,6 +356,7 @@ func (h *Handler) init_sizes(new_size loop.ScreenSize) { h.screen_size.width_px = int(new_size.WidthPx) h.screen_size.height_px = int(new_size.HeightPx) h.rl.ClearCachedScreenSize() + h.search_rl.ClearCachedScreenSize() } func (h *Handler) OnInitialize() (ans string, err error) { @@ -373,6 +375,7 @@ func (h *Handler) OnInitialize() (ans string, err error) { if (s.IsDir() && h.state.mode != SELECT_SAVE_FILE) || (!s.IsDir() && h.state.mode == SELECT_SAVE_FILE) { h.state.SetCurrentDir(filepath.Dir(h.state.suggested_save_file_path)) h.state.SetSearchText(filepath.Base(h.state.suggested_save_file_name)) + h.search_rl.SetText(h.state.SearchText()) } } } @@ -426,6 +429,7 @@ func (h *Handler) toggle_selection() bool { func (h *Handler) change_current_dir(dir string) { if dir != h.state.CurrentDir() { h.state.SetCurrentDir(dir) + h.search_rl.ResetText() h.result_manager.set_root_dir() h.state.last_render = render_state{} } @@ -664,15 +668,20 @@ func (h *Handler) dispatch_action(name, args string) (err error) { func (h *Handler) OnKeyEvent(ev *loop.KeyEvent) (err error) { switch h.state.screen { case NORMAL: - if h.handle_edit_keys(ev) { - ev.Handled = true - h.draw_screen() - } ac := h.shortcut_tracker.Match(ev, h.state.keyboard_shortcuts) if ac != nil { ev.Handled = true + if rlac, is_edit_action := edit_actions[ac.Name]; is_edit_action { + if err = h.perform_edit_action(rlac); err == nil { + err = h.draw_screen() + } + return + } return h.dispatch_action(ac.Name, ac.Args) } + if err = h.forward_key_event_to_search_rl(ev); err == nil { + err = h.draw_screen() + } case SAVE_FILE: err = h.save_file_name_handle_key(ev) } @@ -693,8 +702,10 @@ func (h *Handler) OnMouseEvent(event *loop.MouseEvent) (err error) { func (h *Handler) OnText(text string, from_key_event, in_bracketed_paste bool) (err error) { switch h.state.screen { case NORMAL: - h.set_query(h.state.SearchText() + text) - return h.draw_screen() + if err = h.search_rl.OnText(text, from_key_event, in_bracketed_paste); err == nil { + h.set_query(h.search_rl.AllText()) + err = h.draw_screen() + } case SAVE_FILE: if err = h.rl.OnText(text, from_key_event, in_bracketed_paste); err == nil { err = h.draw_screen() @@ -951,6 +962,7 @@ func main(_ *cli.Command, opts *Options, args []string) (rc int, err error) { handler.rl = readline.New(lp, readline.RlInit{ Prompt: "> ", ContinuationPrompt: ". ", Completer: FilePromptCompleter(getcwd), }) + handler.search_rl = readline.New(lp, readline.RlInit{DontMarkPrompts: true}) if err = handler.set_state_from_config(conf, opts); err != nil { return 1, err } diff --git a/kittens/choose_files/search-bar.go b/kittens/choose_files/search-bar.go index d64f882b6..908e28e11 100644 --- a/kittens/choose_files/search-bar.go +++ b/kittens/choose_files/search-bar.go @@ -6,12 +6,37 @@ import ( "strings" "github.com/kovidgoyal/kitty/tools/tui/loop" + "github.com/kovidgoyal/kitty/tools/tui/readline" "github.com/kovidgoyal/kitty/tools/utils" "github.com/kovidgoyal/kitty/tools/wcswidth" ) var _ = fmt.Print +// Actions that, when matched by a keyboard shortcut, are forwarded to the +// readline instance powering the search/filter text box instead of being +// dispatched as a normal choose_files action. This lets users who prefer +// editing text with the arrow keys, Home, End, etc. rebind those keys away +// from their default job of navigating the list of matches, towards editing +// the search text instead. See the kitten's documentation for details. +var edit_actions = map[string]readline.Action{ + "edit_cursor_left": readline.ActionCursorLeft, + "edit_cursor_right": readline.ActionCursorRight, + "edit_start_of_line": readline.ActionMoveToStartOfLine, + "edit_end_of_line": readline.ActionMoveToEndOfLine, + "edit_start_of_document": readline.ActionMoveToStartOfDocument, + "edit_end_of_document": readline.ActionMoveToEndOfDocument, + "edit_forward_word": readline.ActionMoveToEndOfWord, + "edit_backward_word": readline.ActionMoveToStartOfWord, + "edit_backspace": readline.ActionBackspace, + "edit_delete": readline.ActionDelete, + "edit_kill_to_start_of_line": readline.ActionKillToStartOfLine, + "edit_kill_to_end_of_line": readline.ActionKillToEndOfLine, + "edit_kill_word_left": readline.ActionKillPreviousWord, + "edit_kill_word_right": readline.ActionKillNextWord, + "edit_yank": readline.ActionYank, +} + func (h *Handler) draw_frame(width, height int, in_progress bool) { lp := h.lp prefix, suffix := "", "" @@ -45,16 +70,41 @@ func (h *Handler) draw_frame(width, height int, in_progress bool) { } func (h *Handler) draw_search_text(available_width int) { - text := h.state.SearchText() available_width /= 2 - if wcswidth.Stringwidth(text) > available_width { - g := wcswidth.SplitIntoGraphemes(text) - available_width -= 2 - g = g[len(g)-available_width:] - text = "…" + strings.Join(g, "") + all_graphemes := wcswidth.SplitIntoGraphemes(h.state.SearchText()) + cursor_pos := len(wcswidth.SplitIntoGraphemes(h.search_rl.TextBeforeCursor())) + start, end := 0, len(all_graphemes) + left_ellipsis, right_ellipsis := false, false + if len(all_graphemes) > available_width && available_width > 0 { + start = cursor_pos - available_width/2 + start = max(0, start) + end = min(len(all_graphemes), start+available_width) + start = max(0, end-available_width) + left_ellipsis = start > 0 + right_ellipsis = end < len(all_graphemes) + if left_ellipsis { + start++ + } + if right_ellipsis { + end-- + } + end = max(start, end) } - h.lp.DrawSizedText(text+" ", loop.SizedText{Scale: 2}) - h.lp.MoveCursorHorizontally(-2) + visible := make([]string, 0, end-start+2) + if left_ellipsis { + visible = append(visible, "…") + } + visible = append(visible, all_graphemes[start:end]...) + if right_ellipsis { + visible = append(visible, "…") + } + cursor_col := cursor_pos - start + if left_ellipsis { + cursor_col++ + } + cursor_col = max(0, min(cursor_col, len(visible))) + h.lp.DrawSizedText(strings.Join(visible, "")+" ", loop.SizedText{Scale: 2}) + h.lp.MoveCursorHorizontally(-2 * (len(visible) - cursor_col + 1)) } const SEARCH_BAR_HEIGHT = 4 @@ -115,16 +165,22 @@ func (h *Handler) draw_search_bar(y int) { h.draw_search_text(available_width - 2) } -func (h *Handler) handle_edit_keys(ev *loop.KeyEvent) bool { - switch { - case ev.MatchesPressOrRepeat("backspace"): - if h.state.SearchText() == "" { - h.lp.Beep() - } else { - g := wcswidth.SplitIntoGraphemes(h.state.search_text) - h.set_query(strings.Join(g[:len(g)-1], "")) - return true - } +// perform_edit_action runs a readline editing action against the search text +// box and syncs the resulting text back into the query used to filter results. +func (h *Handler) perform_edit_action(ac readline.Action) (err error) { + if err = h.search_rl.PerformAction(ac, 1); err == nil { + h.set_query(h.search_rl.AllText()) } - return false + return +} + +// forward_key_event_to_search_rl is used for keys that are not claimed by any +// configured keyboard shortcut, so that ordinary readline editing (backspace, +// delete, ctrl+k, ctrl+w, etc.) works in the search box without needing +// explicit configuration. +func (h *Handler) forward_key_event_to_search_rl(ev *loop.KeyEvent) (err error) { + if err = h.search_rl.OnKeyEvent(ev); err == nil { + h.set_query(h.search_rl.AllText()) + } + return } diff --git a/tools/tui/readline/api.go b/tools/tui/readline/api.go index 731f860f4..f82711525 100644 --- a/tools/tui/readline/api.go +++ b/tools/tui/readline/api.go @@ -273,6 +273,21 @@ func (self *Readline) MoveCursorToEnd() bool { return self.move_to_end() } +// PerformAction runs the specified editing Action directly, without going through +// key resolution. Useful for callers that want to bind their own keys to a +// specific line-editing operation, bypassing this package's own default keymap. +func (self *Readline) PerformAction(ac Action, repeat_count uint) error { + if repeat_count == 0 { + repeat_count = 1 + } + err := self.perform_action(ac, repeat_count) + if err == ErrCouldNotPerformAction { + self.loop.Beep() + err = nil + } + return err +} + func (self *Readline) CursorAtEndOfLine() bool { return self.input_state.cursor.X >= len(self.input_state.lines[self.input_state.cursor.Y]) }