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 @@ Anchored patterns match with respect to whatever directory is currently being di
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 @@ known_extensions = {
'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