Files
kitty/kittens/themes/ui.go
Ethan Wu 87a9a60442 Fix themes kitten not displaying colors in narrow windows
The themes kitten used the truncated color name when formatting the
colors themselves, which leads to broken coloring when the window is
narrow enough to cause truncation to occur.
2024-07-08 22:41:29 -07:00

619 lines
15 KiB
Go

// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package themes
import (
"fmt"
"io"
"maps"
"path/filepath"
"regexp"
"slices"
"strings"
"time"
"kitty/tools/config"
"kitty/tools/themes"
"kitty/tools/tui/loop"
"kitty/tools/tui/readline"
"kitty/tools/utils"
"kitty/tools/wcswidth"
)
var _ = fmt.Print
type State int
const (
FETCHING State = iota
BROWSING
SEARCHING
ACCEPTING
)
const SEPARATOR = "║"
type CachedData struct {
Recent []string `json:"recent"`
Category string `json:"category"`
}
type fetch_data struct {
themes *themes.Themes
err error
closer io.Closer
}
var category_filters = map[string]func(*themes.Theme) bool{
"all": func(*themes.Theme) bool { return true },
"dark": func(t *themes.Theme) bool { return t.IsDark() },
"light": func(t *themes.Theme) bool { return !t.IsDark() },
"user": func(t *themes.Theme) bool { return t.IsUserDefined() },
}
func recent_filter(items []string) func(*themes.Theme) bool {
allowed := utils.NewSetWithItems(items...)
return func(t *themes.Theme) bool {
return allowed.Has(t.Name())
}
}
type handler struct {
lp *loop.Loop
opts *Options
cached_data *CachedData
state State
fetch_result chan fetch_data
all_themes *themes.Themes
themes_closer io.Closer
themes_list *ThemesList
category_filters map[string]func(*themes.Theme) bool
colors_set_once bool
tabs []string
rl *readline.Readline
}
// fetching {{{
func (self *handler) fetch_themes() {
r := fetch_data{}
r.themes, r.closer, r.err = themes.LoadThemes(time.Duration(self.opts.CacheAge * float64(time.Hour*24)))
self.lp.WakeupMainThread()
self.fetch_result <- r
}
func (self *handler) on_fetching_key_event(ev *loop.KeyEvent) error {
if ev.MatchesPressOrRepeat("esc") {
self.lp.Quit(0)
ev.Handled = true
}
return nil
}
func (self *handler) on_wakeup() error {
r := <-self.fetch_result
if r.err != nil {
return r.err
}
self.state = BROWSING
self.all_themes = r.themes
self.themes_closer = r.closer
self.redraw_after_category_change()
return nil
}
func (self *handler) draw_fetching_screen() {
self.lp.Println("Downloading themes from repository, please wait...")
}
// }}}
func (self *handler) finalize() {
t := self.themes_closer
if t != nil {
t.Close()
self.themes_closer = nil
}
}
func (self *handler) initialize() {
self.tabs = strings.Split("all dark light recent user", " ")
self.rl = readline.New(self.lp, readline.RlInit{DontMarkPrompts: true, Prompt: "/"})
self.themes_list = &ThemesList{}
self.fetch_result = make(chan fetch_data)
self.category_filters = make(map[string]func(*themes.Theme) bool, len(category_filters)+1)
maps.Copy(self.category_filters, category_filters)
self.category_filters["recent"] = recent_filter(self.cached_data.Recent)
go self.fetch_themes()
self.draw_screen()
}
func (self *handler) enforce_cursor_state() {
self.lp.SetCursorVisible(self.state == FETCHING)
}
func (self *handler) draw_screen() {
self.lp.StartAtomicUpdate()
defer self.lp.EndAtomicUpdate()
self.lp.ClearScreen()
self.enforce_cursor_state()
switch self.state {
case FETCHING:
self.draw_fetching_screen()
case BROWSING, SEARCHING:
self.draw_browsing_screen()
case ACCEPTING:
self.draw_accepting_screen()
}
}
func (self *handler) current_category() string {
ans := self.cached_data.Category
if self.category_filters[ans] == nil {
ans = "all"
}
return ans
}
func (self *handler) set_current_category(category string) {
if self.category_filters[category] == nil {
category = "all"
}
self.cached_data.Category = category
}
func ReadKittyColorSettings() map[string]string {
settings := make(map[string]string, 512)
handle_line := func(key, val string) error {
if themes.AllColorSettingNames[key] {
settings[key] = val
}
return nil
}
cp := config.ConfigParser{LineHandler: handle_line}
cp.ParseFiles(filepath.Join(utils.ConfigDir(), "kitty.conf"))
return settings
}
func (self *handler) set_colors_to_current_theme() bool {
if self.themes_list == nil && self.colors_set_once {
return false
}
self.colors_set_once = true
if self.themes_list != nil {
t := self.themes_list.CurrentTheme()
if t != nil {
raw, err := t.AsEscapeCodes()
if err == nil {
self.lp.QueueWriteString(raw)
return true
}
}
}
self.lp.QueueWriteString(themes.ColorSettingsAsEscapeCodes(ReadKittyColorSettings()))
return true
}
func (self *handler) redraw_after_category_change() {
self.themes_list.UpdateThemes(self.all_themes.Filtered(self.category_filters[self.current_category()]))
self.set_colors_to_current_theme()
self.draw_screen()
}
func (self *handler) on_key_event(ev *loop.KeyEvent) error {
switch self.state {
case FETCHING:
return self.on_fetching_key_event(ev)
case BROWSING:
return self.on_browsing_key_event(ev)
case SEARCHING:
return self.on_searching_key_event(ev)
case ACCEPTING:
return self.on_accepting_key_event(ev)
}
return nil
}
// browsing ... {{{
func (self *handler) next_category(delta int) {
idx := slices.Index(self.tabs, self.current_category()) + delta + len(self.tabs)
self.set_current_category(self.tabs[idx%len(self.tabs)])
self.redraw_after_category_change()
}
func (self *handler) next(delta int, allow_wrapping bool) {
if self.themes_list.Next(delta, allow_wrapping) {
self.set_colors_to_current_theme()
self.draw_screen()
} else {
self.lp.Beep()
}
}
func (self *handler) on_browsing_key_event(ev *loop.KeyEvent) error {
if ev.MatchesPressOrRepeat("esc") || ev.MatchesPressOrRepeat("q") {
self.lp.Quit(0)
ev.Handled = true
return nil
}
for _, cat := range self.tabs {
if ev.MatchesPressOrRepeat(cat[0:1]) || ev.MatchesPressOrRepeat("alt+"+cat[0:1]) {
ev.Handled = true
if cat != self.current_category() {
self.set_current_category(cat)
self.redraw_after_category_change()
return nil
}
}
}
if ev.MatchesPressOrRepeat("left") || ev.MatchesPressOrRepeat("shift+tab") {
self.next_category(-1)
ev.Handled = true
return nil
}
if ev.MatchesPressOrRepeat("right") || ev.MatchesPressOrRepeat("tab") {
self.next_category(1)
ev.Handled = true
return nil
}
if ev.MatchesPressOrRepeat("j") || ev.MatchesPressOrRepeat("down") {
self.next(1, true)
ev.Handled = true
return nil
}
if ev.MatchesPressOrRepeat("k") || ev.MatchesPressOrRepeat("up") {
self.next(-1, true)
ev.Handled = true
return nil
}
if ev.MatchesPressOrRepeat("page_down") {
ev.Handled = true
sz, err := self.lp.ScreenSize()
if err == nil {
self.next(int(sz.HeightCells)-3, false)
}
return nil
}
if ev.MatchesPressOrRepeat("page_up") {
ev.Handled = true
sz, err := self.lp.ScreenSize()
if err == nil {
self.next(3-int(sz.HeightCells), false)
}
return nil
}
if ev.MatchesPressOrRepeat("s") || ev.MatchesPressOrRepeat("/") {
ev.Handled = true
self.start_search()
return nil
}
if ev.MatchesPressOrRepeat("c") || ev.MatchesPressOrRepeat("enter") {
ev.Handled = true
if self.themes_list == nil || self.themes_list.Len() == 0 {
self.lp.Beep()
} else {
self.state = ACCEPTING
self.draw_screen()
}
}
return nil
}
func (self *handler) start_search() {
self.state = SEARCHING
self.rl.SetText(self.themes_list.current_search)
self.draw_screen()
}
func (self *handler) draw_browsing_screen() {
self.draw_tab_bar()
sz, err := self.lp.ScreenSize()
if err != nil {
return
}
num_rows := int(sz.HeightCells) - 2
mw := self.themes_list.max_width + 1
green_fg, _, _ := strings.Cut(self.lp.SprintStyled("fg=green", "|"), "|")
for _, l := range self.themes_list.Lines(num_rows) {
line := l.text
if l.is_current {
line = strings.ReplaceAll(line, themes.MARK_AFTER, green_fg)
self.lp.PrintStyled("fg=green", ">")
self.lp.PrintStyled("fg=green bold", line)
} else {
self.lp.PrintStyled("fg=green", " ")
self.lp.QueueWriteString(line)
}
self.lp.MoveCursorHorizontally(mw - l.width)
self.lp.Println(SEPARATOR)
num_rows--
}
for ; num_rows > 0; num_rows-- {
self.lp.MoveCursorHorizontally(mw + 1)
self.lp.Println(SEPARATOR)
}
if self.themes_list != nil && self.themes_list.Len() > 0 {
self.draw_theme_demo()
}
if self.state == BROWSING {
self.draw_bottom_bar()
} else {
self.draw_search_bar()
}
}
func (self *handler) draw_bottom_bar() {
sz, err := self.lp.ScreenSize()
if err != nil {
return
}
self.lp.MoveCursorTo(1, int(sz.HeightCells))
self.lp.PrintStyled("reverse", strings.Repeat(" ", int(sz.WidthCells)))
self.lp.QueueWriteString("\r")
draw_tab := func(t, sc string) {
text := self.mark_shortcut(utils.Capitalize(t), sc)
self.lp.PrintStyled("reverse", " "+text+" ")
}
draw_tab("search (/)", "s")
draw_tab("accept (⏎)", "c")
self.lp.QueueWriteString("\x1b[m")
}
func (self *handler) draw_search_bar() {
sz, err := self.lp.ScreenSize()
if err != nil {
return
}
self.lp.MoveCursorTo(1, int(sz.HeightCells))
self.lp.ClearToEndOfLine()
self.rl.RedrawNonAtomic()
}
func (self *handler) mark_shortcut(text, acc string) string {
acc_idx := strings.Index(strings.ToLower(text), strings.ToLower(acc))
return text[:acc_idx] + self.lp.SprintStyled("underline bold", text[acc_idx:acc_idx+1]) + text[acc_idx+1:]
}
func (self *handler) draw_tab_bar() {
sz, err := self.lp.ScreenSize()
if err != nil {
return
}
self.lp.PrintStyled("reverse", strings.Repeat(` `, int(sz.WidthCells)))
self.lp.QueueWriteString("\r")
cc := self.current_category()
draw_tab := func(text, name, acc string) {
is_active := name == cc
if is_active {
text := self.lp.SprintStyled("italic", fmt.Sprintf("%s #%d", text, self.themes_list.Len()))
self.lp.Printf(" %s ", text)
} else {
text = self.mark_shortcut(text, acc)
self.lp.PrintStyled("reverse", " "+text+" ")
}
}
for _, title := range self.tabs {
draw_tab(utils.Capitalize(title), title, string([]rune(title)[0]))
}
self.lp.Println("\x1b[m")
}
func center_string(x string, width int) string {
l := wcswidth.Stringwidth(x)
spaces := int(float64(width-l) / 2)
return strings.Repeat(" ", utils.Max(0, spaces)) + x + strings.Repeat(" ", utils.Max(0, width-(spaces+l)))
}
func (self *handler) draw_theme_demo() {
ssz, err := self.lp.ScreenSize()
if err != nil {
return
}
theme := self.themes_list.CurrentTheme()
if theme == nil {
return
}
xstart := self.themes_list.max_width + 3
sz := int(ssz.WidthCells) - xstart
if sz < 20 {
return
}
sz--
y := 0
colors := strings.Split(`black red green yellow blue magenta cyan white`, ` `)
trunc := sz/8 - 1
pat := regexp.MustCompile(`\s+`)
next_line := func() {
self.lp.QueueWriteString("\r")
y++
self.lp.MoveCursorTo(xstart, y+1)
self.lp.QueueWriteString(SEPARATOR + " ")
}
write_para := func(text string) {
text = pat.ReplaceAllLiteralString(text, " ")
for text != "" {
t, sp := wcswidth.TruncateToVisualLengthWithWidth(text, sz)
self.lp.QueueWriteString(t)
next_line()
text = text[sp:]
}
}
write_colors := func(bg string) {
for _, intense := range []bool{false, true} {
buf := strings.Builder{}
buf.Grow(1024)
for _, c := range colors {
s := c
if intense {
s = "bright-" + s
}
sTrunc := s
if len(sTrunc) > trunc {
sTrunc = sTrunc[:trunc]
}
buf.WriteString(self.lp.SprintStyled("fg="+s, sTrunc))
buf.WriteString(" ")
}
text := strings.TrimSpace(buf.String())
if bg == "" {
self.lp.QueueWriteString(text)
} else {
s := bg
if intense {
s = "bright-" + s
}
self.lp.PrintStyled("bg="+s, text)
}
next_line()
}
next_line()
}
self.lp.MoveCursorTo(1, 1)
next_line()
self.lp.PrintStyled("fg=green bold", center_string(theme.Name(), sz))
next_line()
if theme.Author() != "" {
self.lp.PrintStyled("italic", center_string(theme.Author(), sz))
next_line()
}
if theme.Blurb() != "" {
next_line()
write_para(theme.Blurb())
next_line()
}
write_colors("")
for _, bg := range colors {
write_colors(bg)
}
}
// }}}
// accepting {{{
func (self *handler) on_accepting_key_event(ev *loop.KeyEvent) error {
if ev.MatchesPressOrRepeat("q") || ev.MatchesPressOrRepeat("esc") || ev.MatchesPressOrRepeat("shift+q") {
ev.Handled = true
self.lp.Quit(0)
return nil
}
if ev.MatchesPressOrRepeat("a") || ev.MatchesPressOrRepeat("shift+a") {
ev.Handled = true
self.state = BROWSING
self.draw_screen()
return nil
}
if ev.MatchesPressOrRepeat("p") || ev.MatchesPressOrRepeat("shift+p") {
ev.Handled = true
self.themes_list.CurrentTheme().SaveInDir(utils.ConfigDir())
self.update_recent()
self.lp.Quit(0)
return nil
}
if ev.MatchesPressOrRepeat("m") || ev.MatchesPressOrRepeat("shift+m") {
ev.Handled = true
self.themes_list.CurrentTheme().SaveInConf(utils.ConfigDir(), self.opts.ReloadIn, self.opts.ConfigFileName)
self.update_recent()
self.lp.Quit(0)
return nil
}
return nil
}
func (self *handler) update_recent() {
if self.themes_list != nil {
recent := slices.Clone(self.cached_data.Recent)
name := self.themes_list.CurrentTheme().Name()
recent = utils.Remove(recent, name)
recent = append([]string{name}, recent...)
if len(recent) > 20 {
recent = recent[:20]
}
self.cached_data.Recent = recent
}
}
func (self *handler) draw_accepting_screen() {
name := self.themes_list.CurrentTheme().Name()
name = self.lp.SprintStyled("fg=green bold", name)
kc := self.lp.SprintStyled("italic", self.opts.ConfigFileName)
ac := func(x string) string {
return self.lp.SprintStyled("fg=red", x)
}
self.lp.AllowLineWrapping(true)
defer self.lp.AllowLineWrapping(false)
self.lp.Printf(`You have chosen the %s theme`, name)
self.lp.Println()
self.lp.Println()
self.lp.Println(`What would you like to do?`)
self.lp.Println()
self.lp.Printf(` %sodify %s to load %s`, ac("M"), kc, name)
self.lp.Println()
self.lp.Println()
self.lp.Printf(` %slace the theme file in %s but do not modify %s`, ac("P"), utils.ConfigDir(), kc)
self.lp.Println()
self.lp.Println()
self.lp.Printf(` %sbort and return to list of themes`, ac("A"))
self.lp.Println()
self.lp.Println()
self.lp.Printf(` %suit`, ac("Q"))
self.lp.Println()
}
// }}}
// searching {{{
func (self *handler) update_search() {
text := self.rl.AllText()
if self.themes_list.UpdateSearch(text) {
self.set_colors_to_current_theme()
self.draw_screen()
} else {
self.draw_search_bar()
}
}
func (self *handler) on_text(text string, a, b bool) error {
if self.state == SEARCHING {
err := self.rl.OnText(text, a, b)
if err != nil {
return err
}
self.update_search()
}
return nil
}
func (self *handler) on_searching_key_event(ev *loop.KeyEvent) error {
if ev.MatchesPressOrRepeat("enter") {
ev.Handled = true
self.state = BROWSING
self.draw_bottom_bar()
return nil
}
if ev.MatchesPressOrRepeat("esc") {
ev.Handled = true
self.state = BROWSING
self.themes_list.UpdateSearch("")
self.set_colors_to_current_theme()
self.draw_screen()
return nil
}
err := self.rl.OnKeyEvent(ev)
if err != nil {
return err
}
if ev.Handled {
self.update_search()
}
return nil
}
// }}}