From 76d0991b76fac7854ce2ce089223da42c3932d72 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Fri, 4 Jul 2025 13:37:56 +0530 Subject: [PATCH] Work on filter support --- kittens/choose_files/filters.go | 78 ++++++++++++++++++++++++++++ kittens/choose_files/footer.go | 49 +++++++++++++++++ kittens/choose_files/main.go | 61 ++++++++++++++++++++-- kittens/choose_files/main.py | 10 ++++ kittens/choose_files/scan.go | 59 +++++++++++++++++---- tools/utils/style/indent-and-wrap.go | 7 --- 6 files changed, 242 insertions(+), 22 deletions(-) create mode 100644 kittens/choose_files/filters.go create mode 100644 kittens/choose_files/footer.go diff --git a/kittens/choose_files/filters.go b/kittens/choose_files/filters.go new file mode 100644 index 000000000..f08cb3812 --- /dev/null +++ b/kittens/choose_files/filters.go @@ -0,0 +1,78 @@ +package choose_files + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/kovidgoyal/kitty/tools/utils" +) + +var _ = fmt.Print + +type Filter struct { + Name, Type, Pattern string + Match func(filename string) bool +} + +func (f Filter) String() string { + return fmt.Sprintf("%s:%s:%s", f.Type, f.Pattern, f.Name) +} + +func (f Filter) Equal(other Filter) bool { + return f.Type == other.Type && f.Pattern == other.Pattern +} + +func NewFilter(spec string) (*Filter, error) { + parts := strings.SplitN(spec, ":", 3) + if len(parts) != 3 { + return nil, fmt.Errorf("%#v is not a valid filter specifier, must have at least two colons", spec) + } + ans := &Filter{Name: parts[2], Pattern: parts[1], Type: parts[0]} + if _, err := filepath.Match(ans.Pattern, "test"); err != nil { + return nil, fmt.Errorf("%#v is not a valid glob pattern with error: %w", ans.Pattern, err) + } + if ans.Pattern != "*" && ans.Pattern != "" { + switch ans.Type { + case "glob": + ans.Match = func(filename string) bool { + m, _ := filepath.Match(ans.Pattern, filename) + return m + } + case "mime": + ans.Match = func(filename string) bool { + mime := utils.GuessMimeType(filename) + if mime == "" { + return false + } + m, _ := filepath.Match(ans.Pattern, mime) + return m + } + default: + return nil, fmt.Errorf("%#v is not a valid filter type", ans.Type) + } + } + return ans, nil +} + +func CombinedFilter(filters ...Filter) Filter { + if len(filters) == 0 { + return Filter{} + } + for _, f := range filters { + if f.Match == nil { + return f + } + } + ans := filters[0] + matchers := utils.Map(func(f Filter) func(filename string) bool { return f.Match }, filters) + ans.Match = func(filename string) bool { + for _, m := range matchers { + if m(filename) { + return true + } + } + return false + } + return ans +} diff --git a/kittens/choose_files/footer.go b/kittens/choose_files/footer.go new file mode 100644 index 000000000..6ac2bf8b2 --- /dev/null +++ b/kittens/choose_files/footer.go @@ -0,0 +1,49 @@ +package choose_files + +import ( + "fmt" + "strings" + + "github.com/kovidgoyal/kitty/tools/utils" + "github.com/kovidgoyal/kitty/tools/utils/style" + "github.com/kovidgoyal/kitty/tools/wcswidth" +) + +var _ = fmt.Print + +func (h *Handler) draw_footer() (num_lines int, err error) { + lines := []string{} + screen_width := h.screen_size.width + sctx := style.Context{AllowEscapeCodes: true} + if len(h.state.filter_map) > 0 { + buf := strings.Builder{} + pos := 0 + current_style := sctx.SprintFunc("italic fg=green intense") + non_current_style := sctx.SprintFunc("dim") + w := func(text string, sfunc func(...any) string) { + sz := wcswidth.Stringwidth(text) + if sz+pos >= screen_width { + lines = append(lines, buf.String()) + pos = 0 + buf.Reset() + } + if sfunc != nil { + text = sfunc(text) + } + buf.WriteString(text) + pos += sz + } + w("Filter:", nil) + for _, name := range h.state.filter_names { + w(" "+name, utils.IfElse(name == h.state.current_filter, current_style, non_current_style)) + } + if s := buf.String(); s != "" { + lines = append(lines, s) + } + } + if len(lines) > 0 { + h.lp.MoveCursorTo(1, h.screen_size.height-len(lines)+1) + h.lp.QueueWriteString(strings.Join(lines, "\r\n")) + } + return len(lines), err +} diff --git a/kittens/choose_files/main.go b/kittens/choose_files/main.go index 9078aeee2..463f1ea39 100644 --- a/kittens/choose_files/main.go +++ b/kittens/choose_files/main.go @@ -18,7 +18,7 @@ import ( "github.com/kovidgoyal/kitty/tools/utils" ) -// TODO: Comboboxes, multifile selections, save file name, file/dir modes +// TODO: multifile selections, save file name completion var _ = fmt.Print var debugprintln = tty.DebugPrintln @@ -108,6 +108,9 @@ type State struct { suggested_save_file_name string window_title string screen Screen + current_filter string + filter_map map[string]Filter + filter_names []string save_file_cdir string selections []string @@ -116,6 +119,7 @@ type State struct { } func (s State) BaseDir() string { return utils.IfElse(s.base_dir == "", default_cwd, s.base_dir) } +func (s State) Filter() Filter { return s.filter_map[s.current_filter] } func (s State) SelectDirs() bool { return s.select_dirs } func (s State) Multiselect() bool { return s.multiselect } func (s State) String() string { return utils.Repr(s) } @@ -190,7 +194,11 @@ func (h *Handler) draw_screen() (err error) { h.draw_search_bar(0) }() y := SEARCH_BAR_HEIGHT - y += h.draw_results(y, 2, matches, !is_complete) + footer_height, err := h.draw_footer() + if err != nil { + return err + } + y += h.draw_results(y, footer_height, matches, !is_complete) case SAVE_FILE: err = h.draw_save_file_name_screen() } @@ -227,7 +235,7 @@ func (h *Handler) OnInitialize() (ans string, err error) { h.lp.AllowLineWrapping(false) h.lp.SetCursorShape(loop.BAR_CURSOR, true) h.lp.StartBracketedPaste() - h.result_manager.set_root_dir(h.state.CurrentDir()) + h.result_manager.set_root_dir(h.state.CurrentDir(), h.state.Filter()) h.draw_screen() return } @@ -261,7 +269,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.result_manager.set_root_dir(h.state.CurrentDir()) + h.result_manager.set_root_dir(h.state.CurrentDir(), h.state.Filter()) h.state.last_render = render_state{} } } @@ -269,7 +277,15 @@ func (h *Handler) change_current_dir(dir string) { func (h *Handler) set_query(q string) { if q != h.state.SearchText() { h.state.SetSearchText(q) - h.result_manager.set_query(h.state.SearchText()) + h.result_manager.set_query(h.state.SearchText(), h.state.Filter()) + h.state.last_render = render_state{} + } +} + +func (h *Handler) set_filter(filter_name string) { + if filter_name != h.state.current_filter { + h.state.current_filter = filter_name + h.result_manager.set_filter(h.state.Filter()) h.state.last_render = render_state{} } } @@ -411,6 +427,41 @@ func (h *Handler) set_state_from_config(_ *Config, opts *Options) (err error) { } } } + h.state.filter_map = nil + h.state.current_filter = "" + if len(opts.FileFilter) > 0 { + has_all_files := false + fmap := make(map[string][]Filter) + seen := utils.NewSet[string](len(opts.FileFilter)) + for _, x := range opts.FileFilter { + f, ferr := NewFilter(x) + if ferr != nil { + return ferr + } + if f.Match == nil { + has_all_files = true + } + if h.state.current_filter == "" { + h.state.current_filter = f.Name + } + fmap[f.Name] = append(fmap[f.Name], *f) + if !seen.Has(f.Name) { + seen.Add(f.Name) + h.state.filter_names = append(h.state.filter_names, f.Name) + } + } + if !has_all_files { + af, _ := NewFilter("glob:*:All files") + fmap[af.Name] = append(fmap[af.Name], *af) + if !seen.Has(af.Name) { + h.state.filter_names = append(h.state.filter_names, af.Name) + } + } + h.state.filter_map = make(map[string]Filter) + for name, filters := range fmap { + h.state.filter_map[name] = CombinedFilter(filters...) + } + } return } diff --git a/kittens/choose_files/main.py b/kittens/choose_files/main.py index 2a5235604..a56c129c9 100644 --- a/kittens/choose_files/main.py +++ b/kittens/choose_files/main.py @@ -32,6 +32,16 @@ default=file The type of object(s) to select +--file-filter +type=list +A list of filters to restrict the displayed files. Can be either mimetypes, or glob style patterns. Can be specified multiple times. +The syntax is :code:`type:expression:Descriptive Name`. +For example: :code:`mime:image/png:Images` and :code:`mime:image/gif:Images` and :code:`glob:*.[tT][xX][Tt]:Text files`. +Note that glob patterns are case-sensitive. The mimetype specification is treated as a glob expressions as well, so you can, +for example, use :code:`mime:text/*` to match all text files. The first filter in the list will be applied by default. Use a filter +such as :code:`glob:*:All` to match all files. Note that filtering only appies to files, not directories. + + --suggested-save-file-name A suggested name when picking a save file. diff --git a/kittens/choose_files/scan.go b/kittens/choose_files/scan.go index fdd195166..9718d33e3 100644 --- a/kittens/choose_files/scan.go +++ b/kittens/choose_files/scan.go @@ -278,6 +278,7 @@ type FileSystemScorer struct { scanner Scanner keep_going, is_complete atomic.Bool root_dir, query string + filter Filter only_dirs bool mutex sync.Mutex sorted_results *SortedResults @@ -286,10 +287,11 @@ type FileSystemScorer struct { scorer *fzf.FuzzyMatcher } -func NewFileSystemScorer(root_dir, query string, only_dirs bool, on_results func(error, bool)) (ans *FileSystemScorer) { +func NewFileSystemScorer(root_dir, query string, filter Filter, only_dirs bool, on_results func(error, bool)) (ans *FileSystemScorer) { return &FileSystemScorer{ - query: query, root_dir: root_dir, only_dirs: only_dirs, on_results: on_results, - scorer: fzf.NewFuzzyMatcher(fzf.PATH_SCHEME), sorted_results: NewSortedResults()} + query: query, root_dir: root_dir, only_dirs: only_dirs, filter: filter, on_results: on_results, + scorer: fzf.NewFuzzyMatcher(fzf.PATH_SCHEME), sorted_results: NewSortedResults(), + } } func (fss *FileSystemScorer) lock() { fss.mutex.Lock() } @@ -325,6 +327,21 @@ func (fss *FileSystemScorer) Change_query(query string) { fss.Start() } +func (fss *FileSystemScorer) Change_filter(filter Filter) { + if fss.filter.Equal(filter) { + return + } + fss.keep_going.Store(false) + if fss.current_worker_wait != nil { + fss.current_worker_wait.Wait() + } + fss.lock() + fss.filter = filter + fss.sorted_results.Clear() + fss.unlock() + fss.Start() +} + func (fss *FileSystemScorer) worker(on_results chan bool, worker_wait *sync.WaitGroup) { defer func() { fss.is_complete.Store(true) @@ -353,9 +370,18 @@ func (fss *FileSystemScorer) worker(on_results chan bool, worker_wait *sync.Wait } } } else { - rp = make([]*ResultItem, len(results)) - for i := range len(rp) { - rp[i] = &results[i] + if fss.filter.Match == nil { + rp = make([]*ResultItem, len(results)) + for i := range len(rp) { + rp[i] = &results[i] + } + } else { + rp = make([]*ResultItem, 0, len(results)) + for i, r := range results { + if r.ftype.IsDir() || fss.filter.Match(filepath.Base(r.text)) { + rp = append(rp, &results[i]) + } + } } } if len(rp) > 0 { @@ -452,7 +478,7 @@ func (m *ResultManager) on_results(err error, is_finished bool) { } } -func (m *ResultManager) set_root_dir(root_dir string) { +func (m *ResultManager) set_root_dir(root_dir string, filter Filter) { var err error if root_dir == "" || root_dir == "." { if root_dir, err = os.Getwd(); err != nil { @@ -466,25 +492,38 @@ func (m *ResultManager) set_root_dir(root_dir string) { if m.scorer != nil { m.scorer.Cancel() } - m.scorer = NewFileSystemScorer(root_dir, "", m.settings.OnlyDirs(), m.on_results) + _ = os.Chdir(root_dir) // this is so the terminal emulator can read the wd for launch --directory=current + m.scorer = NewFileSystemScorer(root_dir, "", filter, m.settings.OnlyDirs(), m.on_results) m.mutex.Lock() m.last_wakeup_at = time.Time{} m.mutex.Unlock() m.scorer.Start() } -func (m *ResultManager) set_query(query string) { +func (m *ResultManager) set_query(query string, filter Filter) { m.mutex.Lock() m.last_wakeup_at = time.Time{} m.mutex.Unlock() if m.scorer == nil { - m.scorer = NewFileSystemScorer(".", "", m.settings.OnlyDirs(), m.on_results) + m.scorer = NewFileSystemScorer(".", "", filter, m.settings.OnlyDirs(), m.on_results) m.scorer.Start() } else { m.scorer.Change_query(query) } } +func (m *ResultManager) set_filter(f Filter) { + m.mutex.Lock() + m.last_wakeup_at = time.Time{} + m.mutex.Unlock() + if m.scorer == nil { + m.scorer = NewFileSystemScorer(".", "", f, m.settings.OnlyDirs(), m.on_results) + m.scorer.Start() + } else { + m.scorer.Change_filter(f) + } +} + func (h *Handler) get_results() (ans *SortedResults, is_complete bool) { if h.result_manager.scorer == nil { return diff --git a/tools/utils/style/indent-and-wrap.go b/tools/utils/style/indent-and-wrap.go index 7a880c06c..92d570b97 100644 --- a/tools/utils/style/indent-and-wrap.go +++ b/tools/utils/style/indent-and-wrap.go @@ -292,13 +292,6 @@ func (self *line_builder) has_space_for_width(w, max_width int) bool { return w+self.cursor_pos <= max_width } -func (self *line_builder) add_char(ch rune) { - self.seen_non_space_chars = true - self.buf = utf8.AppendRune(self.buf, ch) - self.cursor_pos += wcswidth.Runewidth(ch) - self.pos_of_trailing_whitespace = -1 -} - func (self *line_builder) add_space(ch rune, trim_whitespace bool) { if !trim_whitespace || self.seen_non_space_chars { self.buf = utf8.AppendRune(self.buf, ch)