chore(dev): merge emoji autocomplete and command preview

This commit is contained in:
spearzificial 2026-10-19 13:56:04 +05:30
commit c27c4d903c
10 changed files with 2401 additions and 10 deletions

134
internal/emoji/emoji.go Normal file
View file

@ -0,0 +1,134 @@
package emoji
import (
"slices"
"strings"
"sync"
"github.com/ayn2op/arikawa/v3/discord"
"github.com/sahilm/fuzzy"
)
type Item struct {
Name string
InsertText string
Display string
Custom bool
}
type itemList []Item
func (l itemList) String(i int) string {
return l[i].Name
}
func (l itemList) Len() int {
return len(l)
}
var unicodeList = sync.OnceValue(func() []Item {
items := make([]Item, 0, len(Unicode))
for name, glyph := range Unicode {
items = append(items, Item{
Name: name,
InsertText: glyph,
Display: ":" + name + ": " + glyph,
Custom: false,
})
}
slices.SortFunc(items, func(a, b Item) int {
if a.Name < b.Name {
return -1
} else if a.Name > b.Name {
return 1
}
return 0
})
return items
})
// Search searches custom emojis and standard Unicode emojis matching the query.
func Search(query string, custom []discord.Emoji, limit int) []Item {
if limit <= 0 {
limit = 20
}
customItems := make([]Item, 0, len(custom))
for _, e := range custom {
customItems = append(customItems, Item{
Name: e.Name,
InsertText: e.String(),
Display: ":" + e.Name + ":",
Custom: true,
})
}
uItems := unicodeList()
if query == "" {
results := make([]Item, 0, limit)
results = append(results, customItems...)
if len(results) < limit {
needed := limit - len(results)
if needed > len(uItems) {
needed = len(uItems)
}
results = append(results, uItems[:needed]...)
}
if len(results) > limit {
return results[:limit]
}
return results
}
// Exact prefix matches on custom emojis get highest priority
var results []Item
lowerQuery := strings.ToLower(query)
for _, item := range customItems {
if strings.HasPrefix(strings.ToLower(item.Name), lowerQuery) {
results = append(results, item)
if len(results) >= limit {
return results
}
}
}
// Fuzzy search across remaining custom emojis
if len(customItems) > 0 {
customMatches := fuzzy.FindFrom(query, itemList(customItems))
for _, m := range customMatches {
item := customItems[m.Index]
if !slices.ContainsFunc(results, func(it Item) bool { return it.Name == item.Name && it.Custom }) {
results = append(results, item)
if len(results) >= limit {
return results
}
}
}
}
// Exact prefix matches on unicode emojis
for _, item := range uItems {
if strings.HasPrefix(strings.ToLower(item.Name), lowerQuery) {
results = append(results, item)
if len(results) >= limit {
return results
}
}
}
// Fuzzy search across unicode emojis
uMatches := fuzzy.FindFrom(query, itemList(uItems))
for _, m := range uMatches {
item := uItems[m.Index]
if !slices.ContainsFunc(results, func(it Item) bool { return it.Name == item.Name && !it.Custom }) {
results = append(results, item)
if len(results) >= limit {
return results
}
}
}
return results
}

View file

