feat(messageslist): show custom emoji images in reactions, wrapped with a separator

This commit is contained in:
ayn2op 2026-10-17 13:50:18 -04:00
parent 805621bc2e
commit 997a77c033
16 changed files with 208 additions and 54 deletions

View file

@ -424,6 +424,20 @@ The longest time between two messages by the same author for them to share a hea
Default: `"10m0s"` Default: `"10m0s"`
### ui.messages_list.reactions
#### ui.messages_list.reactions.separator
The text between reactions.
Default: `" • "`
#### ui.messages_list.reactions.colons_around_name
Whether the names of custom emoji are shown between colons, as in `:name:`.
Default: `false`
## ui.mentions_list ## ui.mentions_list
The list of mention suggestions above the composer. Its width and height are capped to the available space. The list of mention suggestions above the composer. Its width and height are capped to the available space.

2
go.mod
View file

@ -16,7 +16,7 @@ require (
github.com/alecthomas/chroma/v2 v2.27.0 github.com/alecthomas/chroma/v2 v2.27.0
github.com/ayn2op/arikawa/v3 v3.0.0-20261016043052-54a68a54e42e github.com/ayn2op/arikawa/v3 v3.0.0-20261016043052-54a68a54e42e
github.com/ayn2op/ningen/v3 v3.0.1-0.20261014035153-5e43590a1426 github.com/ayn2op/ningen/v3 v3.0.1-0.20261014035153-5e43590a1426
github.com/ayn2op/tview v0.0.0-20261017014958-0756c1611d0f github.com/ayn2op/tview v0.0.0-20261017195448-33f4b52a0ef5
github.com/bogdanfinn/fhttp v0.6.9 github.com/bogdanfinn/fhttp v0.6.9
github.com/bogdanfinn/tls-client v1.16.0 github.com/bogdanfinn/tls-client v1.16.0
github.com/gdamore/tcell/v3 v3.6.0 github.com/gdamore/tcell/v3 v3.6.0

4
go.sum
View file

@ -16,8 +16,8 @@ github.com/ayn2op/fhttp v0.6.10-0.20261016015330-2d5e34c6b935 h1:oVxkY1/nVsVQ8Cn
github.com/ayn2op/fhttp v0.6.10-0.20261016015330-2d5e34c6b935/go.mod h1:7phoj3f/q54WuiTnCTlAhEoaVcdlruKkSzCSD47LzHk= github.com/ayn2op/fhttp v0.6.10-0.20261016015330-2d5e34c6b935/go.mod h1:7phoj3f/q54WuiTnCTlAhEoaVcdlruKkSzCSD47LzHk=
github.com/ayn2op/ningen/v3 v3.0.1-0.20261014035153-5e43590a1426 h1:0ndRWpAiSSgR0z9b7K+pwMlCHEk3VzQeS5ls+zWtbr0= github.com/ayn2op/ningen/v3 v3.0.1-0.20261014035153-5e43590a1426 h1:0ndRWpAiSSgR0z9b7K+pwMlCHEk3VzQeS5ls+zWtbr0=
github.com/ayn2op/ningen/v3 v3.0.1-0.20261014035153-5e43590a1426/go.mod h1:4S2B9frwrjELeAldoqPgasA/VfnOySgRlektwYLuVks= github.com/ayn2op/ningen/v3 v3.0.1-0.20261014035153-5e43590a1426/go.mod h1:4S2B9frwrjELeAldoqPgasA/VfnOySgRlektwYLuVks=
github.com/ayn2op/tview v0.0.0-20261017014958-0756c1611d0f h1:f3Kiqss18xrNTzyB5MfzVTGfrX1tUwMPENmku8BdNGg= github.com/ayn2op/tview v0.0.0-20261017195448-33f4b52a0ef5 h1:IbG2cMS8rH6fEtktBc/k+HDyIt0Dosx+pB4sxtXXpRM=
github.com/ayn2op/tview v0.0.0-20261017014958-0756c1611d0f/go.mod h1:kn8Up2Tk2vhDUtW6H9nqAvRk9hDVyCzk18CCAo2qkjA= github.com/ayn2op/tview v0.0.0-20261017195448-33f4b52a0ef5/go.mod h1:kn8Up2Tk2vhDUtW6H9nqAvRk9hDVyCzk18CCAo2qkjA=
github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q= github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q=
github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM= github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM=
github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic= github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic=

View file

@ -37,10 +37,10 @@ func (e enum[T]) text(value T) ([]byte, error) {
var ( var (
boxDrawing = scrollbar.SymbolSet{Track: "│", Thumb: "█", Begin: "▲", End: "▼"} boxDrawing = scrollbar.SymbolSet{Track: "│", Thumb: "█", Begin: "▲", End: "▼"}
alignments = enum[tview.Alignment]{ alignments = enum[tview.HorizontalAlignment]{
{"left", tview.AlignmentLeft}, {"left", tview.HorizontalAlignmentLeft},
{"center", tview.AlignmentCenter}, {"center", tview.HorizontalAlignmentCenter},
{"right", tview.AlignmentRight}, {"right", tview.HorizontalAlignmentRight},
} }
borderSets = enum[tview.BorderSet]{ borderSets = enum[tview.BorderSet]{
{"hidden", tview.BorderSetHidden()}, {"hidden", tview.BorderSetHidden()},
@ -67,14 +67,16 @@ var (
} }
) )
type AlignmentWrapper struct{ tview.Alignment } type AlignmentWrapper struct{ tview.HorizontalAlignment }
func (aw *AlignmentWrapper) UnmarshalText(text []byte) (err error) { func (aw *AlignmentWrapper) UnmarshalText(text []byte) (err error) {
aw.Alignment, err = alignments.parse(string(text)) aw.HorizontalAlignment, err = alignments.parse(string(text))
return err return err
} }
func (aw AlignmentWrapper) MarshalText() ([]byte, error) { return alignments.text(aw.Alignment) } func (aw AlignmentWrapper) MarshalText() ([]byte, error) {
return alignments.text(aw.HorizontalAlignment)
}
type BorderSetWrapper struct{ tview.BorderSet } type BorderSetWrapper struct{ tview.BorderSet }
@ -181,6 +183,14 @@ type (
Attachments AttachmentsUIConfig `toml:"attachments"` Attachments AttachmentsUIConfig `toml:"attachments"`
Avatars AvatarsUIConfig `toml:"avatars"` Avatars AvatarsUIConfig `toml:"avatars"`
Grouping GroupingUIConfig `toml:"grouping"` Grouping GroupingUIConfig `toml:"grouping"`
Reactions ReactionsUIConfig `toml:"reactions"`
}
ReactionsUIConfig struct {
// The text between reactions.
Separator string `toml:"separator"`
// Whether the names of custom emoji are shown between colons, as in `:name:`.
ColonsAroundName bool `toml:"colons_around_name"`
} }
// GroupingUIConfig shows consecutive messages by the same author under one header. // GroupingUIConfig shows consecutive messages by the same author under one header.
@ -214,7 +224,7 @@ type (
) )
func defaultUI() UIConfig { func defaultUI() UIConfig {
left := TitleUIConfig{AlignmentWrapper{tview.AlignmentLeft}} left := TitleUIConfig{AlignmentWrapper{tview.HorizontalAlignmentLeft}}
round := BorderSetWrapper{tview.BorderSetRound()} round := BorderSetWrapper{tview.BorderSetRound()}
attachments := AttachmentsUIConfig{ attachments := AttachmentsUIConfig{
ShowLinks: true, ShowLinks: true,
@ -223,7 +233,7 @@ func defaultUI() UIConfig {
} }
return UIConfig{ return UIConfig{
Tabs: TabsUIConfig{ Tabs: TabsUIConfig{
Alignment: AlignmentWrapper{tview.AlignmentCenter}, Alignment: AlignmentWrapper{tview.HorizontalAlignmentCenter},
Separator: "│", Separator: "│",
Padding: [2]string{" ", " "}, Padding: [2]string{" ", " "},
Arrows: [2]string{"◀", "▶"}, Arrows: [2]string{"◀", "▶"},
@ -246,6 +256,7 @@ func defaultUI() UIConfig {
Attachments: attachments, Attachments: attachments,
Avatars: AvatarsUIConfig{Preview: true, Height: 2}, Avatars: AvatarsUIConfig{Preview: true, Height: 2},
Grouping: GroupingUIConfig{Enabled: true, Interval: 10 * time.Minute}, Grouping: GroupingUIConfig{Enabled: true, Interval: 10 * time.Minute},
Reactions: ReactionsUIConfig{Separator: " • "},
}, },
MentionsList: MentionsListUIConfig{MinWidth: 20}, MentionsList: MentionsListUIConfig{MinWidth: 20},
} }

View file

@ -121,7 +121,7 @@ func (r row) Draw(screen tview.Screen, area tview.Rectangle) {
for x := area.X; x < area.X+area.Width; x++ { for x := area.X; x < area.X+area.Width; x++ {
screen.Put(x, area.Y, " ", r.style) screen.Put(x, area.Y, " ", r.style)
} }
tview.Print(screen, r.text, area.X, area.Y, area.Width, tview.AlignmentLeft, r.style) tview.Print(screen, r.text, area.X, area.Y, area.Width, tview.HorizontalAlignmentLeft, r.style)
} }
func (row) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg { return msg } func (row) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg { return msg }

View file

@ -13,6 +13,7 @@ import (
"github.com/ayn2op/discordo/internal/markdown" "github.com/ayn2op/discordo/internal/markdown"
"github.com/ayn2op/tview" "github.com/ayn2op/tview"
"github.com/ayn2op/tview/layout" "github.com/ayn2op/tview/layout"
"github.com/ayn2op/tview/row"
"github.com/ayn2op/tview/text" "github.com/ayn2op/tview/text"
"github.com/gdamore/tcell/v3" "github.com/gdamore/tcell/v3"
"github.com/gdamore/tcell/v3/color" "github.com/gdamore/tcell/v3/color"
@ -89,7 +90,7 @@ func (d dateSeparator) Draw(screen tview.Screen, area tview.Rectangle) {
fill := area.Width - labelWidth fill := area.Width - labelWidth
line = strings.Repeat(d.fill, fill/2) + label + strings.Repeat(d.fill, fill-fill/2) line = strings.Repeat(d.fill, fill/2) + label + strings.Repeat(d.fill, fill-fill/2)
} }
tview.Print(screen, line, area.X, area.Y, area.Width, tview.AlignmentLeft, d.style) tview.Print(screen, line, area.X, area.Y, area.Width, tview.HorizontalAlignmentLeft, d.style)
} }
func (dateSeparator) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg { return msg } func (dateSeparator) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg { return msg }
@ -134,30 +135,42 @@ func (m Model) writeMessage(builder *markdown.Builder, message discord.Message,
default: default:
m.drawLayout(builder, message, baseStyle, false, nil) m.drawLayout(builder, message, baseStyle, false, nil)
} }
m.drawReactions(builder, message.Reactions, baseStyle)
} }
func (m Model) drawReactions(builder *markdown.Builder, reactions []discord.Reaction, baseStyle tview.Style) { // reactionsView returns the reactions wrapped to the width of the message, with the image of each custom emoji that has one before its name, or nil if there are none.
func (m Model) reactionsView(reactions []discord.Reaction) tview.Widget {
if len(reactions) == 0 { if len(reactions) == 0 {
return return nil
} }
builder.NewLine() theme, cfg := m.cfg.Theme.MessagesList, m.cfg.UI.MessagesList.Reactions
separatorStyle := tview.MergeStyle(theme.MessageStyle.Style, theme.ReactionStyle.Style)
chips := make([]tview.Widget, len(reactions))
for i, reaction := range reactions { for i, reaction := range reactions {
if i > 0 { style := theme.ReactionStyle.Style
builder.Write(" ", baseStyle) if reaction.Me {
style = theme.OwnReactionStyle.Style
} }
style = tview.MergeStyle(theme.MessageStyle.Style, style)
name := reaction.Emoji.Name name := reaction.Emoji.Name
if reaction.Emoji.IsCustom() { if reaction.Emoji.IsCustom() && cfg.ColonsAroundName {
name = ":" + name + ":" name = ":" + name + ":"
} }
style := m.cfg.Theme.MessagesList.ReactionStyle.Style
if reaction.Me { spans := []text.Span{{Text: name + " " + strconv.Itoa(reaction.Count), Style: style}}
style = m.cfg.Theme.MessagesList.OwnReactionStyle.Style // Each reaction but the last ends with the separator, so a line never starts with one.
if i < len(reactions)-1 {
spans = append(spans, text.Span{Text: cfg.Separator, Style: separatorStyle})
} }
builder.Write(name+" "+strconv.Itoa(reaction.Count), tview.MergeStyle(baseStyle, style))
var chip tview.Widget = text.Rich(spans...)
if emoji, ok := m.images[emojiURL(reaction.Emoji)]; ok {
chip = row.New(emoji, chip).Spacing(1).Width(layout.Shrink).Height(layout.Shrink)
} }
chips[i] = chip
}
return row.New(chips...).Height(layout.Shrink).Wrap()
} }
func (m Model) formatTimestamp(ts discord.Timestamp) string { func (m Model) formatTimestamp(ts discord.Timestamp) string {

View file

@ -47,7 +47,8 @@ type Model struct {
items []messageItem items []messageItem
renderer *markdown.Renderer renderer *markdown.Renderer
avatars map[discord.URL]tviewimage.Widget // images are the avatars and emoji, shared by every message that shows them.
images map[discord.URL]tviewimage.Widget
// voicePlayer plays the voice message played last, and voiceStatus is what it last said of it. // voicePlayer plays the voice message played last, and voiceStatus is what it last said of it.
voicePlayer *voice.Player voicePlayer *voice.Player
voiceStatus voice.PlayerStatus voiceStatus voice.PlayerStatus
@ -69,7 +70,7 @@ func NewModel(cfg *config.Config, state *ningen.State) Model {
cfg: cfg, cfg: cfg,
state: state, state: state,
renderer: markdown.NewRenderer(cfg), renderer: markdown.NewRenderer(cfg),
avatars: map[discord.URL]tviewimage.Widget{}, images: map[discord.URL]tviewimage.Widget{},
voicePlayer: voice.NewPlayer(), voicePlayer: voice.NewPlayer(),
} }
@ -155,10 +156,14 @@ func (m *Model) UpdateMessage(message discord.Message) tview.Cmd {
} }
// RefreshMessage shows the cached state of a message, such as after a reaction. // RefreshMessage shows the cached state of a message, such as after a reaction.
func (m *Model) RefreshMessage(channelID discord.ChannelID, id discord.MessageID) { func (m *Model) RefreshMessage(channelID discord.ChannelID, id discord.MessageID) tview.Cmd {
if message, err := m.state.Cabinet.Message(channelID, id); err == nil { message, err := m.state.Cabinet.Message(channelID, id)
m.setMessage(m.indexOf(channelID, id), *message) if err != nil {
return nil
} }
m.setMessage(m.indexOf(channelID, id), *message)
return m.loadPreviews()
} }
// DeleteMessage removes the shown message with id. // DeleteMessage removes the shown message with id.
@ -176,7 +181,7 @@ func (m Model) indexOf(channelID discord.ChannelID, id discord.MessageID) int {
func (m *Model) reset() { func (m *Model) reset() {
m.items = nil m.items = nil
clear(m.avatars) clear(m.images)
m.cursor, m.target = -1, -1 m.cursor, m.target = -1, -1
m.scrollState = viewport.ScrollState{} m.scrollState = viewport.ScrollState{}
m.scrollState.ScrollToEnd() m.scrollState.ScrollToEnd()
@ -266,21 +271,26 @@ func (m Model) buildItem(index int) tview.Widget {
} }
} }
if avatar, ok := m.avatars[avatarURL(message.Author)]; ok { if !m.blocked(message.Author) {
body = body.Push(m.reactionsView(message.Reactions))
}
var view tview.Widget = body
if avatar, ok := m.images[avatarURL(message.Author)]; ok {
var gutter tview.Widget = avatar var gutter tview.Widget = avatar
// A grouped message leaves the avatar's column empty, under the avatar of the group. // A grouped message leaves the avatar's column empty, under the avatar of the group.
if item.grouped { if item.grouped {
width, _ := avatar.Size() width, _ := avatar.Size()
gutter = spacer.New().Width(width) gutter = spacer.New().Width(width)
} }
body = row.New(gutter, body).Spacing(1).Height(layout.Shrink) view = row.New(gutter, body).Spacing(1).Height(layout.Shrink)
} }
if message.Type == discord.InlinedReplyMessage && !m.blocked(message.Author) { if message.Type == discord.InlinedReplyMessage && !m.blocked(message.Author) {
body = column.New(item.reply, body).Height(layout.Shrink) view = column.New(item.reply, view).Height(layout.Shrink)
} }
return body return view
} }
// shouldGroup reports whether message is drawn under the header of previous, the message before it, which is nil for the first. // shouldGroup reports whether message is drawn under the header of previous, the message before it, which is nil for the first.
@ -447,8 +457,8 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
cmd = preview.Transmit() cmd = preview.Transmit()
} }
if _, ok := m.avatars[msg.proxy]; ok { if _, ok := m.images[msg.proxy]; ok {
m.avatars[msg.proxy] = preview m.images[msg.proxy] = preview
return m, cmd return m, cmd
} }

