From 772805b8ec8de6044b07867b581d168974819cc7 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Fri, 5 Dec 2025 19:54:21 +0530 Subject: [PATCH] choose files: Add arbitrary command based previews Needs testing. Someday. --- kittens/choose_files/cmd_preview.go | 100 ++++++++++++++++++++++++++++ kittens/choose_files/main.go | 35 ++++++++++ kittens/choose_files/main.py | 26 ++++++++ kittens/choose_files/preview.go | 8 ++- 4 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 kittens/choose_files/cmd_preview.go diff --git a/kittens/choose_files/cmd_preview.go b/kittens/choose_files/cmd_preview.go new file mode 100644 index 000000000..b5f74c0cd --- /dev/null +++ b/kittens/choose_files/cmd_preview.go @@ -0,0 +1,100 @@ +package choose_files + +import ( + "bytes" + "encoding/json" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + + "github.com/kovidgoyal/kitty/tools/icons" + "github.com/kovidgoyal/kitty/tools/utils/humanize" + "github.com/kovidgoyal/kitty/tools/utils/images" + "golang.org/x/sys/unix" +) + +var _ = fmt.Print + +const CMD_METADATA_KEY = "cmd-metadata.json" + +type cmd_renderer struct { + cmdline []string +} + +func (c cmd_renderer) String() string { + return c.cmdline[0] +} + +type CmdResult struct { + Lines []string `json:"lines"` + Image string `json:"image"` + TitleExtra string `json:"title_extra"` +} + +func (c cmd_renderer) Render(path string) (m map[string][]byte, mi metadata, img *images.ImageData, err error) { + cmdline := append(c.cmdline, path) + cmd := exec.Command(cmdline[0], cmdline[1:]...) + cmd.Stdin = nil + cmd.SysProcAttr = &unix.SysProcAttr{Setsid: true} + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err = cmd.Run(); err != nil { + err = fmt.Errorf("failed to run %v to read metadata from %s with error: %w and stderr: %s", c.cmdline, path, err, stderr.String()) + return + } + var md CmdResult + if err = json.Unmarshal(stdout.Bytes(), &md); err != nil { + err = fmt.Errorf("could not decode JSON response from %v for %s: %w", c.cmdline, path, err) + } + if md.Image != "" { + var ip ImagePreviewRenderer + if m, mi, img, err = ip.Render(md.Image); err != nil { + return + } + } + mi.custom = &md + return +} + +func (c cmd_renderer) Unmarshall(m map[string]string) (any, error) { + data, err := os.ReadFile(m[CMD_METADATA_KEY]) + if err != nil { + return nil, err + } + var ans CmdResult + if err = json.Unmarshal(data, &ans); err != nil { + return nil, err + } + return &ans, nil +} + +func (c cmd_renderer) ShowMetadata(h *Handler, s ShowData) (offset int) { + w := func(text string, center bool) { + if s.height > offset { + offset += h.render_wrapped_text_in_region(text, s.x, s.y+offset, s.width, s.height-offset, center) + } + } + ext := filepath.Ext(s.abspath) + r := s.custom_metadata.custom.(*CmdResult) + text := fmt.Sprintf("%s: %s%s", ext, humanize.Bytes(uint64(s.metadata.Size())), r.TitleExtra) + icon := icons.IconForPath(s.abspath) + w(icon+" "+text, true) + for _, line := range r.Lines { + w(line, false) + } + return +} + +func NewCmdPreview( + abspath string, metadata fs.FileInfo, opts Settings, WakeupMainThread func() bool, p previewer, +) Preview { + c := cmd_renderer{p.cmdline} + if ans, err := NewImagePreview(abspath, metadata, opts, WakeupMainThread, c); err == nil { + return ans + } else { + return NewErrorPreview(err) + } +} diff --git a/kittens/choose_files/main.go b/kittens/choose_files/main.go index beb46002b..d1fd0f4c1 100644 --- a/kittens/choose_files/main.go +++ b/kittens/choose_files/main.go @@ -272,6 +272,38 @@ func (h *Handler) draw_screen() (err error) { return } +type previewer struct { + cmdline []string + pattern string + use_mimetype bool +} + +func (p previewer) matches(fname, mt string) bool { + q := utils.IfElse(p.use_mimetype, mt, fname) + matched, err := filepath.Match(p.pattern, q) + return matched && err == nil +} + +var previewers []previewer + +func load_previewers(cfg *Config) (err error) { + for _, spec := range cfg.Previewer { + parts, err := shlex.Split(spec) + if err != nil { + return fmt.Errorf("invalid previewer specification: %#v with error: %w", spec, err) + } + if len(parts) < 2 { + return fmt.Errorf("invalid previewer specification: %#v with error: no command specified", spec) + } + prefix, pattern, found := strings.Cut(parts[0], ":") + if !found { + return fmt.Errorf("invalid previewer specification: %#v with error: no prefix in pattern specification", spec) + } + previewers = append(previewers, previewer{parts[1:], pattern, prefix == "mime"}) + } + return +} + func load_config(opts *Options) (ans *Config, err error) { ans = NewConfig() p := config.ConfigParser{LineHandler: ans.Parse} @@ -855,6 +887,9 @@ func main(_ *cli.Command, opts *Options, args []string) (rc int, err error) { if err != nil { return 1, err } + if err = load_previewers(conf); err != nil { + return 1, err + } lp, err := loop.New() if err != nil { return 1, err diff --git a/kittens/choose_files/main.py b/kittens/choose_files/main.py index 39941a9ab..2d177a8ea 100644 --- a/kittens/choose_files/main.py +++ b/kittens/choose_files/main.py @@ -85,7 +85,33 @@ otherwise it will not affect already cached previews. ''') +opt('+previewer', '', long_text=''' +Specify an arbitrary program based preview generator. The syntax is:: + pattern program arguments... + +Here, pattern can be used to match file names or mimetypes. For example: +:code:`name:*.doc` matches files with the extension :code:`.doc`. Similarly, +:code:`mime:image/*` matches all image files. :code:`program` can be any +executable program in PATH. It will be run with the supplied arguments. The last argument +will be the path to the file for which a preview must be generated. + +Can be specified multiple times to setup different previewers for different types of files. +Note that previewers specified using this option take precedence over the builtin +previewers. + +The command must output preview data to STDOUT, in JSON format, of the form:: + + { + "lines": ["line1", "line2", ...], + "image": "absolute path to generated image preview", + "title_extra": "some text to show on the first line", + } + +The lines can contain SGR formatting escape codes and will be displayed as is at the +top of the preview panel. The image is optional and must be in one of the JPEG, PNG, GIF, WEBP, APNG +formats. +''') egr() # }}} agr('shortcuts', 'Keyboard shortcuts') # {{{ diff --git a/kittens/choose_files/preview.go b/kittens/choose_files/preview.go index 1b63702d1..0b364a00b 100644 --- a/kittens/choose_files/preview.go +++ b/kittens/choose_files/preview.go @@ -332,7 +332,13 @@ func (pm *PreviewManager) preview_for(abspath string, ftype fs.FileMode) (ans Pr } return NewDirectoryPreview(abspath, s) } - mt := utils.GuessMimeType(filepath.Base(abspath)) + fname := filepath.Base(abspath) + mt := utils.GuessMimeType(fname) + for _, q := range previewers { + if q.matches(fname, mt) { + return NewCmdPreview(abspath, s, pm.settings, pm.WakeupMainThread, q) + } + } const MAX_TEXT_FILE_SIZE = 16 * 1024 * 1024 if s.Size() <= MAX_TEXT_FILE_SIZE && (utils.KnownTextualMimes[mt] || strings.HasPrefix(mt, "text/")) { ch := make(chan highlighed_data, 2)