@ -0,0 +1,87 @@
package emoji
import (
"testing"
"github.com/ayn2op/arikawa/v3/discord"
)
func TestSearchUnicode(t *testing.T) {
results := Search("smile", nil, 5)
if len(results) == 0 {
t.Fatal("expected results for 'smile', got none")
}
found := false
for _, item := range results {
if item.Name == "smile" && item.InsertText == "😄" {
found = true
break
}
}
if !found {
t.Errorf("expected smile -> 😄 in results, got %+v", results)
}
}
func TestSearchCustomEmojis(t *testing.T) {
custom := []discord.Emoji{
{
ID: 123456789,
Name: "custom_cat",
},
{
ID: 987654321,
Name: "pepe_dance",
Animated: true,
},
}
results := Search("cat", custom, 5)
if len(results) == 0 {
t.Fatal("expected results for 'cat', got none")
}
found := false
for _, item := range results {
if item.Name == "custom_cat" {
found = true
if item.InsertText != "<:custom_cat:123456789>" {
t.Errorf("expected custom format, got %s", item.InsertText)
}
if !item.Custom {
t.Errorf("expected Custom=true")
}
break
}
}
if !found {
t.Errorf("expected custom_cat in results, got %+v", results)
}
// Test animated emoji
animResults := Search("dance", custom, 5)
if len(animResults) == 0 {
t.Fatal("expected results for 'dance', got none")
}
if animResults[0].InsertText != "<a:pepe_dance:987654321>" {
t.Errorf("expected animated format, got %s", animResults[0].InsertText)
}
}
func TestSearchEmptyQuery(t *testing.T) {
custom := []discord.Emoji{
{
ID: 111,
Name: "alpha",
},
}
results := Search("", custom, 10)
if len(results) == 0 {
t.Fatal("expected results for empty query, got none")
}
// Custom emojis should be listed first
if results[0].Name != "alpha" {
t.Errorf("expected custom emoji first for empty query, got %s", results[0].Name)
}
}

1860
internal/emoji/unicode.go Normal file

File diff suppressed because it is too large Load diff

View file

