| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160 |
- package chat
- import (
- "encoding/json"
- "log/slog"
- "os"
- "strings"
- "github.com/ayn2op/discordo/internal/config"
- "github.com/ayn2op/discordo/internal/ui"
- "github.com/ayn2op/tview"
- "github.com/ayn2op/tview/keybind"
- "github.com/ayn2op/tview/list"
- "github.com/ayn2op/tview/picker"
- "github.com/gdamore/tcell/v3"
- )
- func ConfigurePicker(model *picker.Model, cfg *config.Config, title string) {
- model.Box = ui.ConfigureBox(tview.NewBox(), &cfg.Theme)
- // When a child of the parent flex is focused, the parent layout itself is not reported as focused.
- // Instead, the focused child (picker) is considered focused.
- // Therefore, we manually set the active border style on the picker to ensure it displays the correct focused appearance.
- model.
- SetBlurFunc(nil).
- SetFocusFunc(nil).
- SetBorderSet(cfg.Theme.Border.ActiveSet.BorderSet).
- SetBorderStyle(cfg.Theme.Border.ActiveStyle.Style).
- SetTitleStyle(cfg.Theme.Title.ActiveStyle.Style).
- SetFooterStyle(cfg.Theme.Footer.ActiveStyle.Style)
- model.SetTitle(title)
- model.SetScrollBarVisibility(cfg.Theme.ScrollBar.Visibility.ScrollBarVisibility)
- model.SetScrollBar(tview.NewScrollBar().
- SetTrackStyle(cfg.Theme.ScrollBar.TrackStyle.Style).
- SetThumbStyle(cfg.Theme.ScrollBar.ThumbStyle.Style).
- SetGlyphSet(cfg.Theme.ScrollBar.GlyphSet.GlyphSet))
- model.SetKeybinds(picker.Keybinds{
- Cancel: cfg.Keybinds.Picker.Cancel.Keybind,
- Keybinds: list.Keybinds{
- SelectUp: cfg.Keybinds.Picker.Up.Keybind,
- SelectDown: cfg.Keybinds.Picker.Down.Keybind,
- SelectTop: cfg.Keybinds.Picker.Top.Keybind,
- SelectBottom: cfg.Keybinds.Picker.Bottom.Keybind,
- },
- Select: cfg.Keybinds.Picker.Select.Keybind,
- })
- }
- // browseKeyHandler is an optional callback for picker-specific keys in browse
- // mode. Return (cmd, true) if handled, (nil, false) to fall through.
- type browseKeyHandler func(event *tview.KeyEvent) (tview.Command, bool)
- // pickerBrowseHandleKey implements a two-phase ESC interaction model for
- // overlay pickers (emoji, search, channels, attachments).
- //
- // When browseMode is false, keys pass through to the picker's input field for
- // filtering/searching. The first ESC press sets browseMode to true instead of
- // closing the picker.
- //
- // When browseMode is true, keys are intercepted before reaching the input field:
- //
- // j / k — select next / previous item
- // g / G — jump to top / bottom of list
- // i — return to input mode (browseMode = false)
- // Enter — confirm the selected item
- // ESC — close the picker via closeFn
- //
- // The optional extra handlers are checked before the default catch-all,
- // allowing pickers to add custom browse-mode keys (e.g. emoji picker's 'f'
- // for favorite toggle). All other keys are swallowed in browse mode to prevent
- // them from reaching the input field or triggering global keybinds.
- //
- // Returns (command, handled). If handled is false, the caller should fall
- // through to the normal picker event handling.
- func pickerBrowseHandleKey(event *tview.KeyEvent, browseMode *bool, model *picker.Model, closeFn func(), extra ...browseKeyHandler) (tview.Command, bool) {
- if !*browseMode {
- if event.Key() == tcell.KeyEsc {
- *browseMode = true
- return nil, true
- }
- return nil, false
- }
- // Browse mode: intercept keys before they reach the input field.
- switch {
- case event.Key() == tcell.KeyEsc:
- *browseMode = false
- closeFn()
- return nil, true
- case event.Key() == tcell.KeyRune && event.Str() == "i":
- *browseMode = false
- return nil, true
- case event.Key() == tcell.KeyRune && event.Str() == "j":
- return model.HandleEvent(tcell.NewEventKey(tcell.KeyCtrlN, "", tcell.ModCtrl)), true
- case event.Key() == tcell.KeyRune && event.Str() == "k":
- return model.HandleEvent(tcell.NewEventKey(tcell.KeyCtrlP, "", tcell.ModCtrl)), true
- case event.Key() == tcell.KeyRune && event.Str() == "g":
- return model.HandleEvent(tcell.NewEventKey(tcell.KeyHome, "", tcell.ModNone)), true
- case event.Key() == tcell.KeyRune && event.Str() == "G":
- return model.HandleEvent(tcell.NewEventKey(tcell.KeyEnd, "", tcell.ModNone)), true
- case event.Key() == tcell.KeyEnter:
- return model.HandleEvent(event), true
- }
- // Check extra handlers before swallowing.
- for _, handler := range extra {
- if cmd, handled := handler(event); handled {
- return cmd, true
- }
- }
- // Swallow other keys in browse mode so they don't reach the input.
- return nil, true
- }
- // pickerShortHelp returns the standard short help for any picker overlay.
- func pickerShortHelp(cfg config.PickerKeybinds) []keybind.Keybind {
- return []keybind.Keybind{cfg.Up.Keybind, cfg.Down.Keybind, cfg.Select.Keybind, cfg.Cancel.Keybind}
- }
- // pickerFullHelp returns the standard full help for any picker overlay.
- func pickerFullHelp(cfg config.PickerKeybinds) [][]keybind.Keybind {
- return [][]keybind.Keybind{
- {cfg.Up.Keybind, cfg.Down.Keybind, cfg.Top.Keybind, cfg.Bottom.Keybind},
- {cfg.Select.Keybind, cfg.Cancel.Keybind},
- }
- }
- // atomicSaveJSON marshals v as JSON and atomically writes it to path
- // via a tmp+rename pattern with 0600 permissions.
- func atomicSaveJSON(path string, v any) {
- data, err := json.Marshal(v)
- if err != nil {
- slog.Error("failed to marshal JSON", "path", path, "err", err)
- return
- }
- tmpPath := path + ".tmp"
- if err := os.WriteFile(tmpPath, data, 0600); err != nil {
- slog.Error("failed to write JSON", "path", path, "err", err)
- return
- }
- if err := os.Rename(tmpPath, path); err != nil {
- os.Remove(tmpPath)
- slog.Error("failed to rename JSON file", "path", path, "err", err)
- }
- }
- func humanJoin(items []string) string {
- count := len(items)
- switch count {
- case 0:
- return ""
- case 1:
- return items[0]
- case 2:
- return items[0] + " and " + items[1]
- default:
- return strings.Join(items[:count-1], ", ") + ", and " + items[count-1]
- }
- }
|