ui.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645
  1. // License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
  2. package themes
  3. import (
  4. "fmt"
  5. "io"
  6. "kitty"
  7. "maps"
  8. "regexp"
  9. "slices"
  10. "strings"
  11. "time"
  12. "kitty/tools/config"
  13. "kitty/tools/themes"
  14. "kitty/tools/tui/loop"
  15. "kitty/tools/tui/readline"
  16. "kitty/tools/utils"
  17. "kitty/tools/wcswidth"
  18. )
  19. var _ = fmt.Print
  20. type State int
  21. const (
  22. FETCHING State = iota
  23. BROWSING
  24. SEARCHING
  25. ACCEPTING
  26. )
  27. const SEPARATOR = "║"
  28. type CachedData struct {
  29. Recent []string `json:"recent"`
  30. Category string `json:"category"`
  31. }
  32. type fetch_data struct {
  33. themes *themes.Themes
  34. err error
  35. closer io.Closer
  36. }
  37. var category_filters = map[string]func(*themes.Theme) bool{
  38. "all": func(*themes.Theme) bool { return true },
  39. "dark": func(t *themes.Theme) bool { return t.IsDark() },
  40. "light": func(t *themes.Theme) bool { return !t.IsDark() },
  41. "user": func(t *themes.Theme) bool { return t.IsUserDefined() },
  42. }
  43. func recent_filter(items []string) func(*themes.Theme) bool {
  44. allowed := utils.NewSetWithItems(items...)
  45. return func(t *themes.Theme) bool {
  46. return allowed.Has(t.Name())
  47. }
  48. }
  49. type handler struct {
  50. lp *loop.Loop
  51. opts *Options
  52. cached_data *CachedData
  53. state State
  54. fetch_result chan fetch_data
  55. all_themes *themes.Themes
  56. themes_closer io.Closer
  57. themes_list *ThemesList
  58. category_filters map[string]func(*themes.Theme) bool
  59. colors_set_once bool
  60. tabs []string
  61. rl *readline.Readline
  62. }
  63. // fetching {{{
  64. func (self *handler) fetch_themes() {
  65. r := fetch_data{}
  66. r.themes, r.closer, r.err = themes.LoadThemes(time.Duration(self.opts.CacheAge * float64(time.Hour*24)))
  67. self.lp.WakeupMainThread()
  68. self.fetch_result <- r
  69. }
  70. func (self *handler) on_fetching_key_event(ev *loop.KeyEvent) error {
  71. if ev.MatchesPressOrRepeat("esc") {
  72. self.lp.Quit(0)
  73. ev.Handled = true
  74. }
  75. return nil
  76. }
  77. func (self *handler) on_wakeup() error {
  78. r := <-self.fetch_result
  79. if r.err != nil {
  80. return r.err
  81. }
  82. self.state = BROWSING
  83. self.all_themes = r.themes
  84. self.themes_closer = r.closer
  85. self.redraw_after_category_change()
  86. return nil
  87. }
  88. func (self *handler) draw_fetching_screen() {
  89. self.lp.Println("Downloading themes from repository, please wait...")
  90. }
  91. // }}}
  92. func (self *handler) finalize() {
  93. t := self.themes_closer
  94. if t != nil {
  95. t.Close()
  96. self.themes_closer = nil
  97. }
  98. }
  99. func (self *handler) initialize() {
  100. self.tabs = strings.Split("all dark light recent user", " ")
  101. self.rl = readline.New(self.lp, readline.RlInit{DontMarkPrompts: true, Prompt: "/"})
  102. self.themes_list = &ThemesList{}
  103. self.fetch_result = make(chan fetch_data)
  104. self.category_filters = make(map[string]func(*themes.Theme) bool, len(category_filters)+1)
  105. maps.Copy(self.category_filters, category_filters)
  106. self.category_filters["recent"] = recent_filter(self.cached_data.Recent)
  107. go self.fetch_themes()
  108. self.draw_screen()
  109. }
  110. func (self *handler) enforce_cursor_state() {
  111. self.lp.SetCursorVisible(self.state == FETCHING)
  112. }
  113. func (self *handler) draw_screen() {
  114. self.lp.StartAtomicUpdate()
  115. defer self.lp.EndAtomicUpdate()
  116. self.lp.ClearScreen()
  117. self.enforce_cursor_state()
  118. switch self.state {
  119. case FETCHING:
  120. self.draw_fetching_screen()
  121. case BROWSING, SEARCHING:
  122. self.draw_browsing_screen()
  123. case ACCEPTING:
  124. self.draw_accepting_screen()
  125. }
  126. }
  127. func (self *handler) current_category() string {
  128. ans := self.cached_data.Category
  129. if self.category_filters[ans] == nil {
  130. ans = "all"
  131. }
  132. return ans
  133. }
  134. func (self *handler) set_current_category(category string) {
  135. if self.category_filters[category] == nil {
  136. category = "all"
  137. }
  138. self.cached_data.Category = category
  139. }
  140. func ReadKittyColorSettings() map[string]string {
  141. settings := make(map[string]string, 512)
  142. handle_line := func(key, val string) error {
  143. if themes.AllColorSettingNames[key] {
  144. settings[key] = val
  145. }
  146. return nil
  147. }
  148. config.ReadKittyConfig(handle_line)
  149. return settings
  150. }
  151. func (self *handler) set_colors_to_current_theme() bool {
  152. if self.themes_list == nil && self.colors_set_once {
  153. return false
  154. }
  155. self.colors_set_once = true
  156. if self.themes_list != nil {
  157. t := self.themes_list.CurrentTheme()
  158. if t != nil {
  159. raw, err := t.AsEscapeCodes()
  160. if err == nil {
  161. self.lp.QueueWriteString(raw)
  162. return true
  163. }
  164. }
  165. }
  166. self.lp.QueueWriteString(themes.ColorSettingsAsEscapeCodes(ReadKittyColorSettings()))
  167. return true
  168. }
  169. func (self *handler) redraw_after_category_change() {
  170. self.themes_list.UpdateThemes(self.all_themes.Filtered(self.category_filters[self.current_category()]))
  171. self.set_colors_to_current_theme()
  172. self.draw_screen()
  173. }
  174. func (self *handler) on_key_event(ev *loop.KeyEvent) error {
  175. switch self.state {
  176. case FETCHING:
  177. return self.on_fetching_key_event(ev)
  178. case BROWSING:
  179. return self.on_browsing_key_event(ev)
  180. case SEARCHING:
  181. return self.on_searching_key_event(ev)
  182. case ACCEPTING:
  183. return self.on_accepting_key_event(ev)
  184. }
  185. return nil
  186. }
  187. // browsing ... {{{
  188. func (self *handler) next_category(delta int) {
  189. idx := slices.Index(self.tabs, self.current_category()) + delta + len(self.tabs)
  190. self.set_current_category(self.tabs[idx%len(self.tabs)])
  191. self.redraw_after_category_change()
  192. }
  193. func (self *handler) next(delta int, allow_wrapping bool) {
  194. if self.themes_list.Next(delta, allow_wrapping) {
  195. self.set_colors_to_current_theme()
  196. self.draw_screen()
  197. } else {
  198. self.lp.Beep()
  199. }
  200. }
  201. func (self *handler) on_browsing_key_event(ev *loop.KeyEvent) error {
  202. if ev.MatchesPressOrRepeat("esc") || ev.MatchesCaseInsensitiveTextOrKey("q") {
  203. self.lp.Quit(0)
  204. ev.Handled = true
  205. return nil
  206. }
  207. for _, cat := range self.tabs {
  208. if ev.MatchesPressOrRepeat(cat[0:1]) || ev.MatchesPressOrRepeat("alt+"+cat[0:1]) || ev.MatchesCaseInsensitiveTextOrKey(cat[0:1]) {
  209. ev.Handled = true
  210. if cat != self.current_category() {
  211. self.set_current_category(cat)
  212. self.redraw_after_category_change()
  213. return nil
  214. }
  215. }
  216. }
  217. if ev.MatchesPressOrRepeat("left") || ev.MatchesPressOrRepeat("shift+tab") {
  218. self.next_category(-1)
  219. ev.Handled = true
  220. return nil
  221. }
  222. if ev.MatchesPressOrRepeat("right") || ev.MatchesPressOrRepeat("tab") {
  223. self.next_category(1)
  224. ev.Handled = true
  225. return nil
  226. }
  227. if ev.MatchesCaseInsensitiveTextOrKey("j") || ev.MatchesPressOrRepeat("down") {
  228. self.next(1, true)
  229. ev.Handled = true
  230. return nil
  231. }
  232. if ev.MatchesCaseInsensitiveTextOrKey("k") || ev.MatchesPressOrRepeat("up") {
  233. self.next(-1, true)
  234. ev.Handled = true
  235. return nil
  236. }
  237. if ev.MatchesPressOrRepeat("page_down") {
  238. ev.Handled = true
  239. sz, err := self.lp.ScreenSize()
  240. if err == nil {
  241. self.next(int(sz.HeightCells)-3, false)
  242. }
  243. return nil
  244. }
  245. if ev.MatchesPressOrRepeat("page_up") {
  246. ev.Handled = true
  247. sz, err := self.lp.ScreenSize()
  248. if err == nil {
  249. self.next(3-int(sz.HeightCells), false)
  250. }
  251. return nil
  252. }
  253. if ev.MatchesCaseInsensitiveTextOrKey("s") || ev.MatchesCaseInsensitiveTextOrKey("/") {
  254. ev.Handled = true
  255. self.start_search()
  256. return nil
  257. }
  258. if ev.MatchesCaseInsensitiveTextOrKey("c") || ev.MatchesPressOrRepeat("enter") {
  259. ev.Handled = true
  260. if self.themes_list == nil || self.themes_list.Len() == 0 {
  261. self.lp.Beep()
  262. } else {
  263. self.state = ACCEPTING
  264. self.draw_screen()
  265. }
  266. }
  267. return nil
  268. }
  269. func (self *handler) start_search() {
  270. self.state = SEARCHING
  271. self.rl.SetText(self.themes_list.current_search)
  272. self.draw_screen()
  273. }
  274. func (self *handler) draw_browsing_screen() {
  275. self.draw_tab_bar()
  276. sz, err := self.lp.ScreenSize()
  277. if err != nil {
  278. return
  279. }
  280. num_rows := int(sz.HeightCells) - 2
  281. mw := self.themes_list.max_width + 1
  282. green_fg, _, _ := strings.Cut(self.lp.SprintStyled("fg=green", "|"), "|")
  283. for _, l := range self.themes_list.Lines(num_rows) {
  284. line := l.text
  285. if l.is_current {
  286. line = strings.ReplaceAll(line, themes.MARK_AFTER, green_fg)
  287. self.lp.PrintStyled("fg=green", ">")
  288. self.lp.PrintStyled("fg=green bold", line)
  289. } else {
  290. self.lp.PrintStyled("fg=green", " ")
  291. self.lp.QueueWriteString(line)
  292. }
  293. self.lp.MoveCursorHorizontally(mw - l.width)
  294. self.lp.Println(SEPARATOR)
  295. num_rows--
  296. }
  297. for ; num_rows > 0; num_rows-- {
  298. self.lp.MoveCursorHorizontally(mw + 1)
  299. self.lp.Println(SEPARATOR)
  300. }
  301. if self.themes_list != nil && self.themes_list.Len() > 0 {
  302. self.draw_theme_demo()
  303. }
  304. if self.state == BROWSING {
  305. self.draw_bottom_bar()
  306. } else {
  307. self.draw_search_bar()
  308. }
  309. }
  310. func (self *handler) draw_bottom_bar() {
  311. sz, err := self.lp.ScreenSize()
  312. if err != nil {
  313. return
  314. }
  315. self.lp.MoveCursorTo(1, int(sz.HeightCells))
  316. self.lp.PrintStyled("reverse", strings.Repeat(" ", int(sz.WidthCells)))
  317. self.lp.QueueWriteString("\r")
  318. draw_tab := func(t, sc string) {
  319. text := self.mark_shortcut(utils.Capitalize(t), sc)
  320. self.lp.PrintStyled("reverse", " "+text+" ")
  321. }
  322. draw_tab("search (/)", "s")
  323. draw_tab("accept (⏎)", "c")
  324. self.lp.QueueWriteString("\x1b[m")
  325. }
  326. func (self *handler) draw_search_bar() {
  327. sz, err := self.lp.ScreenSize()
  328. if err != nil {
  329. return
  330. }
  331. self.lp.MoveCursorTo(1, int(sz.HeightCells))
  332. self.lp.ClearToEndOfLine()
  333. self.rl.RedrawNonAtomic()
  334. }
  335. func (self *handler) mark_shortcut(text, acc string) string {
  336. acc_idx := strings.Index(strings.ToLower(text), strings.ToLower(acc))
  337. return text[:acc_idx] + self.lp.SprintStyled("underline bold", text[acc_idx:acc_idx+1]) + text[acc_idx+1:]
  338. }
  339. func (self *handler) draw_tab_bar() {
  340. sz, err := self.lp.ScreenSize()
  341. if err != nil {
  342. return
  343. }
  344. self.lp.PrintStyled("reverse", strings.Repeat(` `, int(sz.WidthCells)))
  345. self.lp.QueueWriteString("\r")
  346. cc := self.current_category()
  347. draw_tab := func(text, name, acc string) {
  348. is_active := name == cc
  349. if is_active {
  350. text := self.lp.SprintStyled("italic", fmt.Sprintf("%s #%d", text, self.themes_list.Len()))
  351. self.lp.Printf(" %s ", text)
  352. } else {
  353. text = self.mark_shortcut(text, acc)
  354. self.lp.PrintStyled("reverse", " "+text+" ")
  355. }
  356. }
  357. for _, title := range self.tabs {
  358. draw_tab(utils.Capitalize(title), title, string([]rune(title)[0]))
  359. }
  360. self.lp.Println("\x1b[m")
  361. }
  362. func center_string(x string, width int) string {
  363. l := wcswidth.Stringwidth(x)
  364. spaces := int(float64(width-l) / 2)
  365. return strings.Repeat(" ", utils.Max(0, spaces)) + x + strings.Repeat(" ", utils.Max(0, width-(spaces+l)))
  366. }
  367. func (self *handler) draw_theme_demo() {
  368. ssz, err := self.lp.ScreenSize()
  369. if err != nil {
  370. return
  371. }
  372. theme := self.themes_list.CurrentTheme()
  373. if theme == nil {
  374. return
  375. }
  376. xstart := self.themes_list.max_width + 3
  377. sz := int(ssz.WidthCells) - xstart
  378. if sz < 20 {
  379. return
  380. }
  381. sz--
  382. y := 0
  383. colors := strings.Split(`black red green yellow blue magenta cyan white`, ` `)
  384. trunc := sz/8 - 1
  385. pat := regexp.MustCompile(`\s+`)
  386. next_line := func() {
  387. self.lp.QueueWriteString("\r")
  388. y++
  389. self.lp.MoveCursorTo(xstart, y+1)
  390. self.lp.QueueWriteString(SEPARATOR + " ")
  391. }
  392. write_para := func(text string) {
  393. text = pat.ReplaceAllLiteralString(text, " ")
  394. for text != "" {
  395. t, sp := wcswidth.TruncateToVisualLengthWithWidth(text, sz)
  396. self.lp.QueueWriteString(t)
  397. next_line()
  398. text = text[sp:]
  399. }
  400. }
  401. write_colors := func(bg string) {
  402. for _, intense := range []bool{false, true} {
  403. buf := strings.Builder{}
  404. buf.Grow(1024)
  405. for _, c := range colors {
  406. s := c
  407. if intense {
  408. s = "bright-" + s
  409. }
  410. sTrunc := s
  411. if len(sTrunc) > trunc {
  412. sTrunc = sTrunc[:trunc]
  413. }
  414. buf.WriteString(self.lp.SprintStyled("fg="+s, sTrunc))
  415. buf.WriteString(" ")
  416. }
  417. text := strings.TrimSpace(buf.String())
  418. if bg == "" {
  419. self.lp.QueueWriteString(text)
  420. } else {
  421. s := bg
  422. if intense {
  423. s = "bright-" + s
  424. }
  425. self.lp.PrintStyled("bg="+s, text)
  426. }
  427. next_line()
  428. }
  429. next_line()
  430. }
  431. self.lp.MoveCursorTo(1, 1)
  432. next_line()
  433. self.lp.PrintStyled("fg=green bold", center_string(theme.Name(), sz))
  434. next_line()
  435. if theme.Author() != "" {
  436. self.lp.PrintStyled("italic", center_string(theme.Author(), sz))
  437. next_line()
  438. }
  439. if theme.Blurb() != "" {
  440. next_line()
  441. write_para(theme.Blurb())
  442. next_line()
  443. }
  444. write_colors("")
  445. for _, bg := range colors {
  446. write_colors(bg)
  447. }
  448. }
  449. // }}}
  450. // accepting {{{
  451. func (self *handler) on_accepting_key_event(ev *loop.KeyEvent) error {
  452. if ev.MatchesCaseInsensitiveTextOrKey("q") || ev.MatchesPressOrRepeat("esc") || ev.MatchesPressOrRepeat("shift+q") {
  453. ev.Handled = true
  454. self.lp.Quit(0)
  455. return nil
  456. }
  457. if ev.MatchesCaseInsensitiveTextOrKey("a") || ev.MatchesPressOrRepeat("shift+a") {
  458. ev.Handled = true
  459. self.state = BROWSING
  460. self.draw_screen()
  461. return nil
  462. }
  463. if ev.MatchesCaseInsensitiveTextOrKey("p") || ev.MatchesPressOrRepeat("shift+p") {
  464. ev.Handled = true
  465. self.themes_list.CurrentTheme().SaveInDir(utils.ConfigDir())
  466. self.update_recent()
  467. self.lp.Quit(0)
  468. return nil
  469. }
  470. if ev.MatchesCaseInsensitiveTextOrKey("m") || ev.MatchesPressOrRepeat("shift+m") {
  471. ev.Handled = true
  472. self.themes_list.CurrentTheme().SaveInConf(utils.ConfigDir(), self.opts.ReloadIn, self.opts.ConfigFileName)
  473. self.update_recent()
  474. self.lp.Quit(0)
  475. return nil
  476. }
  477. scheme := func(name string) error {
  478. ev.Handled = true
  479. self.themes_list.CurrentTheme().SaveInFile(utils.ConfigDir(), name)
  480. self.update_recent()
  481. self.lp.Quit(0)
  482. return nil
  483. }
  484. if ev.MatchesCaseInsensitiveTextOrKey("d") || ev.MatchesPressOrRepeat("shift+d") {
  485. return scheme(kitty.DarkThemeFileName)
  486. }
  487. if ev.MatchesCaseInsensitiveTextOrKey("l") || ev.MatchesPressOrRepeat("shift+l") {
  488. return scheme(kitty.LightThemeFileName)
  489. }
  490. if ev.MatchesCaseInsensitiveTextOrKey("n") || ev.MatchesPressOrRepeat("shift+n") {
  491. return scheme(kitty.NoPreferenceThemeFileName)
  492. }
  493. return nil
  494. }
  495. func (self *handler) update_recent() {
  496. if self.themes_list != nil {
  497. recent := slices.Clone(self.cached_data.Recent)
  498. name := self.themes_list.CurrentTheme().Name()
  499. recent = utils.Remove(recent, name)
  500. recent = append([]string{name}, recent...)
  501. if len(recent) > 20 {
  502. recent = recent[:20]
  503. }
  504. self.cached_data.Recent = recent
  505. }
  506. }
  507. func (self *handler) draw_accepting_screen() {
  508. name := self.themes_list.CurrentTheme().Name()
  509. name = self.lp.SprintStyled("fg=green bold", name)
  510. kc := self.lp.SprintStyled("italic", self.opts.ConfigFileName)
  511. ac := func(x string) string {
  512. return self.lp.SprintStyled("fg=red", x)
  513. }
  514. self.lp.AllowLineWrapping(true)
  515. defer self.lp.AllowLineWrapping(false)
  516. self.lp.Printf(`You have chosen the %s theme`, name)
  517. self.lp.Println()
  518. self.lp.Println()
  519. self.lp.Println(`What would you like to do?`)
  520. self.lp.Println()
  521. self.lp.Printf(` %sodify %s to load %s`, ac("M"), kc, name)
  522. self.lp.Println()
  523. self.lp.Println()
  524. self.lp.Printf(` %slace the theme file in %s but do not modify %s`, ac("P"), utils.ConfigDir(), kc)
  525. self.lp.Println()
  526. self.lp.Println()
  527. self.lp.Printf(` Save as colors to use when the OS switches to:`)
  528. self.lp.Println()
  529. self.lp.Printf(` %sark mode`, ac("D"))
  530. self.lp.Println()
  531. self.lp.Printf(` %sight mode`, ac("L"))
  532. self.lp.Println()
  533. self.lp.Printf(` %so preference mode`, ac("N"))
  534. self.lp.Println()
  535. self.lp.Println()
  536. self.lp.Printf(` %sbort and return to list of themes`, ac("A"))
  537. self.lp.Println()
  538. self.lp.Println()
  539. self.lp.Printf(` %suit`, ac("Q"))
  540. self.lp.Println()
  541. }
  542. // }}}
  543. // searching {{{
  544. func (self *handler) update_search() {
  545. text := self.rl.AllText()
  546. if self.themes_list.UpdateSearch(text) {
  547. self.set_colors_to_current_theme()
  548. self.draw_screen()
  549. } else {
  550. self.draw_search_bar()
  551. }
  552. }
  553. func (self *handler) on_text(text string, a, b bool) error {
  554. if self.state == SEARCHING {
  555. err := self.rl.OnText(text, a, b)
  556. if err != nil {
  557. return err
  558. }
  559. self.update_search()
  560. }
  561. return nil
  562. }
  563. func (self *handler) on_searching_key_event(ev *loop.KeyEvent) error {
  564. if ev.MatchesPressOrRepeat("enter") {
  565. ev.Handled = true
  566. self.state = BROWSING
  567. self.draw_bottom_bar()
  568. return nil
  569. }
  570. if ev.MatchesPressOrRepeat("esc") {
  571. ev.Handled = true
  572. self.state = BROWSING
  573. self.themes_list.UpdateSearch("")
  574. self.set_colors_to_current_theme()
  575. self.draw_screen()
  576. return nil
  577. }
  578. err := self.rl.OnKeyEvent(ev)
  579. if err != nil {
  580. return err
  581. }
  582. if ev.Handled {
  583. self.update_search()
  584. }
  585. return nil
  586. }
  587. // }}}