View file

@ -2,6 +2,7 @@ package messageslist
import ( import (
"encoding/json" "encoding/json"
"image"
"path/filepath" "path/filepath"
"slices" "slices"
"strings" "strings"
@ -13,8 +14,10 @@ import (
"github.com/ayn2op/discordo/internal/config" "github.com/ayn2op/discordo/internal/config"
"github.com/ayn2op/ningen/v3" "github.com/ayn2op/ningen/v3"
"github.com/ayn2op/tview" "github.com/ayn2op/tview"
tviewimage "github.com/ayn2op/tview/image"
"github.com/ayn2op/tview/layout" "github.com/ayn2op/tview/layout"
"github.com/gdamore/tcell/v3" "github.com/gdamore/tcell/v3"
"github.com/gdamore/tcell/v3/vt"
) )
func testConfig(t *testing.T) *config.Config { func testConfig(t *testing.T) *config.Config {
@ -353,3 +356,46 @@ func TestModelScrollToTop(t *testing.T) {
t.Fatalf("scrolling to the new top fetched %d times, want once", fetches) t.Fatalf("scrolling to the new top fetched %d times, want once", fetches)
} }
} }
func TestModelReactionsView(t *testing.T) {
ml := newTestModel(t, testConfig(t))
custom := discord.Emoji{ID: 5, Name: "cat"}
reactions := []discord.Reaction{{Count: 2, Emoji: discord.Emoji{Name: "ok"}}, {Count: 1, Emoji: custom}}
draw := func() string {
screen, err := tcell.NewTerminfoScreenFromTty(vt.NewMockTerm(vt.MockOptSize{X: 20, Y: 1}))
if err != nil {
t.Fatal(err)
}
if err := screen.Init(); err != nil {
t.Fatal(err)
}
defer screen.Fini()
ml.reactionsView(reactions).Draw(screen, tview.Rectangle{Width: 20, Height: 1})
var row strings.Builder
for x := range 20 {
str, _, _ := screen.Get(x, 0)
row.WriteString(str)
}
return strings.TrimRight(row.String(), " ")
}
t.Run("names", func(t *testing.T) {
if got := draw(); got != "ok 2 • cat 1" {
t.Fatalf("reactions = %q", got)
}
})
t.Run("emoji image", func(t *testing.T) {
ml.images[emojiURL(custom)] = tviewimage.New(image.NewAlpha(image.Rect(0, 0, 2, 2))).Width(2)
// The blank image takes two cells before the name.
if got := draw(); got != "ok 2 • cat 1" {
t.Fatalf("reactions = %q", got)
}
})
t.Run("with colons", func(t *testing.T) {
ml.cfg.UI.MessagesList.Reactions.ColonsAroundName = true
if got := draw(); got != "ok 2 • :cat: 1" {
t.Fatalf("reactions = %q", got)
}
})
}

