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 for overlay pickers. // First ESC enters browse mode (j/k navigate, i returns to input). // Second ESC calls closeFn to close the picker. // Returns (command, handled). If handled is false, the caller should // fall through to the normal picker event handling. // The optional extra handlers are checked before the default swallow-all, // allowing pickers to add custom browse-mode keys (e.g. favorite toggle). 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 { 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] } }