feat(messageslist): show custom emoji images in reactions, wrapped with a separator
This commit is contained in:
parent
805621bc2e
commit
997a77c033
16 changed files with 208 additions and 54 deletions
|
|
@ -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
2
go.mod
|
|
@ -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
4
go.sum
|
|
@ -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=
|
||||||
|
|
|
||||||
|
|
@ -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},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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]).
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) }),
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue