Use readline for the choose files search input

This commit is contained in:
Kovid Goyal
2026-07-05 15:16:11 +05:30
parent 31eb21f9f5
commit 011ef4d319
5 changed files with 184 additions and 25 deletions

View File

@@ -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]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@@ -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
------------------------

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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])
}