ソースを参照

refactor(ui/chat): deduplicate picker helpers, shared atomic save, cleanup

- Extract pickerShortHelp/pickerFullHelp into util.go, used by all 3 pickers
- Add browseKeyHandler callback to pickerBrowseHandleKey for custom keys
- Emoji picker uses shared browse handler + extra callback for f-key
- Remove redundant cursor tracking from emoji picker (use selectItem trick)
- Extract atomicSaveJSON into util.go, used by guildstate + emoji_favorites
- Remove unused isFavorite method from emoji_favorites
- Remove dead selectedItemIndex method from emoji_picker

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
claude 1 ヶ月 前
コミット
2c5040ef6e

+ 2 - 7
internal/ui/chat/channels_picker.go

@@ -118,14 +118,9 @@ func (cp *channelsPicker) channelItem(guild *discord.Guild, channel discord.Chan
 }
 
 func (cp *channelsPicker) ShortHelp() []keybind.Keybind {
-	cfg := cp.chatView.cfg.Keybinds.Picker
-	return []keybind.Keybind{cfg.Up.Keybind, cfg.Down.Keybind, cfg.Select.Keybind, cfg.Cancel.Keybind}
+	return pickerShortHelp(cp.chatView.cfg.Keybinds.Picker)
 }
 
 func (cp *channelsPicker) FullHelp() [][]keybind.Keybind {
-	cfg := cp.chatView.cfg.Keybinds.Picker
-	return [][]keybind.Keybind{
-		{cfg.Up.Keybind, cfg.Down.Keybind, cfg.Top.Keybind, cfg.Bottom.Keybind},
-		{cfg.Select.Keybind, cfg.Cancel.Keybind},
-	}
+	return pickerFullHelp(cp.chatView.cfg.Keybinds.Picker)
 }

+ 1 - 24
internal/ui/chat/emoji_favorites.go