View file

@ -2,6 +2,7 @@ package messageslist
import ( import (
"image" "image"
"image/draw"
_ "image/gif" _ "image/gif"
_ "image/jpeg" _ "image/jpeg"
_ "image/png" _ "image/png"
@ -86,6 +87,10 @@ func (m Model) loadPreviews() tview.Cmd {
} }
cmds = append(cmds, m.loadAvatar(item.message.Author)) cmds = append(cmds, m.loadAvatar(item.message.Author))
for _, reaction := range item.message.Reactions {
cmds = append(cmds, m.loadEmoji(reaction.Emoji))
}
if !m.cfg.UI.MessagesList.Attachments.Preview { if !m.cfg.UI.MessagesList.Attachments.Preview {
continue continue
} }
@ -116,21 +121,55 @@ func avatarURL(user discord.User) discord.URL {
} }
func (m Model) loadAvatar(user discord.User) tview.Cmd { func (m Model) loadAvatar(user discord.User) tview.Cmd {
url, cfg := avatarURL(user), m.cfg.UI.MessagesList.Avatars cfg := m.cfg.UI.MessagesList.Avatars
if _, ok := m.avatars[url]; ok || !cfg.Preview || cfg.Height == 0 || m.blocked(user) { if !cfg.Preview || cfg.Height == 0 || m.blocked(user) {
return nil
}
return m.loadImage(avatarURL(user), uint(cfg.Height))
}
func emojiURL(emoji discord.Emoji) discord.URL {
return emoji.EmojiURLWithType(discord.PNGImage)
}
// loadEmoji loads the image of a custom emoji, a row tall.
func (m Model) loadEmoji(emoji discord.Emoji) tview.Cmd {
if !emoji.IsCustom() {
return nil
}
return m.loadImage(emojiURL(emoji), 1)
}
// loadImage loads the image at url in a square rows tall, unless it is loaded already.
func (m Model) loadImage(url discord.URL, rows uint) tview.Cmd {
if _, ok := m.images[url]; ok {
return nil return nil
} }
cellWidth, cellHeight := m.previewCell() cellWidth, cellHeight := m.previewCell()
cols := uint(cfg.Height) * cellHeight / cellWidth cols := rows * cellHeight / cellWidth
bounds := image.Rect(0, 0, int(cols*cellWidth), int(cols*cellWidth)) bounds := image.Rect(0, 0, int(cols*cellWidth), int(cols*cellWidth))
widget := func(img image.Image) tviewimage.Widget { widget := func(img image.Image) tviewimage.Widget {
return tviewimage.New(img).Width(int(cols)).CellSize(int(cellWidth), int(cellHeight)) return tviewimage.New(square(img)).Width(int(cols)).CellSize(int(cellWidth), int(cellHeight))
} }
m.avatars[url] = widget(image.NewAlpha(bounds)) m.images[url] = widget(image.NewAlpha(bounds))
return m.loadPreview(0, url, bounds, widget) return m.loadPreview(0, url, bounds, widget)
} }
// square returns img centered on a transparent square, so an image of any shape takes the square's cells.
func square(img image.Image) image.Image {
b := img.Bounds()
if b.Dx() == b.Dy() {
return img
}
side := max(b.Dx(), b.Dy())
dst := image.NewNRGBA(image.Rect(0, 0, side, side))
at := image.Pt((side-b.Dx())/2, (side-b.Dy())/2)
draw.Draw(dst, b.Sub(b.Min).Add(at), img, b.Min, draw.Src)
return dst
}
// loadPreview yields an empty preview if loading fails. // loadPreview yields an empty preview if loading fails.
func (m Model) loadPreview(messageID discord.MessageID, proxy discord.URL, bounds image.Rectangle, widget func(image.Image) tviewimage.Widget) tview.Cmd { func (m Model) loadPreview(messageID discord.MessageID, proxy discord.URL, bounds image.Rectangle, widget func(image.Image) tviewimage.Widget) tview.Cmd {
return func() tview.Msg { return func() tview.Msg {

View file

@ -1,6 +1,7 @@
package messageslist package messageslist
import ( import (
"image"
"slices" "slices"
"testing" "testing"
@ -56,3 +57,23 @@ func TestPreviewSourceCols(t *testing.T) {
}) })
} }
} }
func TestSquare(t *testing.T) {
t.Run("square", func(t *testing.T) {
img := image.NewAlpha(image.Rect(0, 0, 20, 20))
if got := square(img); got != image.Image(img) {
t.Fatal("square image was copied")
}
})
t.Run("tall", func(t *testing.T) {
img := image.NewAlpha(image.Rect(0, 0, 10, 30))
img.Pix[0] = 0xff
got := square(img)
if got.Bounds() != image.Rect(0, 0, 30, 30) {
t.Fatalf("bounds = %v, want 30 by 30", got.Bounds())
}
if _, _, _, a := got.At(10, 0).RGBA(); a == 0 {
t.Fatal("image is not centered")
}
})
}

