Skip to content

Commit bd0f555

Browse files
committed
Implement previews for plain text files
1 parent 28fce00 commit bd0f555

File tree

10 files changed

+185
-25
lines changed

10 files changed

+185
-25
lines changed

kittens/choose_files/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
def syntax_aliases(x: str) -> dict[str, str]:
2+
ans = {}
3+
for x in x.split():
4+
k, _, v = x.partition(':')
5+
ans[k] = v
6+
return ans

kittens/choose_files/main.go

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -102,26 +102,28 @@ type render_state struct {
102102
}
103103

104104
type State struct {
105-
base_dir string
106-
current_dir string
107-
select_dirs bool
108-
multiselect bool
109-
search_text string
110-
mode Mode
111-
suggested_save_file_name string
112-
suggested_save_file_path string
113-
window_title string
114-
screen Screen
115-
current_filter string
116-
filter_map map[string]Filter
117-
filter_names []string
118-
show_hidden bool
119-
show_preview bool
120-
respect_ignores bool
121-
sort_by_last_modified bool
122-
global_ignores ignorefiles.IgnoreFile
123-
keyboard_shortcuts []*config.KeyAction
124-
display_title bool
105+
base_dir string
106+
current_dir string
107+
select_dirs bool
108+
multiselect bool
109+
search_text string
110+
mode Mode
111+
suggested_save_file_name string
112+
suggested_save_file_path string
113+
window_title string
114+
screen Screen
115+
current_filter string
116+
filter_map map[string]Filter
117+
filter_names []string
118+
show_hidden bool
119+
show_preview bool
120+
respect_ignores bool
121+
sort_by_last_modified bool
122+
global_ignores ignorefiles.IgnoreFile
123+
keyboard_shortcuts []*config.KeyAction
124+
display_title bool
125+
pygments_style, dark_pygments_style string
126+
syntax_aliases map[string]string
125127

126128
selections []string
127129
current_idx CollectionIndex
@@ -130,6 +132,8 @@ type State struct {
130132
redraw_needed bool
131133
}
132134

135+
func (s State) HighlightStyles() (string, string) { return s.pygments_style, s.dark_pygments_style }
136+
func (s State) SyntaxAliases() map[string]string { return s.syntax_aliases }
133137
func (s State) DisplayTitle() bool { return s.display_title }
134138
func (s State) ShowHidden() bool { return s.show_hidden }
135139
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)
709713
}
710714
h.state.keyboard_shortcuts = conf.KeyboardShortcuts
711715
h.state.display_title = opts.DisplayTitle
716+
h.state.pygments_style = conf.Pygments_style
717+
h.state.dark_pygments_style = conf.Dark_pygments_style
718+
h.state.syntax_aliases = conf.Syntax_aliases
712719
return
713720
}
714721

