mirror of
https://github.com/kovidgoyal/kitty
synced 2026-07-05 23:51:29 +02:00
Use readline for the choose files search input
This commit is contained in:
@@ -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]
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
@@ -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
|
||||
------------------------
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user