ui.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619
  1. // License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
  2. package themes
  3. import (
  4. "fmt"
  5. "io"
  6. "maps"
  7. "path/filepath"
  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. cp := config.ConfigParser{LineHandler: handle_line}
  149. cp.ParseFiles(filepath.Join(utils.ConfigDir(), "kitty.conf"))
  150. return settings
  151. }
  152. func (self *handler) set_colors_to_current_theme() bool {
  153. if self.themes_list == nil && self.colors_set_once {
  154. return false
  155. }
  156. self.colors_set_once = true
  157. if self.themes_list != nil {
  158. t := self.themes_list.CurrentTheme()
  159. if t != nil {
  160. raw, err := t.AsEscapeCodes()
  161. if err == nil {
  162. self.lp.QueueWriteString(raw)
  163. return true
  164. }
  165. }
  166. }
  167. self.lp.QueueWriteString(themes.ColorSettingsAsEscapeCodes(ReadKittyColorSettings()))
  168. return true
  169. }
  170. func (self *handler) redraw_after_category_change() {
  171. self.themes_list.UpdateThemes(self.all_themes.Filtered(self.category_filters[self.current_category()]))
  172. self.set_colors_to_current_theme()
  173. self.draw_screen()
  174. }
  175. func (self *handler) on_key_event(ev *loop.KeyEvent) error {
  176. switch self.state {
  177. case FETCHING:
  178. return self.on_fetching_key_event(ev)
  179. case BROWSING:
  180. return self.on_browsing_key_event(ev)
  181. case SEARCHING:
  182. return self.on_searching_key_event(ev)
  183. case ACCEPTING:
  184. return self.on_accepting_key_event(ev)
  185. }
  186. return nil
  187. }
  188. // browsing ... {{{
  189. func (self *handler) next_category(delta int) {
  190. idx := slices.Index(self.tabs, self.current_category()) + delta + len(self.tabs)
  191. self.set_current_category(self.tabs[idx%len(self.tabs)])
  192. self.redraw_after_category_change()
  193. }
  194. func (self *handler) next(delta int, allow_wrapping bool) {
  195. if self.themes_list.Next(delta, allow_wrapping) {
  196. self.set_colors_to_current_theme()
  197. self.draw_screen()
  198. } else {
  199. self.lp.Beep()
  200. }
  201. }
  202. func (self *handler) on_browsing_key_event(ev *loop.KeyEvent) error {
  203. if ev.MatchesPressOrRepeat("esc") || ev.MatchesPressOrRepeat("q") {
  204. self.lp.Quit(0)
  205. ev.Handled = true
  206. return nil
  207. }
  208. for _, cat := range self.tabs {
  209. if ev.MatchesPressOrRepeat(cat[0:1]) || ev.MatchesPressOrRepeat("alt+"+cat[0:1]) {
  210. ev.Handled = true
  211. if cat != self.current_category() {
  212. self.set_current_category(cat)
  213. self.redraw_after_category_change()
  214. return nil
  215. }
  216. }
  217. }
  218. if ev.MatchesPressOrRepeat("left") || ev.MatchesPressOrRepeat("shift+tab") {
  219. self.next_category(-1)
  220. ev.Handled = true
  221. return nil
  222. }
  223. if ev.MatchesPressOrRepeat("right") || ev.MatchesPressOrRepeat("tab") {
  224. self.next_category(1)
  225. ev.Handled = true
  226. return nil
  227. }
  228. if ev.MatchesPressOrRepeat("j") || ev.MatchesPressOrRepeat("down") {
  229. self.next(1, true)
  230. ev.Handled = true
  231. return nil
  232. }
  233. if ev.MatchesPressOrRepeat("k") || ev.MatchesPressOrRepeat("up") {
  234. self.next(-1, true)
  235. ev.Handled = true
  236. return nil
  237. }
  238. if ev.MatchesPressOrRepeat("page_down") {
  239. ev.Handled = true
  240. sz, err := self.lp.ScreenSize()
  241. if err == nil {
  242. self.next(int(sz.HeightCells)-3, false)
  243. }
  244. return nil
  245. }
  246. if ev.MatchesPressOrRepeat("page_up") {
  247. ev.Handled = true
  248. sz, err := self.lp.ScreenSize()
  249. if err == nil {
  250. self.next(3-int(sz.HeightCells), false)
  251. }
  252. return nil
  253. }
  254. if ev.MatchesPressOrRepeat("s") || ev.MatchesPressOrRepeat("/") {
  255. ev.Handled = true
  256. self.start_search()
  257. return nil
  258. }
  259. if ev.MatchesPressOrRepeat("c") || ev.MatchesPressOrRepeat("enter") {
  260. ev.Handled = true
  261. if self.themes_list == nil || self.themes_list.Len() == 0 {
  262. self.lp.Beep()
  263. } else {
  264. self.state = ACCEPTING
  265. self.draw_screen()
  266. }
  267. }
  268. return nil
  269. }
  270. func (self *handler) start_search() {
  271. self.state = SEARCHING
  272. self.rl.SetText(self.themes_list.current_search)
  273. self.draw_screen()
  274. }
  275. func (self *handler) draw_browsing_screen() {
  276. self.draw_tab_bar()
  277. sz, err := self.lp.ScreenSize()
  278. if err != nil {
  279. return
  280. }
  281. num_rows := int(sz.HeightCells) - 2
  282. mw := self.themes_list.max_width + 1
  283. green_fg, _, _ := strings.Cut(self.lp.SprintStyled("fg=green", "|"), "|")
  284. for _, l := range self.themes_list.Lines(num_rows) {
  285. line := l.text
  286. if l.is_current {
  287. line = strings.ReplaceAll(line, themes.MARK_AFTER, green_fg)
  288. self.lp.PrintStyled("fg=green", ">")
  289. self.lp.PrintStyled("fg=green bold", line)
  290. } else {
  291. self.lp.PrintStyled("fg=green", " ")
  292. self.lp.QueueWriteString(line)
  293. }
  294. self.lp.MoveCursorHorizontally(mw - l.width)
  295. self.lp.Println(SEPARATOR)
  296. num_rows--
  297. }
  298. for ; num_rows > 0; num_rows-- {
  299. self.lp.MoveCursorHorizontally(mw + 1)
  300. self.lp.Println(SEPARATOR)
  301. }
  302. if self.themes_list != nil && self.themes_list.Len() > 0 {
  303. self.draw_theme_demo()
  304. }
  305. if self.state == BROWSING {
  306. self.draw_bottom_bar()
  307. } else {
  308. self.draw_search_bar()
  309. }
  310. }
  311. func (self *handler) draw_bottom_bar() {
  312. sz, err := self.lp.ScreenSize()
  313. if err != nil {
  314. return
  315. }
  316. self.lp.MoveCursorTo(1, int(sz.HeightCells))
  317. self.lp.PrintStyled("reverse", strings.Repeat(" ", int(sz.WidthCells)))
  318. self.lp.QueueWriteString("\r")
  319. draw_tab := func(t, sc string) {
  320. text := self.mark_shortcut(utils.Capitalize(t), sc)
  321. self.lp.PrintStyled("reverse", " "+text+" ")
  322. }
  323. draw_tab("search (/)", "s")
  324. draw_tab("accept (⏎)", "c")
  325. self.lp.QueueWriteString("\x1b[m")
  326. }
  327. func (self *handler) draw_search_bar() {
  328. sz, err := self.lp.ScreenSize()
  329. if err != nil {
  330. return
  331. }
  332. self.lp.MoveCursorTo(1, int(sz.HeightCells))
  333. self.lp.ClearToEndOfLine()
  334. self.rl.RedrawNonAtomic()
  335. }
  336. func (self *handler) mark_shortcut(text, acc string) string {
  337. acc_idx := strings.Index(strings.ToLower(text), strings.ToLower(acc))
  338. return text[:acc_idx] + self.lp.SprintStyled("underline bold", text[acc_idx:acc_idx+1]) + text[acc_idx+1:]
  339. }
  340. func (self *handler) draw_tab_bar() {
  341. sz, err := self.lp.ScreenSize()
  342. if err != nil {
  343. return
  344. }
  345. self.lp.PrintStyled("reverse", strings.Repeat(` `, int(sz.WidthCells)))
  346. self.lp.QueueWriteString("\r")
  347. cc := self.current_category()
  348. draw_tab := func(text, name, acc string) {
  349. is_active := name == cc
  350. if is_active {
  351. text := self.lp.SprintStyled("italic", fmt.Sprintf("%s #%d", text, self.themes_list.Len()))
  352. self.lp.Printf(" %s ", text)
  353. } else {
  354. text = self.mark_shortcut(text, acc)
  355. self.lp.PrintStyled("reverse", " "+text+" ")
  356. }
  357. }
  358. for _, title := range self.tabs {
  359. draw_tab(utils.Capitalize(title), title, string([]rune(title)[0]))
  360. }
  361. self.lp.Println("\x1b[m")
  362. }
  363. func center_string(x string, width int) string {
  364. l := wcswidth.Stringwidth(x)
  365. spaces := int(float64(width-l) / 2)
  366. return strings.Repeat(" ", utils.Max(0, spaces)) + x + strings.Repeat(" ", utils.Max(0, width-(spaces+l)))
  367. }
  368. func (self *handler) draw_theme_demo() {
  369. ssz, err := self.lp.ScreenSize()
  370. if err != nil {
  371. return
  372. }
  373. theme := self.themes_list.CurrentTheme()
  374. if theme == nil {
  375. return
  376. }
  377. xstart := self.themes_list.max_width + 3
  378. sz := int(ssz.WidthCells) - xstart
  379. if sz < 20 {
  380. return
  381. }
  382. sz--
  383. y := 0
  384. colors := strings.Split(`black red green yellow blue magenta cyan white`, ` `)
  385. trunc := sz/8 - 1
  386. pat := regexp.MustCompile(`\s+`)
  387. next_line := func() {
  388. self.lp.QueueWriteString("\r")
  389. y++
  390. self.lp.MoveCursorTo(xstart, y+1)
  391. self.lp.QueueWriteString(SEPARATOR + " ")
  392. }
  393. write_para := func(text string) {
  394. text = pat.ReplaceAllLiteralString(text, " ")
  395. for text != "" {
  396. t, sp := wcswidth.TruncateToVisualLengthWithWidth(text, sz)
  397. self.lp.QueueWriteString(t)
  398. next_line()
  399. text = text[sp:]
  400. }
  401. }
  402. write_colors := func(bg string) {
  403. for _, intense := range []bool{false, true} {
  404. buf := strings.Builder{}
  405. buf.Grow(1024)
  406. for _, c := range colors {
  407. s := c
  408. if intense {
  409. s = "bright-" + s
  410. }
  411. sTrunc := s
  412. if len(sTrunc) > trunc {
  413. sTrunc = sTrunc[:trunc]
  414. }
  415. buf.WriteString(self.lp.SprintStyled("fg="+s, sTrunc))
  416. buf.WriteString(" ")
  417. }
  418. text := strings.TrimSpace(buf.String())
  419. if bg == "" {
  420. self.lp.QueueWriteString(text)
  421. } else {
  422. s := bg
  423. if intense {
  424. s = "bright-" + s
  425. }
  426. self.lp.PrintStyled("bg="+s, text)
  427. }
  428. next_line()
  429. }
  430. next_line()
  431. }
  432. self.lp.MoveCursorTo(1, 1)
  433. next_line()
  434. self.lp.PrintStyled("fg=green bold", center_string(theme.Name(), sz))
  435. next_line()
  436. if theme.Author() != "" {
  437. self.lp.PrintStyled("italic", center_string(theme.Author(), sz))
  438. next_line()
  439. }
  440. if theme.Blurb() != "" {
  441. next_line()
  442. write_para(theme.Blurb())
  443. next_line()
  444. }
  445. write_colors("")
  446. for _, bg := range colors {
  447. write_colors(bg)
  448. }
  449. }
  450. // }}}
  451. // accepting {{{
  452. func (self *handler) on_accepting_key_event(ev *loop.KeyEvent) error {
  453. if ev.MatchesPressOrRepeat("q") || ev.MatchesPressOrRepeat("esc") || ev.MatchesPressOrRepeat("shift+q") {
  454. ev.Handled = true
  455. self.lp.Quit(0)
  456. return nil
  457. }
  458. if ev.MatchesPressOrRepeat("a") || ev.MatchesPressOrRepeat("shift+a") {
  459. ev.Handled = true
  460. self.state = BROWSING
  461. self.draw_screen()
  462. return nil
  463. }
  464. if ev.MatchesPressOrRepeat("p") || ev.MatchesPressOrRepeat("shift+p") {
  465. ev.Handled = true
  466. self.themes_list.CurrentTheme().SaveInDir(utils.ConfigDir())
  467. self.update_recent()
  468. self.lp.Quit(0)
  469. return nil
  470. }
  471. if ev.MatchesPressOrRepeat("m") || ev.MatchesPressOrRepeat("shift+m") {
  472. ev.Handled = true
  473. self.themes_list.CurrentTheme().SaveInConf(utils.ConfigDir(), self.opts.ReloadIn, self.opts.ConfigFileName)
  474. self.update_recent()
  475. self.lp.Quit(0)
  476. return nil
  477. }
  478. return nil
  479. }
  480. func (self *handler) update_recent() {
  481. if self.themes_list != nil {
  482. recent := slices.Clone(self.cached_data.Recent)
  483. name := self.themes_list.CurrentTheme().Name()
  484. recent = utils.Remove(recent, name)
  485. recent = append([]string{name}, recent...)
  486. if len(recent) > 20 {
  487. recent = recent[:20]
  488. }
  489. self.cached_data.Recent = recent
  490. }
  491. }
  492. func (self *handler) draw_accepting_screen() {
  493. name := self.themes_list.CurrentTheme().Name()
  494. name = self.lp.SprintStyled("fg=green bold", name)
  495. kc := self.lp.SprintStyled("italic", self.opts.ConfigFileName)
  496. ac := func(x string) string {
  497. return self.lp.SprintStyled("fg=red", x)
  498. }
  499. self.lp.AllowLineWrapping(true)
  500. defer self.lp.AllowLineWrapping(false)
  501. self.lp.Printf(`You have chosen the %s theme`, name)
  502. self.lp.Println()
  503. self.lp.Println()
  504. self.lp.Println(`What would you like to do?`)
  505. self.lp.Println()
  506. self.lp.Printf(` %sodify %s to load %s`, ac("M"), kc, name)
  507. self.lp.Println()
  508. self.lp.Println()
  509. self.lp.Printf(` %slace the theme file in %s but do not modify %s`, ac("P"), utils.ConfigDir(), kc)
  510. self.lp.Println()
  511. self.lp.Println()
  512. self.lp.Printf(` %sbort and return to list of themes`, ac("A"))
  513. self.lp.Println()
  514. self.lp.Println()
  515. self.lp.Printf(` %suit`, ac("Q"))
  516. self.lp.Println()
  517. }
  518. // }}}
  519. // searching {{{
  520. func (self *handler) update_search() {
  521. text := self.rl.AllText()
  522. if self.themes_list.UpdateSearch(text) {
  523. self.set_colors_to_current_theme()
  524. self.draw_screen()
  525. } else {
  526. self.draw_search_bar()
  527. }
  528. }
  529. func (self *handler) on_text(text string, a, b bool) error {
  530. if self.state == SEARCHING {
  531. err := self.rl.OnText(text, a, b)
  532. if err != nil {
  533. return err
  534. }
  535. self.update_search()
  536. }
  537. return nil
  538. }
  539. func (self *handler) on_searching_key_event(ev *loop.KeyEvent) error {
  540. if ev.MatchesPressOrRepeat("enter") {
  541. ev.Handled = true
  542. self.state = BROWSING
  543. self.draw_bottom_bar()
  544. return nil
  545. }
  546. if ev.MatchesPressOrRepeat("esc") {
  547. ev.Handled = true
  548. self.state = BROWSING
  549. self.themes_list.UpdateSearch("")
  550. self.set_colors_to_current_theme()
  551. self.draw_screen()
  552. return nil
  553. }
  554. err := self.rl.OnKeyEvent(ev)
  555. if err != nil {
  556. return err
  557. }
  558. if ev.Handled {
  559. self.update_search()
  560. }
  561. return nil
  562. }
  563. // }}}