From 513fd720eb4e49b43dac7233f2bc721e757565f7 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Fri, 11 Jul 2025 10:36:20 +0530 Subject: [PATCH] Implement mouse interaction with result list --- kittens/choose_files/collection.go | 93 +++++++++++++++++++++++++++++- kittens/choose_files/main.go | 79 +++++++++++++++++-------- kittens/choose_files/main.py | 6 +- kittens/choose_files/results.go | 63 ++++++++++++++++++-- kittens/choose_files/scan.go | 4 ++ 5 files changed, 214 insertions(+), 31 deletions(-) diff --git a/kittens/choose_files/collection.go b/kittens/choose_files/collection.go index 499944efd..a8571b045 100644 --- a/kittens/choose_files/collection.go +++ b/kittens/choose_files/collection.go @@ -5,6 +5,8 @@ import ( "io/fs" "slices" "sync" + + "github.com/kovidgoyal/kitty/tools/utils" ) var _ = fmt.Print @@ -211,6 +213,71 @@ func (s *SortedResults) AddSortedSlice(sl []*ResultItem) { s.slices = append(s.slices, sl) } +func (s *SortedResults) Apply(first, last CollectionIndex, action func(*ResultItem) (keep_going bool)) { + s.lock() + defer s.unlock() + if first.Slice >= len(s.slices) || first.Pos >= len(s.slices[first.Slice]) { + return + } + amt := utils.IfElse(first.Less(last), 1, -1) + var did_wrap bool + for { + if !action(s.slices[first.Slice][first.Pos]) { + break + } + if first.Compare(last) == 0 { + break + } + first, did_wrap = s.increment_with_wrap_around(first, amt) + if did_wrap { + break + } + } +} + +func (s *SortedResults) Closest(idx CollectionIndex, matches func(*ResultItem) bool) *CollectionIndex { + s.lock() + defer s.unlock() + if idx.Slice >= len(s.slices) || idx.Pos >= len(s.slices[idx.Slice]) { + return nil + } + + type result struct { + idx CollectionIndex + count int + } + var a, b result + iterate := func(idx CollectionIndex, amt int, result *result) { + var did_wrap bool + var count int + result.count = -1 + for { + idx, did_wrap = s.increment_with_wrap_around(idx, amt) + if did_wrap { + break + } + count++ + if matches(s.slices[idx.Slice][idx.Pos]) { + result.idx = idx + result.count = count + break + } + } + } + go func() { iterate(idx, 1, &a) }() + go func() { iterate(idx, -1, &a) }() + if a.count < 0 && b.count < 0 { + return nil + } + return utils.IfElse(a.count < b.count, &b.idx, &a.idx) +} + +func (s *SortedResults) IncrementIndexWithWrapAroundAndCheck(idx CollectionIndex, amt int) (ans CollectionIndex, did_wrap bool) { + s.lock() + defer s.unlock() + return s.increment_with_wrap_around(idx, amt) +} + func (s *SortedResults) IncrementIndexWithWrapAround(idx CollectionIndex, amt int) CollectionIndex { s.lock() defer s.unlock() @@ -259,6 +326,27 @@ func (s *SortedResults) increment_with_wrap_around(idx CollectionIndex, amt int) return idx, did_wrap } +// Return a - b +func (s *SortedResults) SignedDistance(a, b CollectionIndex) (ans int) { + s.lock() + defer s.unlock() + return s.signed_distance(a, b) +} + +// Return a - b +func (s *SortedResults) signed_distance(a, b CollectionIndex) (ans int) { + mult := -1 + if b.Less(a) { + a, b = b, a + mult = 1 + } + limit := min(b.Slice, len(s.slices)) + for ; a.Slice < limit; a.NextSlice() { + ans += len(s.slices[a.Slice]) - a.Pos + } + return mult * (ans + (b.Pos - a.Pos)) +} + // Return |a - b| func (s *SortedResults) distance(a, b CollectionIndex) (ans int) { if b.Less(a) { @@ -271,13 +359,13 @@ func (s *SortedResults) distance(a, b CollectionIndex) (ans int) { return ans + (b.Pos - a.Pos) } -func (s *SortedResults) SplitIntoColumns(calc_num_cols func(int) int, num_per_column, num_before_current int, current CollectionIndex) (ans [][]*ResultItem, num_before int) { +func (s *SortedResults) SplitIntoColumns(calc_num_cols func(int) int, num_per_column, num_before_current int, current CollectionIndex) (ans [][]*ResultItem, num_before int, first_idx CollectionIndex) { s.lock() defer s.unlock() num_cols := calc_num_cols(s.len) total := num_cols * num_per_column if total < 1 { - return nil, 0 + return nil, 0, CollectionIndex{} } num_before = min(total-1, num_before_current) idx, did_wrap := s.increment_with_wrap_around(current, -num_before) @@ -290,6 +378,7 @@ func (s *SortedResults) SplitIntoColumns(calc_num_cols func(int) int, num_per_co idx = CollectionIndex{} } } + first_idx = idx num_before = s.distance(idx, current) // fmt.Printf("111111 idx: %v current: %v num_before: %d\n", idx, current, num_before) ans = make([][]*ResultItem, num_cols) diff --git a/kittens/choose_files/main.go b/kittens/choose_files/main.go index 87be5533e..9cea39161 100644 --- a/kittens/choose_files/main.go +++ b/kittens/choose_files/main.go @@ -99,6 +99,7 @@ func (m Mode) WindowTitle() string { type render_state struct { num_matches, num_of_slots, num_before, num_per_column, num_columns int + first_idx CollectionIndex } type State struct { @@ -175,12 +176,14 @@ func (s *State) AddSelection(abspath string) bool { return false } -func (s *State) ToggleSelection(abspath string) { +func (s *State) ToggleSelection(abspath string) (added bool) { before := len(s.selections) s.selections = slices.DeleteFunc(s.selections, func(x string) bool { return x == abspath }) if len(s.selections) == before { s.selections = append(s.selections, abspath) + added = true } + return } func (s *State) IsSelected(x *ResultItem) bool { @@ -279,15 +282,33 @@ func (h *Handler) current_abspath() string { } -func (h *Handler) toggle_selection() bool { - m := h.current_abspath() - if m != "" { - h.state.ToggleSelection(m) +func (s *State) CanSelect(r *ResultItem) bool { + return utils.IfElse(s.OnlyDirs(), r.ftype.IsDir(), !r.ftype.IsDir()) +} + +func (h *Handler) toggle_selection_at(idx CollectionIndex) bool { + matches, _ := h.get_results() + if r := matches.At(idx); r != nil && h.state.CanSelect(r) { + m := filepath.Join(h.state.CurrentDir(), r.text) + if added := h.state.ToggleSelection(m); added { + h.result_manager.last_click_anchor = &idx + } else { + h.result_manager.last_click_anchor = nil + if len(h.state.selections) > 0 { + x := utils.NewSetWithItems(h.state.selections...) + cdir := h.state.CurrentDir() + h.result_manager.last_click_anchor = matches.Closest(idx, func(q *ResultItem) bool { return x.Has(filepath.Join(cdir, q.text)) }) + } + } return true } return false } +func (h *Handler) toggle_selection() bool { + return h.toggle_selection_at(h.state.CurrentIndex()) +} + func (h *Handler) change_current_dir(dir string) { if dir != h.state.CurrentDir() { h.state.SetCurrentDir(dir) @@ -346,6 +367,31 @@ func (h *Handler) change_filter(delta int) bool { return true } +func (h *Handler) accept_idx(idx CollectionIndex) (accepted bool, err error) { + matches, _ := h.get_results() + if r := matches.At(idx); r != nil { + m := filepath.Join(h.state.CurrentDir(), r.text) + + if h.state.mode.SelectFiles() { + var s os.FileInfo + if s, err = os.Stat(m); err != nil { + return false, nil + } + if s.IsDir() { + return true, h.change_to_current_dir_if_possible() + } + } + + h.state.AddSelection(m) + h.result_manager.last_click_anchor = &idx + if len(h.state.selections) > 0 { + return true, h.finish_selection() + } + return true, h.draw_screen() + } + return +} + func (h *Handler) dispatch_action(name, args string) (err error) { switch name { case "quit": @@ -380,26 +426,13 @@ func (h *Handler) dispatch_action(name, args string) (err error) { return h.draw_screen() } case "accept": - m := h.current_abspath() - if m == "" { + accepted, aerr := h.accept_idx(h.state.CurrentIndex()) + if aerr != nil { + return aerr + } + if !accepted { h.lp.Beep() - return } - if h.state.mode.SelectFiles() { - var s os.FileInfo - if s, err = os.Stat(m); err != nil { - h.lp.Beep() - return nil - } - if s.IsDir() { - return h.change_to_current_dir_if_possible() - } - } - h.state.AddSelection(m) - if len(h.state.selections) > 0 { - return h.finish_selection() - } - return h.draw_screen() case "toggle": switch args { case "dotfiles": diff --git a/kittens/choose_files/main.py b/kittens/choose_files/main.py index 0113e920a..8cb77e499 100644 --- a/kittens/choose_files/main.py +++ b/kittens/choose_files/main.py @@ -47,7 +47,11 @@ map('Quit', 'quit ctrl+c quit') map('Accept current result', 'accept enter accept') -map('Select current result', 'select shift+enter select') +map('Select current result', 'select shift+enter select', long_text=''' +When selecting multiple files, this will add the current file to the list of selected files. +You can also toggle the selected status of a file by holding down the :kbd:`Ctrl` key and clicking on +it. Similarly, the :kbd:`Alt` key can be held to click and extend the range of selected files. +''') map('Next result', 'next_result down next 1') map('Previous result', 'prev_result up next -1') diff --git a/kittens/choose_files/results.go b/kittens/choose_files/results.go index e5d005047..24611d778 100644 --- a/kittens/choose_files/results.go +++ b/kittens/choose_files/results.go @@ -6,10 +6,12 @@ import ( "math" "os" "path/filepath" + "runtime" "strings" "unicode/utf8" "github.com/kovidgoyal/kitty/tools/icons" + "github.com/kovidgoyal/kitty/tools/tui" "github.com/kovidgoyal/kitty/tools/tui/loop" "github.com/kovidgoyal/kitty/tools/utils" "github.com/kovidgoyal/kitty/tools/utils/style" @@ -123,7 +125,7 @@ func icon_for(path string, x os.FileMode) string { return ans } -func (h *Handler) draw_column_of_matches(matches ResultsType, current_idx int, x, available_width int) { +func (h *Handler) draw_column_of_matches(matches ResultsType, current_idx int, x, y, available_width, colnum int) { root_dir := h.state.CurrentDir() for i, m := range matches { h.lp.QueueWriteString("\r") @@ -137,9 +139,11 @@ func (h *Handler) draw_column_of_matches(matches ResultsType, current_idx int, x } text := m.text add_ellipsis := false - if wcswidth.Stringwidth(text) > available_width-3 { + width := wcswidth.Stringwidth(text) + if width > available_width-3 { text = wcswidth.TruncateToVisualLength(text, available_width-4) add_ellipsis = true + width = available_width - 3 } is_current := i == current_idx if is_current { @@ -153,6 +157,54 @@ func (h *Handler) draw_column_of_matches(matches ResultsType, current_idx int, x } h.render_match_with_positions(text, add_ellipsis, m.sorted_positions(), is_current) h.lp.MoveCursorVertically(1) + cr := h.state.mouse_state.AddCellRegion(fmt.Sprintf("result-%d-%d", colnum, i), x, y-1+i, x+width+2, y-1+i) + cr.HoverStyle = HOVER_STYLE + var data struct { + colnum, i int + } + data.colnum, data.i = colnum, i + cr.OnClickEvent = func(id string, ev *loop.MouseEvent, cell_offset tui.Point) error { + if ev.Buttons&loop.LEFT_MOUSE_BUTTON == 0 { + return nil + } + ctrl_mod := utils.IfElse(runtime.GOOS == "darwin", loop.SUPER, loop.CTRL) + mods := ev.Mods & (ctrl_mod | loop.ALT) // shift alone and ctrl+shift are used for kitty bindings + matches, _ := h.get_results() + num_before := h.state.last_render.num_of_slots*data.colnum + data.i + idx, did_wrap := matches.IncrementIndexWithWrapAroundAndCheck(h.state.last_render.first_idx, num_before) + if did_wrap { + h.lp.Beep() + return nil + } + d := matches.SignedDistance(idx, h.state.current_idx) + h.state.SetCurrentIndex(idx) + h.state.last_render.num_before = max(0, h.state.last_render.num_before+d) + switch mods { + case 0: + h.dispatch_action("accept", "") + case ctrl_mod, ctrl_mod | loop.ALT: + h.dispatch_action("select", "") + case loop.ALT: + r := matches.At(idx) + if (r != nil && h.state.IsSelected(r)) || h.result_manager.last_click_anchor == nil { + h.dispatch_action("select", "") + return nil + } + already_selected := utils.NewSetWithItems(h.state.selections...) + cdir := h.state.CurrentDir() + matches.Apply(idx, *h.result_manager.last_click_anchor, func(r *ResultItem) bool { + m := filepath.Join(cdir, r.text) + if !already_selected.Has(m) && h.state.CanSelect(r) { + already_selected.Add(m) + h.state.selections = append(h.state.selections, m) + } + return true + }) + return h.draw_screen() + + } + return nil + } } } @@ -174,14 +226,15 @@ func (h *Handler) draw_list_of_results(matches *SortedResults, y, height int) in } return num_cols } - columns, num_before := matches.SplitIntoColumns(calc_num_cols, height, h.state.last_render.num_before, h.state.CurrentIndex()) + columns, num_before, first_idx := matches.SplitIntoColumns(calc_num_cols, height, h.state.last_render.num_before, h.state.CurrentIndex()) h.state.last_render.num_before = num_before h.state.last_render.num_per_column = height h.state.last_render.num_columns = num_cols + h.state.last_render.first_idx = first_idx x := 1 - for _, col := range columns { + for i, col := range columns { h.lp.MoveCursorTo(x, y) - h.draw_column_of_matches(col, num_before, x, col_width-1) + h.draw_column_of_matches(col, num_before, x, y, col_width-1, i) num_before -= height x += col_width } diff --git a/kittens/choose_files/scan.go b/kittens/choose_files/scan.go index d6ef189c9..594134bbb 100644 --- a/kittens/choose_files/scan.go +++ b/kittens/choose_files/scan.go @@ -640,6 +640,8 @@ type ResultManager struct { scorer *FileSystemScorer mutex sync.Mutex last_wakeup_at time.Time + + last_click_anchor *CollectionIndex } func NewResultManager(err_chan chan error, settings Settings, WakeupMainThread func() bool) *ResultManager { @@ -658,6 +660,7 @@ func (m *ResultManager) new_scorer() { m.scorer.respect_ignores = m.settings.RespectIgnores() m.scorer.show_hidden = m.settings.ShowHidden() m.scorer.global_ignore = m.settings.GlobalIgnores() + m.last_click_anchor = nil } func (m *ResultManager) on_results(err error, is_finished bool) { @@ -709,6 +712,7 @@ func (m *ResultManager) set_something(callback func()) { m.new_scorer() m.scorer.Start() } else { + m.last_click_anchor = nil callback() }