From 21954937fba3f0337d1f174d2bf3ea31eace2e8e Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 13 Mar 2023 21:31:09 +0530 Subject: [PATCH] More work on porting themes --- tools/cmd/themes/list.go | 50 ++++ tools/cmd/themes/main.go | 5 + tools/cmd/themes/ui.go | 522 ++++++++++++++++++++++++++++++++++++- tools/themes/collection.go | 47 +++- tools/tui/loop/api.go | 2 +- 5 files changed, 616 insertions(+), 10 deletions(-) diff --git a/tools/cmd/themes/list.go b/tools/cmd/themes/list.go index 1161818a7..3a2629333 100644 --- a/tools/cmd/themes/list.go +++ b/tools/cmd/themes/list.go @@ -6,6 +6,8 @@ import ( "fmt" "kitty/tools/themes" + "kitty/tools/utils" + "kitty/tools/wcswidth" ) var _ = fmt.Print @@ -40,10 +42,58 @@ func (self *ThemesList) Next(delta int, allow_wrapping bool) bool { return true } +func limit_lengths(text string) string { + t, x := wcswidth.TruncateToVisualLengthWithWidth(text, 31) + if x >= len(text) { + return text + } + return t + "…" +} + func (self *ThemesList) UpdateThemes(themes *themes.Themes) { self.themes, self.all_themes = themes, themes if self.current_search != "" { self.themes = self.all_themes.Copy() + self.display_strings = utils.Map(limit_lengths, self.themes.ApplySearch(self.current_search)) } else { + self.display_strings = utils.Map(limit_lengths, self.themes.Names()) } + self.widths = utils.Map(wcswidth.Stringwidth, self.display_strings) + self.max_width = utils.Max(0, self.widths...) + self.current_idx = 0 +} + +func (self *ThemesList) UpdateSearch(query string) bool { + if query == self.current_search || self.all_themes == nil { + return false + } + self.current_search = query + self.UpdateThemes(self.all_themes) + return true +} + +type Line struct { + text string + width int + is_current bool +} + +func (self *ThemesList) Lines(num_rows int) []Line { + if num_rows < 1 { + return nil + } + ans := make([]Line, 0, len(self.display_strings)) + before_num := utils.Min(self.current_idx, num_rows-1) + start := self.current_idx - before_num + for i := start; i < utils.Min(start+num_rows, len(self.display_strings)); i++ { + ans = append(ans, Line{self.display_strings[i], self.widths[i], i == self.current_idx}) + } + return ans +} + +func (self *ThemesList) CurrentTheme() *themes.Theme { + if self.themes == nil { + return nil + } + return self.themes.At(self.current_idx) } diff --git a/tools/cmd/themes/main.go b/tools/cmd/themes/main.go index bec57c17d..4825a9916 100644 --- a/tools/cmd/themes/main.go +++ b/tools/cmd/themes/main.go @@ -74,6 +74,11 @@ func main(_ *cli.Command, opts *Options, args []string) (rc int, err error) { lp.SetCursorVisible(true) return `` } + lp.OnResize = func(_, _ loop.ScreenSize) error { + h.draw_screen() + return nil + } + lp.OnKeyEvent = h.on_key_event err = lp.Run() if err != nil { return 1, err diff --git a/tools/cmd/themes/ui.go b/tools/cmd/themes/ui.go index b8b54d0a4..309d1a2d3 100644 --- a/tools/cmd/themes/ui.go +++ b/tools/cmd/themes/ui.go @@ -5,9 +5,20 @@ package themes import ( "fmt" "io" + "path/filepath" + "regexp" + "strings" + "time" + + "kitty/tools/config" "kitty/tools/themes" "kitty/tools/tui/loop" - "time" + "kitty/tools/tui/readline" + "kitty/tools/utils" + "kitty/tools/wcswidth" + + "golang.org/x/exp/maps" + "golang.org/x/exp/slices" ) var _ = fmt.Print @@ -20,6 +31,7 @@ const ( SEARCHING ACCEPTING ) +const SEPARATOR = "║" type CachedData struct { Recent []string `json:"recent"` @@ -32,17 +44,38 @@ type fetch_data struct { 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 + 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))) @@ -50,6 +83,14 @@ func (self *handler) fetch_themes() { self.fetch_result <- r } +func (self *handler) on_fetching_key_event(ev *loop.KeyEvent) error { + if ev.MatchesRelease("esc") { + self.lp.Quit(0) + ev.Handled = true + } + return nil +} + func (self *handler) on_wakeup() error { r := <-self.fetch_result if r.err != nil { @@ -62,23 +103,490 @@ func (self *handler) on_wakeup() error { 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() { - // TODO: Implement me + 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() { - // TODO: Implement me + 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.MatchesRelease("esc") || ev.MatchesRelease("q") { + self.lp.Quit(0) + ev.Handled = true + return nil + } + for _, cat := range self.tabs { + if ev.MatchesRelease(cat[0:1]) || ev.MatchesRelease("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.MatchesRelease("left") || ev.MatchesRelease("shift+tab") { + self.next_category(-1) + ev.Handled = true + return nil + } + if ev.MatchesRelease("right") || ev.MatchesRelease("tab") { + self.next_category(1) + ev.Handled = true + return nil + } + if ev.MatchesRelease("j") || ev.MatchesRelease("down") { + self.next(1, true) + ev.Handled = true + return nil + } + if ev.MatchesRelease("k") || ev.MatchesRelease("up") { + self.next(-1, true) + ev.Handled = true + return nil + } + if ev.MatchesRelease("page_down") { + ev.Handled = true + sz, err := self.lp.ScreenSize() + if err == nil { + self.next(int(sz.HeightCells)-3, false) + } + return nil + } + if ev.MatchesRelease("page_up") { + ev.Handled = true + sz, err := self.lp.ScreenSize() + if err == nil { + self.next(3-int(sz.HeightCells), false) + } + return nil + } + if ev.MatchesRelease("s") || ev.MatchesRelease("/") { + ev.Handled = true + self.start_search() + return nil + } + 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 + for _, l := range self.themes_list.Lines(num_rows) { + num_rows-- + line := l.text + if l.is_current { + line = strings.ReplaceAll(line, themes.MARK_BEFORE, self.lp.SprintStyled("fg=green")) + if l.is_current { + 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 + } + buf.WriteString(self.lp.SprintStyled("fg="+c, c[:trunc])) + 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.MatchesRelease("q") || ev.MatchesRelease("esc") { + ev.Handled = true + self.lp.Quit(0) + return nil + } + if ev.MatchesRelease("a") { + ev.Handled = true + self.state = BROWSING + self.draw_screen() + return nil + } + if ev.MatchesRelease("p") { + ev.Handled = true + self.themes_list.CurrentTheme().SaveInDir(utils.ConfigDir()) + self.update_recent() + self.lp.Quit(0) + return nil + } + if ev.MatchesRelease("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) on_searching_key_event(ev *loop.KeyEvent) error { + if ev.MatchesRelease("enter") { + ev.Handled = true + self.state = BROWSING + self.draw_bottom_bar() + return nil + } + if ev.MatchesPressOrRepeat("enter") || ev.MatchesPressOrRepeat("esc") { + ev.Handled = true + return nil + } + if ev.MatchesRelease("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 + } + text := self.rl.AllText() + if self.themes_list.UpdateSearch(text) { + self.set_colors_to_current_theme() + self.draw_screen() + } else { + self.draw_search_bar() + } + return nil +} + +// }}} diff --git a/tools/themes/collection.go b/tools/themes/collection.go index 36a173a50..15d0cbe62 100644 --- a/tools/themes/collection.go +++ b/tools/themes/collection.go @@ -508,6 +508,12 @@ type Theme struct { is_user_defined bool } +func (self *Theme) Name() string { return self.metadata.Name } +func (self *Theme) Author() string { return self.metadata.Author } +func (self *Theme) Blurb() string { return self.metadata.Blurb } +func (self *Theme) IsDark() bool { return self.metadata.Is_dark } +func (self *Theme) IsUserDefined() bool { return self.is_user_defined } + func (self *Theme) load_code() (string, error) { if self.zip_reader != nil { f, err := self.zip_reader.Open() @@ -599,6 +605,15 @@ func reload_config(reload_in ReloadDestination) bool { return false } +func (self *Theme) SaveInDir(dirpath string) (err error) { + path := filepath.Join(dirpath, self.Name()+".conf") + code, err := self.Code() + if err != nil { + return err + } + return utils.AtomicUpdateFile(path, utils.UnsafeStringToBytes(code), 0o644) +} + func (self *Theme) SaveInConf(config_dir, reload_in, config_file_name string) (err error) { os.MkdirAll(config_dir, 0o755) path := filepath.Join(config_dir, `current-theme.conf`) @@ -656,6 +671,10 @@ func (self *Theme) AsEscapeCodes() (string, error) { if err != nil { return "", err } + return ColorSettingsAsEscapeCodes(settings), nil +} + +func ColorSettingsAsEscapeCodes(settings map[string]string) string { w := strings.Builder{} w.Grow(4096) @@ -707,7 +726,7 @@ func (self *Theme) AsEscapeCodes() (string, error) { set_color(i, rgba.AsRGBSharp()) } w.WriteString("\033\\") - return w.String(), nil + return w.String() } type Themes struct { @@ -733,6 +752,24 @@ func theme_name_from_file_name(fname string) string { } func (self *Themes) Len() int { return len(self.name_map) } +func (self *Themes) At(x int) *Theme { + if x >= len(self.index_map) || x < 0 { + return nil + } + return self.name_map[self.index_map[x]] +} +func (self *Themes) Names() []string { return self.index_map } + +func (self *Themes) Filtered(is_ok func(*Theme) bool) *Themes { + themes := utils.Filter(maps.Values(self.name_map), is_ok) + ans := Themes{name_map: make(map[string]*Theme, len(themes))} + for _, theme := range themes { + ans.name_map[theme.metadata.Name] = theme + } + ans.index_map = maps.Keys(ans.name_map) + ans.index_map = utils.StableSortWithKey(ans.index_map, strings.ToLower) + return &ans +} func (self *Themes) AddFromFile(path string) (*Theme, error) { m, conf, err := parse_theme_metadata(path) @@ -826,8 +863,13 @@ func match(expression string, items []string) []*subseq.Match { return matches } +const ( + MARK_BEFORE = "\033[33m" + MARK_AFTER = "\033[39m" +) + func (self *Themes) ApplySearch(expression string, marks ...string) []string { - mark_before, mark_after := "\033[33m", "\033[39m" + mark_before, mark_after := MARK_BEFORE, MARK_AFTER if len(marks) == 2 { mark_before, mark_after = marks[0], marks[1] } @@ -847,6 +889,7 @@ func (self *Themes) ApplySearch(expression string, marks ...string) []string { } self.name_map = name_map self.index_map = maps.Keys(name_map) + self.index_map = utils.StableSortWithKey(self.index_map, strings.ToLower) return ans } diff --git a/tools/tui/loop/api.go b/tools/tui/loop/api.go index 280bee7a1..d482f4714 100644 --- a/tools/tui/loop/api.go +++ b/tools/tui/loop/api.go @@ -311,7 +311,7 @@ func (self *Loop) SetCursorVisible(visible bool) { const MoveCursorToTemplate = "\x1b[%d;%dH" -func (self *Loop) MoveCursorTo(x, y int) { +func (self *Loop) MoveCursorTo(x, y int) { // 1, 1 is top left if x > 0 && y > 0 { self.QueueWriteString(fmt.Sprintf(MoveCursorToTemplate, y, x)) }