2023-03-14 20:24:21 +05:30

617 lines
15 KiB
Go

// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package themes
import (
"fmt"
"io"
"path/filepath"
"regexp"
"strings"
"time"
"kitty/tools/config"
"kitty/tools/themes"
"kitty/tools/tui/loop"
"kitty/tools/tui/readline"
"kitty/tools/utils"
"kitty/tools/wcswidth"
"golang.org/x/exp/maps"
"golang.org/x/exp/slices"
)
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
quit_on_next_key_release int
}
// 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.quit_on_next_key_release = 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.quit_on_next_key_release = -1
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 {
if self.quit_on_next_key_release > -1 && ev.Type == loop.RELEASE {
self.lp.Quit(self.quit_on_next_key_release)
return nil
}
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.quit_on_next_key_release = 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)
}
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(" ", spaces) + x + strings.Repeat(" ", 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
}
if len(c) > trunc {
c = c[:trunc]
}
buf.WriteString(self.lp.SprintStyled("fg="+c, c))
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.Handled = true
self.quit_on_next_key_release = 0
return nil
}
if ev.MatchesPressOrRepeat("a") {
ev.Handled = true
self.state = BROWSING
self.draw_screen()
return nil
}
if ev.MatchesPressOrRepeat("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.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...)
self.cached_data.Recent = recent[:20]
}
}
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
}
// }}}