util.go 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. package chat
  2. import (
  3. "encoding/json"
  4. "log/slog"
  5. "os"
  6. "strings"
  7. "github.com/ayn2op/discordo/internal/config"
  8. "github.com/ayn2op/discordo/internal/ui"
  9. "github.com/ayn2op/tview"
  10. "github.com/ayn2op/tview/keybind"
  11. "github.com/ayn2op/tview/list"
  12. "github.com/ayn2op/tview/picker"
  13. "github.com/gdamore/tcell/v3"
  14. )
  15. func ConfigurePicker(model *picker.Model, cfg *config.Config, title string) {
  16. model.Box = ui.ConfigureBox(tview.NewBox(), &cfg.Theme)
  17. // When a child of the parent flex is focused, the parent layout itself is not reported as focused.
  18. // Instead, the focused child (picker) is considered focused.
  19. // Therefore, we manually set the active border style on the picker to ensure it displays the correct focused appearance.
  20. model.
  21. SetBlurFunc(nil).
  22. SetFocusFunc(nil).
  23. SetBorderSet(cfg.Theme.Border.ActiveSet.BorderSet).
  24. SetBorderStyle(cfg.Theme.Border.ActiveStyle.Style).
  25. SetTitleStyle(cfg.Theme.Title.ActiveStyle.Style).
  26. SetFooterStyle(cfg.Theme.Footer.ActiveStyle.Style)
  27. model.SetTitle(title)
  28. model.SetScrollBarVisibility(cfg.Theme.ScrollBar.Visibility.ScrollBarVisibility)
  29. model.SetScrollBar(tview.NewScrollBar().
  30. SetTrackStyle(cfg.Theme.ScrollBar.TrackStyle.Style).
  31. SetThumbStyle(cfg.Theme.ScrollBar.ThumbStyle.Style).
  32. SetGlyphSet(cfg.Theme.ScrollBar.GlyphSet.GlyphSet))
  33. model.SetKeybinds(picker.Keybinds{
  34. Cancel: cfg.Keybinds.Picker.Cancel.Keybind,
  35. Keybinds: list.Keybinds{
  36. SelectUp: cfg.Keybinds.Picker.Up.Keybind,
  37. SelectDown: cfg.Keybinds.Picker.Down.Keybind,
  38. SelectTop: cfg.Keybinds.Picker.Top.Keybind,
  39. SelectBottom: cfg.Keybinds.Picker.Bottom.Keybind,
  40. },
  41. Select: cfg.Keybinds.Picker.Select.Keybind,
  42. })
  43. }
  44. // browseKeyHandler is an optional callback for picker-specific keys in browse
  45. // mode. Return (cmd, true) if handled, (nil, false) to fall through.
  46. type browseKeyHandler func(event *tview.KeyEvent) (tview.Command, bool)
  47. // pickerBrowseHandleKey implements a two-phase ESC interaction model for
  48. // overlay pickers (emoji, search, channels, attachments).
  49. //
  50. // When browseMode is false, keys pass through to the picker's input field for
  51. // filtering/searching. The first ESC press sets browseMode to true instead of
  52. // closing the picker.
  53. //
  54. // When browseMode is true, keys are intercepted before reaching the input field:
  55. //
  56. // j / k — select next / previous item
  57. // g / G — jump to top / bottom of list
  58. // i — return to input mode (browseMode = false)
  59. // Enter — confirm the selected item
  60. // ESC — close the picker via closeFn
  61. //
  62. // The optional extra handlers are checked before the default catch-all,
  63. // allowing pickers to add custom browse-mode keys (e.g. emoji picker's 'f'
  64. // for favorite toggle). All other keys are swallowed in browse mode to prevent
  65. // them from reaching the input field or triggering global keybinds.
  66. //
  67. // Returns (command, handled). If handled is false, the caller should fall
  68. // through to the normal picker event handling.
  69. func pickerBrowseHandleKey(event *tview.KeyEvent, browseMode *bool, model *picker.Model, closeFn func(), extra ...browseKeyHandler) (tview.Command, bool) {
  70. if !*browseMode {
  71. if event.Key() == tcell.KeyEsc {
  72. *browseMode = true
  73. return nil, true
  74. }
  75. return nil, false
  76. }
  77. // Browse mode: intercept keys before they reach the input field.
  78. switch {
  79. case event.Key() == tcell.KeyEsc:
  80. *browseMode = false
  81. closeFn()
  82. return nil, true
  83. case event.Key() == tcell.KeyRune && event.Str() == "i":
  84. *browseMode = false
  85. return nil, true
  86. case event.Key() == tcell.KeyRune && event.Str() == "j":
  87. return model.HandleEvent(tcell.NewEventKey(tcell.KeyCtrlN, "", tcell.ModCtrl)), true
  88. case event.Key() == tcell.KeyRune && event.Str() == "k":
  89. return model.HandleEvent(tcell.NewEventKey(tcell.KeyCtrlP, "", tcell.ModCtrl)), true
  90. case event.Key() == tcell.KeyRune && event.Str() == "g":
  91. return model.HandleEvent(tcell.NewEventKey(tcell.KeyHome, "", tcell.ModNone)), true
  92. case event.Key() == tcell.KeyRune && event.Str() == "G":
  93. return model.HandleEvent(tcell.NewEventKey(tcell.KeyEnd, "", tcell.ModNone)), true
  94. case event.Key() == tcell.KeyEnter:
  95. return model.HandleEvent(event), true
  96. }
  97. // Check extra handlers before swallowing.
  98. for _, handler := range extra {
  99. if cmd, handled := handler(event); handled {
  100. return cmd, true
  101. }
  102. }
  103. // Swallow other keys in browse mode so they don't reach the input.
  104. return nil, true
  105. }
  106. // pickerShortHelp returns the standard short help for any picker overlay.
  107. func pickerShortHelp(cfg config.PickerKeybinds) []keybind.Keybind {
  108. return []keybind.Keybind{cfg.Up.Keybind, cfg.Down.Keybind, cfg.Select.Keybind, cfg.Cancel.Keybind}
  109. }
  110. // pickerFullHelp returns the standard full help for any picker overlay.
  111. func pickerFullHelp(cfg config.PickerKeybinds) [][]keybind.Keybind {
  112. return [][]keybind.Keybind{
  113. {cfg.Up.Keybind, cfg.Down.Keybind, cfg.Top.Keybind, cfg.Bottom.Keybind},
  114. {cfg.Select.Keybind, cfg.Cancel.Keybind},
  115. }
  116. }
  117. // atomicSaveJSON marshals v as JSON and atomically writes it to path
  118. // via a tmp+rename pattern with 0600 permissions.
  119. func atomicSaveJSON(path string, v any) {
  120. data, err := json.Marshal(v)
  121. if err != nil {
  122. slog.Error("failed to marshal JSON", "path", path, "err", err)
  123. return
  124. }
  125. tmpPath := path + ".tmp"
  126. if err := os.WriteFile(tmpPath, data, 0600); err != nil {
  127. slog.Error("failed to write JSON", "path", path, "err", err)
  128. return
  129. }
  130. if err := os.Rename(tmpPath, path); err != nil {
  131. os.Remove(tmpPath)
  132. slog.Error("failed to rename JSON file", "path", path, "err", err)
  133. }
  134. }
  135. func humanJoin(items []string) string {
  136. count := len(items)
  137. switch count {
  138. case 0:
  139. return ""
  140. case 1:
  141. return items[0]
  142. case 2:
  143. return items[0] + " and " + items[1]
  144. default:
  145. return strings.Join(items[:count-1], ", ") + ", and " + items[count-1]
  146. }
  147. }