diff --git a/kittens/choose_files/__init__.py b/kittens/choose_files/__init__.py index e69de29bb..e74008787 100644 --- a/kittens/choose_files/__init__.py +++ b/kittens/choose_files/__init__.py @@ -0,0 +1,6 @@ +def syntax_aliases(x: str) -> dict[str, str]: + ans = {} + for x in x.split(): + k, _, v = x.partition(':') + ans[k] = v + return ans diff --git a/kittens/choose_files/main.go b/kittens/choose_files/main.go index ced324e14..5efa080e2 100644 --- a/kittens/choose_files/main.go +++ b/kittens/choose_files/main.go @@ -102,26 +102,28 @@ type render_state struct { } type State struct { - base_dir string - current_dir string - select_dirs bool - multiselect bool - search_text string - mode Mode - suggested_save_file_name string - suggested_save_file_path string - window_title string - screen Screen - current_filter string - filter_map map[string]Filter - filter_names []string - show_hidden bool - show_preview bool - respect_ignores bool - sort_by_last_modified bool - global_ignores ignorefiles.IgnoreFile - keyboard_shortcuts []*config.KeyAction - display_title bool + base_dir string + current_dir string + select_dirs bool + multiselect bool + search_text string + mode Mode + suggested_save_file_name string + suggested_save_file_path string + window_title string + screen Screen + current_filter string + filter_map map[string]Filter + filter_names []string + show_hidden bool + show_preview bool + respect_ignores bool + sort_by_last_modified bool + global_ignores ignorefiles.IgnoreFile + keyboard_shortcuts []*config.KeyAction + display_title bool + pygments_style, dark_pygments_style string + syntax_aliases map[string]string selections []string current_idx CollectionIndex @@ -130,6 +132,8 @@ type State struct { redraw_needed bool } +func (s State) HighlightStyles() (string, string) { return s.pygments_style, s.dark_pygments_style } +func (s State) SyntaxAliases() map[string]string { return s.syntax_aliases } func (s State) DisplayTitle() bool { return s.display_title } func (s State) ShowHidden() bool { return s.show_hidden } func (s State) ShowPreview() bool { return s.show_preview } @@ -709,6 +713,9 @@ func (h *Handler) set_state_from_config(conf *Config, opts *Options) (err error) } h.state.keyboard_shortcuts = conf.KeyboardShortcuts h.state.display_title = opts.DisplayTitle + h.state.pygments_style = conf.Pygments_style + h.state.dark_pygments_style = conf.Dark_pygments_style + h.state.syntax_aliases = conf.Syntax_aliases return } diff --git a/kittens/choose_files/main.py b/kittens/choose_files/main.py index 1d8b402fc..2726a7deb 100644 --- a/kittens/choose_files/main.py +++ b/kittens/choose_files/main.py @@ -40,10 +40,34 @@ Can be specified multiple times to use multiple patterns. Note that every pattern has to be checked against every file, so use sparingly. ''') +egr() # }}} + +agr('appearance', 'Appearance') # {{{ opt('show_preview', 'last', choices=('last', 'yes', 'y', 'true', 'no', 'n', 'false'), long_text=''' Whether to show a preview of the current file/directory. The default value of :code:`last` means remember the last used value. This setting can be toggled withing the program.''') + +opt('pygments_style', 'default', long_text=''' +The pygments color scheme to use for syntax highlighting of file previews. See :link:`pygments +builtin styles ` for a list of schemes. +This sets the colors used for light color schemes, use :opt:`dark_pygments_style` to change the +colors for dark color schemes. +''') + +opt('dark_pygments_style', 'github-dark', long_text=''' +The pygments color scheme to use for syntax highlighting with dark colors. See :link:`pygments +builtin styles ` for a list of schemes. +This sets the colors used for dark color schemes, use :opt:`pygments_style` to change the +colors for light color schemes.''') + +opt('syntax_aliases', 'pyj:py pyi:py recipe:py', ctype='strdict_ _:', option_type='syntax_aliases', + long_text=''' +File extension aliases for syntax highlight. For example, to syntax highlight +:file:`file.xyz` as :file:`file.abc` use a setting of :code:`xyz:abc`. +Multiple aliases must be separated by spaces. +''') + egr() # }}} agr('shortcuts', 'Keyboard shortcuts') # {{{ diff --git a/kittens/choose_files/preview.go b/kittens/choose_files/preview.go index 0ef7aae68..0771fbacb 100644 --- a/kittens/choose_files/preview.go +++ b/kittens/choose_files/preview.go @@ -9,8 +9,11 @@ import ( "slices" "strings" "sync" + "unicode/utf8" + "github.com/kovidgoyal/kitty/tools/highlight" "github.com/kovidgoyal/kitty/tools/icons" + "github.com/kovidgoyal/kitty/tools/tui/loop" "github.com/kovidgoyal/kitty/tools/utils" "github.com/kovidgoyal/kitty/tools/utils/humanize" "github.com/kovidgoyal/kitty/tools/utils/style" @@ -30,12 +33,14 @@ type PreviewManager struct { WakeupMainThread func() bool cache map[string]Preview lock sync.Mutex + highlighter highlight.Highlighter } -func NewPreviewManager(err_chan chan error, settings Settings, WakeupMainThread func() bool) *PreviewManager { +func NewPreviewManager(err_chan chan error, settings Settings, WakeupMainThread func() bool) (ans *PreviewManager) { + defer func() { sanitize = ans.highlighter.Sanitize }() return &PreviewManager{ report_errors: err_chan, settings: settings, WakeupMainThread: WakeupMainThread, - cache: make(map[string]Preview), + cache: make(map[string]Preview), highlighter: highlight.NewHighlighter(nil), } } @@ -104,6 +109,8 @@ func NewErrorPreview(err error) Preview { return &MessagePreview{msg: text} } +var sanitize func(string) string + func write_file_metadata(abspath string, metadata fs.FileInfo, entries []fs.DirEntry) (header string, trailers []string) { buf := strings.Builder{} buf.Grow(4096) @@ -115,7 +122,7 @@ func write_file_metadata(abspath string, metadata fs.FileInfo, entries []fs.DirE add("Size", humanize.Bytes(uint64(metadata.Size()))) case fs.ModeSymlink: if tgt, err := os.Readlink(abspath); err == nil { - add("Target", tgt) + add("Target", sanitize(tgt)) } else { add("Target", err.Error()) } @@ -145,7 +152,7 @@ func write_file_metadata(abspath string, metadata fs.FileInfo, entries []fs.DirE slices.SortFunc(names, func(a, b string) int { return strings.Compare(type_map[a].lname, type_map[b].lname) }) fmt.Fprintln(&buf, "Contents:") for _, n := range names { - trailers = append(trailers, icons.IconForFileWithMode(n, type_map[n].ftype, false)+" "+n) + trailers = append(trailers, icons.IconForFileWithMode(n, type_map[n].ftype, false)+" "+sanitize(n)) } } return buf.String(), trailers @@ -167,6 +174,96 @@ func NewFileMetadataPreview(abspath string, metadata fs.FileInfo) Preview { return &MessagePreview{title: title, msg: h, trailers: t} } +type highlighed_data struct { + text string + light bool + err error +} + +type TextFilePreview struct { + plain_text, highlighted_text string + highlighted_chan chan highlighed_data + light bool +} + +func (p TextFilePreview) IsValidForColorScheme(light bool) bool { return p.light == light } + +func (p TextFilePreview) Render(h *Handler, x, y, width, height int) { + if p.highlighted_chan != nil { + select { + case hd := <-p.highlighted_chan: + p.highlighted_chan = nil + if hd.err == nil { + p.highlighted_text = hd.text + } + default: + } + } + text := p.highlighted_text + if text == "" { + text = p.plain_text + } + s := utils.NewLineScanner(text) + buf := strings.Builder{} + buf.Grow(1024 * height) + for num := 0; s.Scan() && num < height; num++ { + line := s.Text() + truncated := wcswidth.TruncateToVisualLength(line, width) + buf.WriteString(fmt.Sprintf(loop.MoveCursorToTemplate, y+num, x)) + buf.WriteString(truncated) + if len(truncated) < len(line) { + wcswidth.KeepOnlyCSI(line[len(truncated):], &buf) + } + } + buf.WriteString("\x1b[m") // reset any highlight styles + h.lp.QueueWriteString(buf.String()) +} + +func NewTextFilePreview(abspath string, metadata fs.FileInfo, highlighted_chan chan highlighed_data, sanitize func(string) string) Preview { + data, err := os.ReadFile(abspath) + if err != nil { + return NewFileMetadataPreview(abspath, metadata) + } + text := utils.UnsafeBytesToString(data) + if !utf8.ValidString(text) { + text = "Error: not valid utf-8 text" + } + return &TextFilePreview{plain_text: sanitize(text), highlighted_chan: highlighted_chan, light: use_light_colors} +} + +type style_resolver struct { + light bool + light_style, dark_style string + syntax_aliases map[string]string +} + +func (s style_resolver) StyleName() string { + return utils.IfElse(s.light, s.light_style, s.dark_style) +} +func (s style_resolver) UseLightColors() bool { return s.light } +func (s style_resolver) SyntaxAliases() map[string]string { return s.syntax_aliases } +func (s style_resolver) TextForPath(path string) (string, error) { + ans, err := os.ReadFile(path) + if err == nil { + return utils.UnsafeBytesToString(ans), nil + } + return "", err +} + +func (pm *PreviewManager) highlight_file_async(path string, output chan highlighed_data) { + s := style_resolver{light: use_light_colors, syntax_aliases: pm.settings.SyntaxAliases()} + s.light_style, s.dark_style = pm.settings.HighlightStyles() + go func() { + highlighted, err := pm.highlighter.HighlightFile(path, &s) + if err != nil { + debugprintln(fmt.Sprintf("Failed to highlight: %s with error: %s", path, err)) + } + output <- highlighed_data{text: highlighted, err: err, light: s.light} + close(output) + pm.WakeupMainThread() + }() +} + func (pm *PreviewManager) invalidate_color_scheme_based_cached_items() { pm.lock.Lock() defer pm.lock.Unlock() @@ -192,6 +289,13 @@ func (pm *PreviewManager) preview_for(abspath string, ftype fs.FileMode) (ans Pr } return NewDirectoryPreview(abspath, s) } + mt := utils.GuessMimeType(filepath.Base(abspath)) + 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) + pm.highlight_file_async(abspath, ch) + return NewTextFilePreview(abspath, s, ch, pm.highlighter.Sanitize) + } return NewFileMetadataPreview(abspath, s) } diff --git a/kittens/choose_files/results.go b/kittens/choose_files/results.go index c7a69b4d5..d816ac32a 100644 --- a/kittens/choose_files/results.go +++ b/kittens/choose_files/results.go @@ -26,6 +26,7 @@ func (h *Handler) draw_results_title() { if strings.HasPrefix(text, home) { text = "~" + text[len(home):] } + text = sanitize(text) available_width := h.screen_size.width - 9 if available_width < 2 { return @@ -137,7 +138,7 @@ func (h *Handler) draw_column_of_matches(matches ResultsType, current_idx int, x } else { icon = icon_for(filepath.Join(root_dir, m.text), m.ftype) } - text := m.text + text := sanitize(m.text) add_ellipsis := false width := wcswidth.Stringwidth(text) if width > available_width-3 { diff --git a/kittens/choose_files/scan.go b/kittens/choose_files/scan.go index 1a1942857..6565fe15a 100644 --- a/kittens/choose_files/scan.go +++ b/kittens/choose_files/scan.go @@ -630,6 +630,8 @@ type Settings interface { SortByLastModified() bool Filter() Filter GlobalIgnores() ignorefiles.IgnoreFile + HighlightStyles() (string, string) + SyntaxAliases() map[string]string } type ResultManager struct { diff --git a/kitty/guess_mime_type.py b/kitty/guess_mime_type.py index 44f349ec2..b9954e2ff 100644 --- a/kitty/guess_mime_type.py +++ b/kitty/guess_mime_type.py @@ -18,6 +18,7 @@ 'yaml': 'text/yaml', 'js': 'text/javascript', 'json': 'text/json', + 'nix': 'text/nix', } diff --git a/tools/highlight/api.go b/tools/highlight/api.go index 5068e91a7..281e89688 100644 --- a/tools/highlight/api.go +++ b/tools/highlight/api.go @@ -44,6 +44,7 @@ func NewSanitizeControlCodes(replace_tab_by string) *SanitizeControlCodes { type Highlighter interface { HighlightFile(path string, srd StyleResolveData) (highlighted_string string, err error) + Sanitize(string) string } func NewHighlighter(sanitize func(string) string) Highlighter { diff --git a/tools/highlight/impl.go b/tools/highlight/impl.go index 878d18828..7e6af8ab4 100644 --- a/tools/highlight/impl.go +++ b/tools/highlight/impl.go @@ -180,6 +180,8 @@ type highlighter struct { sanitize func(string) string } +func (h *highlighter) Sanitize(x string) string { return h.sanitize(x) } + func (h *highlighter) HighlightFile(path string, srd StyleResolveData) (highlighted_string string, err error) { defer func() { if r := recover(); r != nil { diff --git a/tools/wcswidth/truncate.go b/tools/wcswidth/truncate.go index 7085629db..7aa0e50b9 100644 --- a/tools/wcswidth/truncate.go +++ b/tools/wcswidth/truncate.go @@ -5,6 +5,7 @@ package wcswidth import ( "errors" "fmt" + "io" "strconv" "github.com/kovidgoyal/kitty/tools/utils" @@ -50,6 +51,17 @@ func (self *truncate_iterator) handle_st_terminated_escape_code(body []byte) err return nil } +func KeepOnlyCSI(text string, output io.Writer) { + var w WCWidthIterator + w.parser.HandleCSI = func(data []byte) (err error) { + _, err = output.Write([]byte{'\x1b', '['}) + if err == nil { + _, err = output.Write(data) + } + return + } +} + func create_truncate_iterator() *truncate_iterator { var ans truncate_iterator ans.w.parser.HandleRune = ans.handle_rune