View file

@ -116,15 +116,15 @@ func (m *Model) applyEvent(event gateway.Event) tview.Cmd {
case *gateway.MessageDeleteEvent: case *gateway.MessageDeleteEvent:
m.messagesList.DeleteMessage(event.ChannelID, event.ID) m.messagesList.DeleteMessage(event.ChannelID, event.ID)
case *gateway.MessageReactionAddEvent: case *gateway.MessageReactionAddEvent:
m.messagesList.RefreshMessage(event.ChannelID, event.MessageID) return m.messagesList.RefreshMessage(event.ChannelID, event.MessageID)
case *gateway.MessageReactionAddManyEvent: case *gateway.MessageReactionAddManyEvent:
m.messagesList.RefreshMessage(event.ChannelID, event.MessageID) return m.messagesList.RefreshMessage(event.ChannelID, event.MessageID)
case *gateway.MessageReactionRemoveEvent: case *gateway.MessageReactionRemoveEvent:
m.messagesList.RefreshMessage(event.ChannelID, event.MessageID) return m.messagesList.RefreshMessage(event.ChannelID, event.MessageID)
case *gateway.MessageReactionRemoveAllEvent: case *gateway.MessageReactionRemoveAllEvent:
m.messagesList.RefreshMessage(event.ChannelID, event.MessageID) return m.messagesList.RefreshMessage(event.ChannelID, event.MessageID)
case *gateway.MessageReactionRemoveEmojiEvent: case *gateway.MessageReactionRemoveEmojiEvent:
m.messagesList.RefreshMessage(event.ChannelID, event.MessageID) return m.messagesList.RefreshMessage(event.ChannelID, event.MessageID)
case *gateway.GuildMembersChunkEvent: case *gateway.GuildMembersChunkEvent:
return m.refreshMemberNames(event) return m.refreshMemberNames(event)

View file

@ -72,7 +72,7 @@ func (m Model) View() tview.Widget {
func (m Model) tabs() tabs.Widget { func (m Model) tabs() tabs.Widget {
cfg := m.cfg.UI.Tabs cfg := m.cfg.UI.Tabs
return tabs.New(m.password.Label(), m.qr.Label(), m.token.Label()). return tabs.New(m.password.Label(), m.qr.Label(), m.token.Label()).
Alignment(cfg.Alignment.Alignment). Alignment(cfg.Alignment.HorizontalAlignment).
Wrap(cfg.Wrap). Wrap(cfg.Wrap).
Separator(cfg.Separator). Separator(cfg.Separator).
Padding(cfg.Padding[0], cfg.Padding[1]). Padding(cfg.Padding[0], cfg.Padding[1]).

View file

@ -82,5 +82,5 @@ func (m Model) View() tview.Widget {
row.New(column.New(text.New("Login")).Width(labelWidth), login).Height(layout.Shrink), row.New(column.New(text.New("Login")).Width(labelWidth), login).Height(layout.Shrink),
row.New(column.New(text.New("Password")).Width(labelWidth), password).Height(layout.Shrink), row.New(column.New(text.New("Password")).Width(labelWidth), password).Height(layout.Shrink),
submit, submit,
).Spacing(1).Align(tview.AlignmentCenter) ).Spacing(1).AlignX(tview.HorizontalAlignmentCenter)
} }

View file

@ -2,12 +2,12 @@ package qr
import ( import (
"crypto/rsa" "crypto/rsa"
"github.com/ayn2op/tview/layout"
"strings" "strings"
"time" "time"
"github.com/ayn2op/tview" "github.com/ayn2op/tview"
"github.com/ayn2op/tview/center" "github.com/ayn2op/tview/center"
"github.com/ayn2op/tview/layout"
"github.com/ayn2op/tview/text" "github.com/ayn2op/tview/text"
"github.com/ayn2op/tview/viewport" "github.com/ayn2op/tview/viewport"
"github.com/gdamore/tcell/v3" "github.com/gdamore/tcell/v3"
@ -131,7 +131,7 @@ func halfBlock(top, bottom bool) rune {
func (m Model) View() tview.Widget { func (m Model) View() tview.Widget {
code := m.code() code := m.code()
return center.New( return center.New(
viewport.New(text.New(code).Wrapping(text.WrappingNone).AlignX(tview.AlignmentCenter), m.scrollState). viewport.New(text.New(code).Wrapping(text.WrappingNone).AlignX(tview.HorizontalAlignmentCenter), m.scrollState).
Height(layout.Shrink). Height(layout.Shrink).
Focused(true). Focused(true).
OnChange(func(s *viewport.ScrollState) tview.Msg { return scrollMsg(*s) }), OnChange(func(s *viewport.ScrollState) tview.Msg { return scrollMsg(*s) }),

View file

@ -62,5 +62,5 @@ func (m Model) View() tview.Widget {
return column.New( return column.New(
row.New(column.New(text.New("Token")).Width(layout.Fixed(len("Token")+1)), input).Height(layout.Shrink), row.New(column.New(text.New("Token")).Width(layout.Fixed(len("Token")+1)), input).Height(layout.Shrink),
submit, submit,
).Spacing(1).Align(tview.AlignmentCenter) ).Spacing(1).AlignX(tview.HorizontalAlignmentCenter)
} }

View file

@ -18,8 +18,8 @@ func Box(child tview.Widget, cfg *config.Config, focused bool) box.Widget {
padding := ui.Border.Padding padding := ui.Border.Padding
b := box.New(child). b := box.New(child).
Padding(padding[0], padding[1], padding[2], padding[3]). Padding(padding[0], padding[1], padding[2], padding[3]).
TitleAlignment(ui.Title.Alignment.Alignment). TitleAlignment(ui.Title.Alignment.HorizontalAlignment).
FooterAlignment(ui.Footer.Alignment.Alignment) FooterAlignment(ui.Footer.Alignment.HorizontalAlignment)
if ui.Border.Enabled { if ui.Border.Enabled {
b = b.Borders(tview.BordersAll) b = b.Borders(tview.BordersAll)
} }