Implement mouse interaction with result list

This commit is contained in:
Kovid Goyal
2025-07-11 10:36:20 +05:30
parent 6856df4391
commit 513fd720eb
5 changed files with 214 additions and 31 deletions

View File

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

View File

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

View File

@@ -47,7 +47,11 @@ map('Quit', 'quit esc quit')
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')

View File

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

View File

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