@ -27,6 +27,7 @@ import (
"github.com/ayn2op/discordo/internal/consts" "github.com/ayn2op/discordo/internal/consts"
"github.com/ayn2op/discordo/internal/ui" "github.com/ayn2op/discordo/internal/ui"
"github.com/ayn2op/discordo/internal/ui/chat/commandslist" "github.com/ayn2op/discordo/internal/ui/chat/commandslist"
"github.com/ayn2op/discordo/internal/ui/chat/emojislist"
"github.com/ayn2op/discordo/internal/ui/chat/mentionslist" "github.com/ayn2op/discordo/internal/ui/chat/mentionslist"
"github.com/ayn2op/ningen/v3" "github.com/ayn2op/ningen/v3"
"github.com/ayn2op/tview" "github.com/ayn2op/tview"
@ -74,6 +75,7 @@ type Model struct {
memberSearchCache map[string]uint memberSearchCache map[string]uint
mentionsList mentionslist.Model mentionsList mentionslist.Model
commandsList commandslist.Model commandsList commandslist.Model
emojisList emojislist.Model
botCommands []command.Item botCommands []command.Item
lastSearch time.Time lastSearch time.Time
@ -92,6 +94,7 @@ func NewModel(cfg *config.Config, state *ningen.State) Model {
memberSearchCache: make(map[string]uint), memberSearchCache: make(map[string]uint),
mentionsList: mentionslist.NewModel(cfg), mentionsList: mentionslist.NewModel(cfg),
commandsList: commandslist.NewModel(cfg), commandsList: commandslist.NewModel(cfg),
emojisList: emojislist.NewModel(cfg),
} }
return c return c
} }
@ -271,7 +274,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
m.pasteText() m.pasteText()
return m, pasteImage() return m, pasteImage()
case keybind.Matches(msg, kbs.Send.Keybind): case keybind.Matches(msg, kbs.Send.Keybind):
if m.mentionsListVisible() || m.commandsListVisible() { if m.mentionsListVisible() || m.emojisListVisible() || m.commandsListVisible() {
return m, m.tabComplete() return m, m.tabComplete()
} }
return m, m.send() return m, m.send()
@ -282,14 +285,14 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
m.stopTabCompletion() m.stopTabCompletion()
return m, m.pickFiles() return m, m.pickFiles()
case keybind.Matches(msg, kbs.Cancel.Keybind): case keybind.Matches(msg, kbs.Cancel.Keybind):
if m.mentionsListVisible() || m.commandsListVisible() { if m.mentionsListVisible() || m.emojisListVisible() || m.commandsListVisible() {
m.stopTabCompletion() m.stopTabCompletion()
return m, nil return m, nil
} }
m.reset() m.reset()
return m, nil return m, nil
case keybind.Matches(msg, kbs.TabComplete.Keybind): case keybind.Matches(msg, kbs.TabComplete.Keybind):
if m.mentionsListVisible() || m.commandsListVisible() { if m.mentionsListVisible() || m.emojisListVisible() || m.commandsListVisible() {
return m, m.tabComplete() return m, m.tabComplete()
} }
m.insert("\t") m.insert("\t")
@ -305,6 +308,10 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
var cmd tview.Cmd var cmd tview.Cmd
m.mentionsList, cmd = m.mentionsList.Update(msg) m.mentionsList, cmd = m.mentionsList.Update(msg)
return m, cmd return m, cmd
case emojislist.Msg:
var cmd tview.Cmd
m.emojisList, cmd = m.emojisList.Update(msg)
return m, cmd
case commandslist.Msg: case commandslist.Msg:
var cmd tview.Cmd var cmd tview.Cmd
m.commandsList, cmd = m.commandsList.Update(msg) m.commandsList, cmd = m.commandsList.Update(msg)
@ -323,7 +330,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
func (m Model) canEditLastMessage() bool { func (m Model) canEditLastMessage() bool {
return !m.Disabled() && m.editing == nil && m.text == "" && return !m.Disabled() && m.editing == nil && m.text == "" &&
m.sendMessageData.Reference == nil && len(m.sendMessageData.Files) == 0 && m.sendMessageData.Reference == nil && len(m.sendMessageData.Files) == 0 &&
!m.mentionsListVisible() && !m.commandsListVisible() !m.mentionsListVisible() && !m.emojisListVisible() && !m.commandsListVisible()
} }
// StartEdit puts message in the composer to be edited. // StartEdit puts message in the composer to be edited.
@ -437,6 +444,10 @@ func isMentionChar(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '.' return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '.'
} }
func isEmojiChar(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '+' || r == '-'
}
// suggestRecentAuthors suggests the authors of the channel's recent messages, other than the user. // suggestRecentAuthors suggests the authors of the channel's recent messages, other than the user.
func (m *Model) suggestRecentAuthors(channel *discord.Channel) { func (m *Model) suggestRecentAuthors(channel *discord.Channel) {
messages, err := m.state.Cabinet.Messages(channel.ID) messages, err := m.state.Cabinet.Messages(channel.ID)
@ -516,7 +527,7 @@ func (m Model) OnGuildMemberRemove(event *gateway.GuildMemberRemoveEvent) {
} }
} }
// AutocompleteView places the active completion list (mentions or commands) at the bottom of the area above the composer, near the cursor, or returns nil if none are active or the composer is not focused. // AutocompleteView places the active completion list (mentions, emojis, or commands) at the bottom of the area above the composer, near the cursor, or returns nil if none are active or the composer is not focused.
func (m Model) AutocompleteView(focused bool) tview.Widget { func (m Model) AutocompleteView(focused bool) tview.Widget {
if !focused { if !focused {
return nil return nil
@ -524,6 +535,9 @@ func (m Model) AutocompleteView(focused bool) tview.Widget {
if m.commandsListVisible() { if m.commandsListVisible() {
return commandsPopup{m} return commandsPopup{m}
} }
if m.emojisListVisible() {
return emojisPopup{m}
}
if m.mentionsListVisible() { if m.mentionsListVisible() {
return mentionsPopup{m} return mentionsPopup{m}
} }
@ -591,6 +605,33 @@ func (p commandsPopup) area(area tview.Rectangle) tview.Rectangle {
return tview.Rectangle{X: area.X + x, Y: area.Y + area.Height - size.Height, Width: size.Width, Height: size.Height} return tview.Rectangle{X: area.X + x, Y: area.Y + area.Height - size.Height, Width: size.Width, Height: size.Height}
} }
type emojisPopup struct {
c Model
}
func (emojisPopup) Size() (width, height layout.Length) { return layout.Fill, layout.Fill }
func (emojisPopup) Layout(limits layout.Limits) layout.Size {
return layout.Atomic(limits, layout.Fill, layout.Fill)
}
func (p emojisPopup) Draw(screen tview.Screen, area tview.Rectangle) {
p.c.emojisList.View().Draw(screen, p.area(area))
}
func (p emojisPopup) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg {
return p.c.emojisList.View().Handle(msg, p.area(area))
}
func (p emojisPopup) area(area tview.Rectangle) tview.Rectangle {
c := p.c
size := c.emojisList.View().Layout(layout.Limits{Max: layout.Size{Width: area.Width, Height: area.Height}})
value, cursor := c.text, c.cursor
col := uucode.StringWidth(value[strings.LastIndex(value[:cursor], "\n")+1 : cursor])
x := min(c.box(false).InnerArea(tview.Rectangle{}).X+col, area.Width-size.Width)
return tview.Rectangle{X: area.X + x, Y: area.Y + area.Height - size.Height, Width: size.Width, Height: size.Height}
}
func (m *Model) addMentionMember(gID discord.GuildID, member *discord.Member) bool { func (m *Model) addMentionMember(gID discord.GuildID, member *discord.Member) bool {
if member == nil { if member == nil {
return false return false
@ -649,6 +690,7 @@ func (m *Model) addMentionUser(user *discord.User) {
func (m *Model) stopTabCompletion() { func (m *Model) stopTabCompletion() {
if m.cfg.AutocompleteLimit > 0 { if m.cfg.AutocompleteLimit > 0 {
m.mentionsList.Clear() m.mentionsList.Clear()
m.emojisList.Clear()
m.commandsList.Clear() m.commandsList.Clear()
} }
} }
@ -662,6 +704,10 @@ func (m Model) commandsListVisible() bool {
return m.commandsList.ItemCount() != 0 return m.commandsList.ItemCount() != 0
} }
func (m Model) emojisListVisible() bool {
return m.emojisList.ItemCount() != 0
}
func (m Model) attach(name string, reader io.Reader) { func (m Model) attach(name string, reader io.Reader) {
m.sendMessageData.Files = append(m.sendMessageData.Files, sendpart.File{Name: name, Reader: reader}) m.sendMessageData.Files = append(m.sendMessageData.Files, sendpart.File{Name: name, Reader: reader})
} }

View file

@ -12,6 +12,7 @@ import (
"github.com/ayn2op/arikawa/v3/utils/sendpart" "github.com/ayn2op/arikawa/v3/utils/sendpart"
"github.com/ayn2op/discordo/internal/config" "github.com/ayn2op/discordo/internal/config"
"github.com/ayn2op/ningen/v3" "github.com/ayn2op/ningen/v3"
ningenEmoji "github.com/ayn2op/ningen/v3/states/emoji"
"github.com/ayn2op/tview" "github.com/ayn2op/tview"
"github.com/ayn2op/tview/keybind" "github.com/ayn2op/tview/keybind"
"github.com/gdamore/tcell/v3" "github.com/gdamore/tcell/v3"
@ -241,3 +242,63 @@ func TestModelCommandComplete(t *testing.T) {
t.Fatal("expected commands list hidden after completion") t.Fatal("expected commands list hidden after completion")
} }
} }
func TestModelEmojiSuggest(t *testing.T) {
c, _ := newTestModel(t)
channel := discord.Channel{ID: 1, Type: discord.DirectMessage}
c.SetChannel(&channel)
c.setText(":smi", 4)
c.tabSuggest()
if got := c.emojisList.ItemCount(); got == 0 {
t.Fatal("expected emoji suggestions for ':smi', got none")
}
if !c.emojisListVisible() {
t.Fatal("expected emojisListVisible to be true")
}
if c.mentionsListVisible() {
t.Fatal("expected mentionsListVisible to be false")
}
}
func TestModelEmojiComplete(t *testing.T) {
c, _ := newTestModel(t)
channel := discord.Channel{ID: 1, Type: discord.DirectMessage}
c.SetChannel(&channel)
c.setText("hello :smile", 12)
c.tabSuggest()
if !c.emojisListVisible() {
t.Fatal("expected emojis list visible")
}
c.tabComplete()
if got := c.text; got != "hello 😄 " {
t.Fatalf("text after completion = %q, want 'hello 😄 '", got)
}
if c.emojisListVisible() {
t.Fatal("expected emojis list to be hidden after completion")
}
}
func TestModelCustomEmojiComplete(t *testing.T) {
c, _ := newTestModel(t)
guildID := discord.GuildID(100)
channel := discord.Channel{ID: 10, GuildID: guildID, Type: discord.GuildText}
c.state.EmojiState = ningenEmoji.NewState(c.state.Cabinet)
_ = c.state.Cabinet.GuildSet(&discord.Guild{ID: guildID}, false)
_ = c.state.Cabinet.EmojiSet(guildID, []discord.Emoji{
{ID: 555, Name: "custom_pepe"},
}, false)
c.SetChannel(&channel)
c.setText(":custom_pepe", 12)
c.tabSuggest()
if !c.emojisListVisible() {
t.Fatal("expected emojis list visible")
}
c.tabComplete()
if got := c.text; got != "<:custom_pepe:555> " {
t.Fatalf("text after completion = %q, want '<:custom_pepe:555> '", got)
}
if c.emojisListVisible() {
t.Fatal("expected emojis list to be hidden after completion")
}
}

View file

@ -16,7 +16,9 @@ import (
"github.com/ayn2op/arikawa/v3/utils/json/option" "github.com/ayn2op/arikawa/v3/utils/json/option"
"github.com/ayn2op/arikawa/v3/utils/sendpart" "github.com/ayn2op/arikawa/v3/utils/sendpart"
"github.com/ayn2op/discordo/internal/command" "github.com/ayn2op/discordo/internal/command"
"github.com/ayn2op/discordo/internal/emoji"
"github.com/ayn2op/discordo/internal/ui/chat/commandslist" "github.com/ayn2op/discordo/internal/ui/chat/commandslist"
"github.com/ayn2op/discordo/internal/ui/chat/emojislist"
"github.com/ayn2op/tview" "github.com/ayn2op/tview"
"github.com/gdamore/tcell/v3" "github.com/gdamore/tcell/v3"
"github.com/ncruces/zenity" "github.com/ncruces/zenity"
@ -197,6 +199,9 @@ func (m *Model) tabComplete() tview.Cmd {
if m.commandsListVisible() { if m.commandsListVisible() {
return m.completeCommand() return m.completeCommand()
} }
if m.emojisListVisible() {
return m.completeEmoji()
}
if m.mentionsListVisible() { if m.mentionsListVisible() {
return m.completeMention() return m.completeMention()
} }
@ -270,22 +275,54 @@ func (m *Model) completeMention() tview.Cmd {
return nil return nil
} }
func (m *Model) completeEmoji() tview.Cmd {
posEnd, name, r := wordBeforeCursor(m.text, m.cursor, isEmojiChar)
if r != ':' {
m.stopTabCompletion()
return nil
}
pos := posEnd - (len(name) + 1)
insertText, ok := m.emojisList.SelectedInsertText()
if !ok {
return nil
}
m.replace(pos, posEnd, insertText+" ")
m.stopTabCompletion()
return nil
}
func (m *Model) tabSuggest() tview.Cmd { func (m *Model) tabSuggest() tview.Cmd {
if strings.HasPrefix(m.text, "/") { if strings.HasPrefix(m.text, "/") {
firstSpace := strings.Index(m.text, " ") firstSpace := strings.Index(m.text, " ")
if firstSpace == -1 || m.cursor <= firstSpace { if firstSpace == -1 || m.cursor <= firstSpace {
m.mentionsList.Clear() m.mentionsList.Clear()
m.emojisList.Clear()
query := m.text[1:m.cursor] query := m.text[1:m.cursor]
return m.suggestCommands(query) return m.suggestCommands(query)
} }
} }
m.commandsList.Clear() m.commandsList.Clear()
_, name, r := wordBeforeCursor(m.text, m.cursor, isMentionChar) _, mentionName, rMention := wordBeforeCursor(m.text, m.cursor, isMentionChar)
if r != '@' { if rMention == '@' {
m.emojisList.Clear()
m.commandsList.Clear()
return m.suggestMentions(mentionName)
}
_, emojiName, rEmoji := wordBeforeCursor(m.text, m.cursor, isEmojiChar)
if rEmoji == ':' {
m.mentionsList.Clear()
m.commandsList.Clear()
return m.suggestEmojis(emojiName)
}
m.stopTabCompletion() m.stopTabCompletion()
return nil return nil
} }
func (m *Model) suggestMentions(name string) tview.Cmd {
channel := m.channel channel := m.channel
if channel == nil { if channel == nil {
return nil return nil
@ -410,6 +447,49 @@ func (m *Model) fetchBotCommands(channelID discord.ChannelID, query string) tvie
} }
} }
func (m *Model) suggestEmojis(name string) tview.Cmd {
channel := m.channel
if channel == nil {
return nil
}
m.emojisList.Clear()
var customEmojis []discord.Emoji
if m.state != nil && m.state.EmojiState != nil {
guilds, err := m.state.EmojiState.ForGuild(channel.GuildID)
if err == nil {
for _, g := range guilds {
customEmojis = append(customEmojis, g.Emojis...)
}
} else {
slog.Debug("failed to get emojis for guild", "err", err)
}
}
limit := int(m.cfg.AutocompleteLimit)
if limit <= 0 {
limit = 20
}
results := emoji.Search(name, customEmojis, limit)
for _, item := range results {
style := tcell.StyleDefault
m.emojisList.Append(emojislist.Item{
Name: item.Name,
InsertText: item.InsertText,
DisplayText: item.Display,
Style: style,
})
}
if m.emojisList.ItemCount() == 0 {
m.stopTabCompletion()
return nil
}
m.emojisList.Rebuild()
return nil
}
// searchMember performs member discovery in a command goroutine. // searchMember performs member discovery in a command goroutine.
// It emits a follow-up suggestion message once results are loaded. // It emits a follow-up suggestion message once results are loaded.
func (m *Model) searchMember(gID discord.GuildID, name string) tview.Cmd { func (m *Model) searchMember(gID discord.GuildID, name string) tview.Cmd {

View file

@ -0,0 +1,10 @@
package emojislist
import "github.com/ayn2op/tview"
type Item struct {
Name string
InsertText string
DisplayText string
Style tview.Style
}

View file

@ -0,0 +1,103 @@
package emojislist
import (
"github.com/ayn2op/discordo/internal/config"
"github.com/ayn2op/discordo/internal/ui"
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/layout"
"github.com/ayn2op/tview/list"
"github.com/ayn2op/tview/scrollbar"
"github.com/ayn2op/tview/text"
"github.com/ayn2op/tview/viewport"
"github.com/rockorager/go-uucode"
)
// Model shows emoji suggestions as a list in a box.
type Model struct {
cfg *config.Config
items []Item
cursor int
scrollState viewport.ScrollState
}
func NewModel(cfg *config.Config) Model {
return Model{cfg: cfg, cursor: -1}
}
// View shows the list in a box whose bottom corners join the composer's border below it.
func (m Model) View() tview.Widget {
set := m.cfg.UI.Border.NormalSet.BorderSet
set.BottomLeft, set.BottomRight = set.BottomT, set.BottomT
cfg := m.cfg.UI.MentionsList
list, width := m.listView().Height(layout.Shrink.Max(int(cfg.MaxHeight))), layout.Fill
if cfg.MinWidth != 0 {
list, width = list.Width(layout.Fixed(max(int(cfg.MinWidth), m.maxDisplayWidth()))), layout.Shrink
}
return ui.Box(list, m.cfg, false).Title(ui.Label("Emoji", m.cfg.Theme.Title, false)).BorderSet(set).Width(width).Height(layout.Shrink)
}
func (Model) Init() tview.Cmd { return nil }
func (m Model) listView() viewport.Widget {
l := list.New(m.cursor, len(m.items), func(i int) tview.Widget {
item := m.items[i]
return text.New(item.DisplayText).Style(item.Style).Wrapping(text.WrappingNone)
}).
SelectedStyle(m.cfg.Theme.MentionsList.SelectedStyle.Style).
Keybind(ui.SelectionKeybind(m.cfg.Keybinds.MentionsList.SelectionKeybinds)).
Focused(true).
OnChange(func(cursor int) tview.Msg { return listMsg(cursor) })
return viewport.New(l, m.scrollState).
Target(l.Target(m.cursor)).
ScrollBar(scrollbar.New(), viewport.ScrollBarVisibilityAutomatic).
OnChange(func(s *viewport.ScrollState) tview.Msg { return scrollMsg(*s) })
}
func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
switch msg := msg.(type) {
case listMsg:
m.cursor = int(msg)
m.scrollState.ScrollToTarget()
case scrollMsg:
m.scrollState = viewport.ScrollState(msg)
}
return m, nil
}
func (m *Model) Append(item Item) {
m.items = append(m.items, item)
}
func (m *Model) Clear() {
m.items = nil
m.cursor = -1
}
func (m Model) ItemCount() int {
return len(m.items)
}
func (m Model) SelectedInsertText() (string, bool) {
index := m.cursor
if index < 0 || index >= len(m.items) {
return "", false
}
return m.items[index].InsertText, true
}
func (m Model) maxDisplayWidth() int {
width := 0
for _, item := range m.items {
width = max(width, uucode.StringWidth(item.DisplayText))
}
return width
}
func (m *Model) Rebuild() {
m.cursor = min(0, len(m.items)-1)
m.scrollState.ScrollToTarget()
}
var _ tview.Model[Model] = Model{}

View file

@ -0,0 +1,9 @@
package emojislist
import "github.com/ayn2op/tview/viewport"
type Msg any
type listMsg int
type scrollMsg viewport.ScrollState

View file

@ -23,6 +23,7 @@ import (
"github.com/ayn2op/discordo/internal/ui/chat/channelspicker" "github.com/ayn2op/discordo/internal/ui/chat/channelspicker"
"github.com/ayn2op/discordo/internal/ui/chat/commandslist" "github.com/ayn2op/discordo/internal/ui/chat/commandslist"
"github.com/ayn2op/discordo/internal/ui/chat/composer" "github.com/ayn2op/discordo/internal/ui/chat/composer"
"github.com/ayn2op/discordo/internal/ui/chat/emojislist"
"github.com/ayn2op/discordo/internal/ui/chat/guildstree" "github.com/ayn2op/discordo/internal/ui/chat/guildstree"
"github.com/ayn2op/discordo/internal/ui/chat/memberstree" "github.com/ayn2op/discordo/internal/ui/chat/memberstree"
"github.com/ayn2op/discordo/internal/ui/chat/mentionslist" "github.com/ayn2op/discordo/internal/ui/chat/mentionslist"
@ -305,7 +306,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
ui.DialogButton{Label: "Yes", Cmd: tview.Sequence(closeState(m.state), logout())}, ui.DialogButton{Label: "Yes", Cmd: tview.Sequence(closeState(m.state), logout())},
) )
} }
case composer.TabSuggestMsg, mentionslist.Msg, commandslist.Msg: case composer.TabSuggestMsg, mentionslist.Msg, emojislist.Msg, commandslist.Msg:
return m, m.updatePane(paneComposer, msg) return m, m.updatePane(paneComposer, msg)
case paneMsg: case paneMsg:
if msg.focus { if msg.focus {