Implement previews for plain text files

This commit is contained in:
Kovid Goyal 2025-07-20 19:24:17 +05:30
parent 28fce006d6
commit bd0f55531f
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
10 changed files with 185 additions and 25 deletions

View file

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

View file

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

View file

@ -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 <https://pygments.org/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 <https://pygments.org/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') # {{{

View file

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

View file

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

View file

@ -630,6 +630,8 @@ type Settings interface {
SortByLastModified() bool
Filter() Filter
GlobalIgnores() ignorefiles.IgnoreFile
HighlightStyles() (string, string)
SyntaxAliases() map[string]string
}
type ResultManager struct {

View file

@ -18,6 +18,7 @@
'yaml': 'text/yaml',
'js': 'text/javascript',
'json': 'text/json',
'nix': 'text/nix',
}

View file

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

View file

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

View file

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