kittens/choose_files/main.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,34 @@
4040
Can be specified multiple times to use multiple patterns. Note that every pattern
4141
has to be checked against every file, so use sparingly.
4242
''')
43+
egr() # }}}
44+
45+
agr('appearance', 'Appearance') # {{{
4346

4447
opt('show_preview', 'last', choices=('last', 'yes', 'y', 'true', 'no', 'n', 'false'), long_text='''
4548
Whether to show a preview of the current file/directory. The default value of :code:`last` means remember the last
4649
used value. This setting can be toggled withing the program.''')
50+
51+
opt('pygments_style', 'default', long_text='''
52+
The pygments color scheme to use for syntax highlighting of file previews. See :link:`pygments
53+
builtin styles <https://pygments.org/styles/>` for a list of schemes.
54+
This sets the colors used for light color schemes, use :opt:`dark_pygments_style` to change the
55+
colors for dark color schemes.
56+
''')
57+
58+
opt('dark_pygments_style', 'github-dark', long_text='''
59+
The pygments color scheme to use for syntax highlighting with dark colors. See :link:`pygments
60+
builtin styles <https://pygments.org/styles/>` for a list of schemes.
61+
This sets the colors used for dark color schemes, use :opt:`pygments_style` to change the
62+
colors for light color schemes.''')
63+
64+
opt('syntax_aliases', 'pyj:py pyi:py recipe:py', ctype='strdict_ _:', option_type='syntax_aliases',
65+
long_text='''
66+
File extension aliases for syntax highlight. For example, to syntax highlight
67+
:file:`file.xyz` as :file:`file.abc` use a setting of :code:`xyz:abc`.
68+
Multiple aliases must be separated by spaces.
69+
''')
70+
4771
egr() # }}}
4872

4973
agr('shortcuts', 'Keyboard shortcuts') # {{{

kittens/choose_files/preview.go

Lines changed: 108 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@ import (
99
"slices"
1010
"strings"
1111
"sync"
12+
"unicode/utf8"
1213

14+
"github.com/kovidgoyal/kitty/tools/highlight"
1315
"github.com/kovidgoyal/kitty/tools/icons"
16+
"github.com/kovidgoyal/kitty/tools/tui/loop"
1417
"github.com/kovidgoyal/kitty/tools/utils"
1518
"github.com/kovidgoyal/kitty/tools/utils/humanize"
1619
"github.com/kovidgoyal/kitty/tools/utils/style"
@@ -30,12 +33,14 @@ type PreviewManager struct {
3033
WakeupMainThread func() bool
3134
cache map[string]Preview
3235
lock sync.Mutex
36+
highlighter highlight.Highlighter
3337
}
3438

35-
func NewPreviewManager(err_chan chan error, settings Settings, WakeupMainThread func() bool) *PreviewManager {
39+
func NewPreviewManager(err_chan chan error, settings Settings, WakeupMainThread func() bool) (ans *PreviewManager) {
40+
defer func() { sanitize = ans.highlighter.Sanitize }()
3641
return &PreviewManager{
3742
report_errors: err_chan, settings: settings, WakeupMainThread: WakeupMainThread,
38-
cache: make(map[string]Preview),
43+
cache: make(map[string]Preview), highlighter: highlight.NewHighlighter(nil),
3944
}
4045
}
4146

@@ -104,6 +109,8 @@ func NewErrorPreview(err error) Preview {
104109
return &MessagePreview{msg: text}
105110
}
106111

112+
var sanitize func(string) string
113+
107114
func write_file_metadata(abspath string, metadata fs.FileInfo, entries []fs.DirEntry) (header string, trailers []string) {
108115
buf := strings.Builder{}
109116
buf.Grow(4096)
@@ -115,7 +122,7 @@ func write_file_metadata(abspath string, metadata fs.FileInfo, entries []fs.DirE
115122
add("Size", humanize.Bytes(uint64(metadata.Size())))
116123
case fs.ModeSymlink:
117124
if tgt, err := os.Readlink(abspath); err == nil {
118-
add("Target", tgt)
125+
add("Target", sanitize(tgt))
119126
} else {
120127
add("Target", err.Error())
121128
}
@@ -145,7 +152,7 @@ func write_file_metadata(abspath string, metadata fs.FileInfo, entries []fs.DirE
145152
slices.SortFunc(names, func(a, b string) int { return strings.Compare(type_map[a].lname, type_map[b].lname) })
146153
fmt.Fprintln(&buf, "Contents:")
147154
for _, n := range names {
148-
trailers = append(trailers, icons.IconForFileWithMode(n, type_map[n].ftype, false)+" "+n)
155+
trailers = append(trailers, icons.IconForFileWithMode(n, type_map[n].ftype, false)+" "+sanitize(n))
149156
}
150157
}
151158
return buf.String(), trailers
@@ -167,6 +174,96 @@ func NewFileMetadataPreview(abspath string, metadata fs.FileInfo) Preview {
167174
return &MessagePreview{title: title, msg: h, trailers: t}
168175
}
169176

177+
type highlighed_data struct {
178+
text string
179+
light bool
180+
err error
181+
}
182+
183+
type TextFilePreview struct {
184+
plain_text, highlighted_text string
185+
highlighted_chan chan highlighed_data
186+
light bool
187+
}
188+
189+
func (p TextFilePreview) IsValidForColorScheme(light bool) bool { return p.light == light }
190+
191+
func (p TextFilePreview) Render(h *Handler, x, y, width, height int) {
192+
if p.highlighted_chan != nil {
193+
select {
194+
case hd := <-p.highlighted_chan:
195+
p.highlighted_chan = nil
196+
if hd.err == nil {
197+
p.highlighted_text = hd.text
198+
}
199+
default:
200+
}
201+
}
202+
text := p.highlighted_text
203+
if text == "" {
204+
text = p.plain_text
205+
}
206+
s := utils.NewLineScanner(text)
207+
buf := strings.Builder{}
208+
buf.Grow(1024 * height)
209+
for num := 0; s.Scan() && num < height; num++ {
210+
line := s.Text()
211+
truncated := wcswidth.TruncateToVisualLength(line, width)
212+
buf.WriteString(fmt.Sprintf(loop.MoveCursorToTemplate, y+num, x))
213+
buf.WriteString(truncated)
214+
if len(truncated) < len(line) {
215+
wcswidth.KeepOnlyCSI(line[len(truncated):], &buf)
216+
}
217+
}
218+
buf.WriteString("\x1b[m") // reset any highlight styles
219+
h.lp.QueueWriteString(buf.String())
220+
}
221+
222+
func NewTextFilePreview(abspath string, metadata fs.FileInfo, highlighted_chan chan highlighed_data, sanitize func(string) string) Preview {
223+
data, err := os.ReadFile(abspath)
224+
if err != nil {
225+
return NewFileMetadataPreview(abspath, metadata)
226+
}
227+
text := utils.UnsafeBytesToString(data)
228+
if !utf8.ValidString(text) {
229+
text = "Error: not valid utf-8 text"
230+
}
231+
return &TextFilePreview{plain_text: sanitize(text), highlighted_chan: highlighted_chan, light: use_light_colors}
232+
}
233+
234+
type style_resolver struct {
235+
light bool
236+
light_style, dark_style string
237+
syntax_aliases map[string]string
238+
}
239+
240+
func (s style_resolver) StyleName() string {
241+
return utils.IfElse(s.light, s.light_style, s.dark_style)
242+
}
243+
func (s style_resolver) UseLightColors() bool { return s.light }
244+
func (s style_resolver) SyntaxAliases() map[string]string { return s.syntax_aliases }
245+
func (s style_resolver) TextForPath(path string) (string, error) {
246+
ans, err := os.ReadFile(path)
247+
if err == nil {
248+
return utils.UnsafeBytesToString(ans), nil
249+
}
250+
return "", err
251+
}
252+
253+
func (pm *PreviewManager) highlight_file_async(path string, output chan highlighed_data) {
254+
s := style_resolver{light: use_light_colors, syntax_aliases: pm.settings.SyntaxAliases()}
255+
s.light_style, s.dark_style = pm.settings.HighlightStyles()
256+
go func() {
257+
highlighted, err := pm.highlighter.HighlightFile(path, &s)
258+
if err != nil {
259+
debugprintln(fmt.Sprintf("Failed to highlight: %s with error: %s", path, err))
260+
}
261+
output <- highlighed_data{text: highlighted, err: err, light: s.light}
262+
close(output)
263+
pm.WakeupMainThread()
264+
}()
265+
}
266+
170267
func (pm *PreviewManager) invalidate_color_scheme_based_cached_items() {
171268
pm.lock.Lock()
172269
defer pm.lock.Unlock()
@@ -192,6 +289,13 @@ func (pm *PreviewManager) preview_for(abspath string, ftype fs.FileMode) (ans Pr
192289
}
193290
return NewDirectoryPreview(abspath, s)
194291
}
292+
mt := utils.GuessMimeType(filepath.Base(abspath))
293+
const MAX_TEXT_FILE_SIZE = 16 * 1024 * 1024
294+
if s.Size() <= MAX_TEXT_FILE_SIZE && (utils.KnownTextualMimes[mt] || strings.HasPrefix(mt, "text/")) {
295+
ch := make(chan highlighed_data, 2)
296+
pm.highlight_file_async(abspath, ch)
297+
return NewTextFilePreview(abspath, s, ch, pm.highlighter.Sanitize)
298+
}
195299
return NewFileMetadataPreview(abspath, s)
196300
}
197301

kittens/choose_files/results.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ func (h *Handler) draw_results_title() {
2626
if strings.HasPrefix(text, home) {
2727
text = "~" + text[len(home):]
2828
}
29+
text = sanitize(text)
2930
available_width := h.screen_size.width - 9
3031
if available_width < 2 {
3132
return
@@ -137,7 +138,7 @@ func (h *Handler) draw_column_of_matches(matches ResultsType, current_idx int, x
137138
} else {
138139
icon = icon_for(filepath.Join(root_dir, m.text), m.ftype)
139140
}
140-
text := m.text
141+
text := sanitize(m.text)
141142
add_ellipsis := false
142143
width := wcswidth.Stringwidth(text)
143144
if width > available_width-3 {

kittens/choose_files/scan.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,8 @@ type Settings interface {
630630
SortByLastModified() bool
631631
Filter() Filter
632632
GlobalIgnores() ignorefiles.IgnoreFile
633+
HighlightStyles() (string, string)
634+
SyntaxAliases() map[string]string
633635
}
634636

635637
type ResultManager struct {

kitty/guess_mime_type.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
'yaml': 'text/yaml',
1919
'js': 'text/javascript',
2020
'json': 'text/json',
21+
'nix': 'text/nix',
2122
}
2223

2324

tools/highlight/api.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ func NewSanitizeControlCodes(replace_tab_by string) *SanitizeControlCodes {
4444

4545
type Highlighter interface {
4646
HighlightFile(path string, srd StyleResolveData) (highlighted_string string, err error)
47+
Sanitize(string) string
4748
}
4849

4950
func NewHighlighter(sanitize func(string) string) Highlighter {

tools/highlight/impl.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,8 @@ type highlighter struct {
180180
sanitize func(string) string
181181
}
182182

183+
func (h *highlighter) Sanitize(x string) string { return h.sanitize(x) }
184+
183185
func (h *highlighter) HighlightFile(path string, srd StyleResolveData) (highlighted_string string, err error) {
184186
defer func() {
185187
if r := recover(); r != nil {

tools/wcswidth/truncate.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package wcswidth
55
import (
66
"errors"
77
"fmt"
8+
"io"
89
"strconv"
910

1011
"github.com/kovidgoyal/kitty/tools/utils"
@@ -50,6 +51,17 @@ func (self *truncate_iterator) handle_st_terminated_escape_code(body []byte) err
5051
return nil
5152
}
5253

54+
func KeepOnlyCSI(text string, output io.Writer) {
55+
var w WCWidthIterator
56+
w.parser.HandleCSI = func(data []byte) (err error) {
57+
_, err = output.Write([]byte{'\x1b', '['})
58+
if err == nil {
59+
_, err = output.Write(data)
60+
}
61+
return
62+
}
63+
}
64+
5365
func create_truncate_iterator() *truncate_iterator {
5466
var ans truncate_iterator
5567
ans.w.parser.HandleRune = ans.handle_rune

0 commit comments

Comments
 (0)