@@ -33,32 +33,9 @@ func loadEmojiFavorites() *emojiFavorites {
 }
 
 func (ef *emojiFavorites) save() {
-	ef.mu.RLock()
-	data, err := json.Marshal(ef)
-	ef.mu.RUnlock()
-	if err != nil {
-		slog.Error("failed to marshal emoji favorites", "err", err)
-		return
-	}
-	tmpPath := emojiFavoritesPath + ".tmp"
-	if err := os.WriteFile(tmpPath, data, 0600); err != nil {
-		slog.Error("failed to write emoji favorites", "err", err)
-		return
-	}
-	if err := os.Rename(tmpPath, emojiFavoritesPath); err != nil {
-		slog.Error("failed to rename emoji favorites file", "err", err)
-	}
-}
-
-func (ef *emojiFavorites) isFavorite(emoji string) bool {
 	ef.mu.RLock()
 	defer ef.mu.RUnlock()
-	for _, e := range ef.Favorites {
-		if e == emoji {
-			return true
-		}
-	}
-	return false
+	atomicSaveJSON(emojiFavoritesPath, ef)
 }
 
 func (ef *emojiFavorites) toggle(emoji string) {

+ 26 - 58
internal/ui/chat/emoji_picker.go

@@ -21,14 +21,8 @@ type emojiPicker struct {
 	browseMode bool
 	favorites  *emojiFavorites
 
-	// items mirrors what was passed to SetItems, so we can look up by cursor.
-	items []picker.Item
-
 	targetMessageID discord.MessageID
 	targetChannelID discord.ChannelID
-
-	// cursor tracks the browse-mode position (0-based into items).
-	cursor int
 }
 
 var _ help.KeyMap = (*emojiPicker)(nil)
@@ -44,7 +38,7 @@ func newEmojiPicker(cfg *config.Config, chatView *Model) *emojiPicker {
 }
 
 // resetBrowse starts the emoji picker in browse (scroll) mode by default.
-func (ep *emojiPicker) resetBrowse() { ep.browseMode = true; ep.cursor = 0 }
+func (ep *emojiPicker) resetBrowse() { ep.browseMode = true }
 
 var commonEmoji = []struct {
 	emoji string
@@ -156,13 +150,7 @@ func (ep *emojiPicker) update() {
 		}
 	}
 
-	ep.items = items
 	ep.Model.SetItems(items)
-
-	// Clamp cursor.
-	if ep.cursor >= len(items) {
-		ep.cursor = max(len(items)-1, 0)
-	}
 }
 
 func emojiItemKey(item picker.Item) string {
@@ -184,12 +172,12 @@ func indexOf(s string, c byte) int {
 func (ep *emojiPicker) HandleEvent(event tview.Event) tview.Command {
 	switch event := event.(type) {
 	case *tview.KeyEvent:
-		if ep.browseMode {
-			return ep.handleBrowseKey(event)
-		}
-		if event.Key() == tcell.KeyEsc {
-			ep.browseMode = true
-			return nil
+		if cmd, handled := pickerBrowseHandleKey(
+			event, &ep.browseMode, ep.Model,
+			func() { ep.chatView.closeEmojiPicker() },
+			ep.handleFavoriteKey,
+		); handled {
+			return cmd
 		}
 	case *picker.SelectedEvent:
 		apiEmoji, ok := event.Reference.(discord.APIEmoji)
@@ -214,51 +202,31 @@ func (ep *emojiPicker) HandleEvent(event tview.Event) tview.Command {
 	return ep.Model.HandleEvent(event)
 }
 
-func (ep *emojiPicker) handleBrowseKey(event *tview.KeyEvent) tview.Command {
-	switch {
-	case event.Key() == tcell.KeyEsc:
-		ep.browseMode = false
-		ep.chatView.closeEmojiPicker()
-		return nil
-	case event.Key() == tcell.KeyRune && event.Str() == "i":
-		ep.browseMode = false
-		return nil
-	case event.Key() == tcell.KeyRune && event.Str() == "j":
-		ep.cursor = min(ep.cursor+1, max(len(ep.items)-1, 0))
-		return ep.Model.HandleEvent(tcell.NewEventKey(tcell.KeyCtrlN, "", tcell.ModCtrl))
-	case event.Key() == tcell.KeyRune && event.Str() == "k":
-		ep.cursor = max(ep.cursor-1, 0)
-		return ep.Model.HandleEvent(tcell.NewEventKey(tcell.KeyCtrlP, "", tcell.ModCtrl))
-	case event.Key() == tcell.KeyRune && event.Str() == "g":
-		ep.cursor = 0
-		return ep.Model.HandleEvent(tcell.NewEventKey(tcell.KeyHome, "", tcell.ModNone))
-	case event.Key() == tcell.KeyRune && event.Str() == "G":
-		ep.cursor = max(len(ep.items)-1, 0)
-		return ep.Model.HandleEvent(tcell.NewEventKey(tcell.KeyEnd, "", tcell.ModNone))
-	case event.Key() == tcell.KeyRune && event.Str() == "f":
-		if ep.cursor >= 0 && ep.cursor < len(ep.items) {
-			key := emojiItemKey(ep.items[ep.cursor])
-			if key != "" {
-				ep.favorites.toggle(key)
-				ep.update()
-			}
+func (ep *emojiPicker) handleFavoriteKey(event *tview.KeyEvent) (tview.Command, bool) {
+	if event.Key() != tcell.KeyRune || event.Str() != "f" {
+		return nil, false
+	}
+
+	// Trigger a select to get the highlighted item's reference without
+	// dispatching the event through the normal loop.
+	cmd := ep.Model.HandleEvent(tcell.NewEventKey(tcell.KeyEnter, "", tcell.ModNone))
+	if cmd == nil {
+		return nil, true
+	}
+	if sel, ok := cmd().(*picker.SelectedEvent); ok {
+		key := emojiItemKey(sel.Item)
+		if key != "" {
+			ep.favorites.toggle(key)
+			ep.update()
 		}
-		return nil
-	case event.Key() == tcell.KeyEnter:
-		return ep.Model.HandleEvent(event)
 	}
-	return nil
+	return nil, true
 }
 
 func (ep *emojiPicker) ShortHelp() []keybind.Keybind {
-	cfg := ep.chatView.cfg.Keybinds.Picker
-	return []keybind.Keybind{cfg.Up.Keybind, cfg.Down.Keybind, cfg.Select.Keybind, cfg.Cancel.Keybind}
+	return pickerShortHelp(ep.chatView.cfg.Keybinds.Picker)
 }
 
 func (ep *emojiPicker) FullHelp() [][]keybind.Keybind {
-	cfg := ep.chatView.cfg.Keybinds.Picker
-	return [][]keybind.Keybind{
-		{cfg.Up.Keybind, cfg.Down.Keybind, cfg.Top.Keybind, cfg.Bottom.Keybind},
-		{cfg.Select.Keybind, cfg.Cancel.Keybind},
-	}
+	return pickerFullHelp(ep.chatView.cfg.Keybinds.Picker)
 }

+ 1 - 13
internal/ui/chat/guildstate.go

@@ -47,19 +47,7 @@ func loadGuildState() *guildState {
 func (gs *guildState) save() {
 	gs.mu.Lock()
 	defer gs.mu.Unlock()
-	data, err := json.Marshal(gs)
-	if err != nil {
-		slog.Error("failed to marshal guild state", "err", err)
-		return
-	}
-	tmpPath := stateFilePath + ".tmp"
-	if err := os.WriteFile(tmpPath, data, 0600); err != nil {
-		slog.Error("failed to write guild state", "err", err)
-		return
-	}
-	if err := os.Rename(tmpPath, stateFilePath); err != nil {
-		slog.Error("failed to rename guild state file", "err", err)
-	}
+	atomicSaveJSON(stateFilePath, gs)
 }
 
 func (gs *guildState) setExpanded(id discord.GuildID, expanded bool) {

+ 2 - 7
internal/ui/chat/search_picker.go

@@ -68,14 +68,9 @@ func (sp *searchPicker) update() {
 }
 
 func (sp *searchPicker) ShortHelp() []keybind.Keybind {
-	cfg := sp.chatView.cfg.Keybinds.Picker
-	return []keybind.Keybind{cfg.Up.Keybind, cfg.Down.Keybind, cfg.Select.Keybind, cfg.Cancel.Keybind}
+	return pickerShortHelp(sp.chatView.cfg.Keybinds.Picker)
 }
 
 func (sp *searchPicker) FullHelp() [][]keybind.Keybind {
-	cfg := sp.chatView.cfg.Keybinds.Picker
-	return [][]keybind.Keybind{
-		{cfg.Up.Keybind, cfg.Down.Keybind, cfg.Top.Keybind, cfg.Bottom.Keybind},
-		{cfg.Select.Keybind, cfg.Cancel.Keybind},
-	}
+	return pickerFullHelp(sp.chatView.cfg.Keybinds.Picker)
 }

+ 50 - 1
internal/ui/chat/util.go

@@ -1,11 +1,15 @@
 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"
@@ -42,12 +46,18 @@ func ConfigurePicker(model *picker.Model, cfg *config.Config, title string) {
 	})
 }
 
+// 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.
-func pickerBrowseHandleKey(event *tview.KeyEvent, browseMode *bool, model *picker.Model, closeFn func()) (tview.Command, bool) {
+// 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
@@ -76,10 +86,49 @@ func pickerBrowseHandleKey(event *tview.KeyEvent, browseMode *bool, model *picke
 	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 {