feat(messageslist): show avatar preview
This commit is contained in:
parent
5d518d7521
commit
bba502cba8
20 changed files with 227 additions and 213 deletions
|
|
@ -41,31 +41,6 @@ The number of messages to fetch when a text-based channel is selected, from 1 to
|
|||
|
||||
Default: `50`
|
||||
|
||||
# attachments
|
||||
|
||||
## attachments.show_links
|
||||
|
||||
Default: `true`
|
||||
|
||||
## attachments.allowed_mime_types
|
||||
|
||||
Attachment MIME types that can be opened or downloaded without confirmation.
|
||||
Exact types and wildcards such as "image/*" are supported.
|
||||
|
||||
Default: `["image/*", "video/*"]`
|
||||
|
||||
## attachments.preview
|
||||
|
||||
Whether to draw image attachments and embedded images below their messages.
|
||||
|
||||
Default: `true`
|
||||
|
||||
## attachments.protocol
|
||||
|
||||
How previews are drawn: "auto", "kitty" or "halfblocks".
|
||||
|
||||
Default: `"auto"`
|
||||
|
||||
# markdown
|
||||
|
||||
## markdown.enabled
|
||||
|
|
@ -388,7 +363,7 @@ Default: `false`
|
|||
|
||||
### ui.messages_list.gap
|
||||
|
||||
Empty rows between messages.
|
||||
The number of empty rows between messages.
|
||||
|
||||
Default: `1`
|
||||
|
||||
|
|
@ -400,6 +375,43 @@ Default: `">"`
|
|||
|
||||
Default: `"<"`
|
||||
|
||||
### ui.messages_list.attachments
|
||||
|
||||
#### ui.messages_list.attachments.show_links
|
||||
|
||||
Default: `true`
|
||||
|
||||
#### ui.messages_list.attachments.allowed_mime_types
|
||||
|
||||
Attachment MIME types that can be opened or downloaded without confirmation.
|
||||
Exact types and wildcards such as "image/*" are supported.
|
||||
|
||||
Default: `["image/*", "video/*"]`
|
||||
|
||||
#### ui.messages_list.attachments.preview
|
||||
|
||||
Whether to draw image attachments and embedded images below their messages.
|
||||
|
||||
Default: `true`
|
||||
|
||||
#### ui.messages_list.attachments.protocol
|
||||
|
||||
How previews are drawn: "auto", "kitty" or "halfblocks".
|
||||
|
||||
Default: `"auto"`
|
||||
|
||||
### ui.messages_list.avatars
|
||||
|
||||
#### ui.messages_list.avatars.preview
|
||||
|
||||
Default: `true`
|
||||
|
||||
#### ui.messages_list.avatars.height
|
||||
|
||||
The number of rows an avatar takes. It is recommended to use `1` with compact.
|
||||
|
||||
Default: `2`
|
||||
|
||||
## ui.mentions_list
|
||||
|
||||
The list of mention suggestions above the composer. Its width and height are capped to the available space.
|
||||
|
|
|
|||
4
go.mod
4
go.mod
|
|
@ -15,13 +15,13 @@ require (
|
|||
github.com/andybalholm/brotli v1.2.6
|
||||
github.com/ayn2op/arikawa/v3 v3.0.0-20261012034659-5b006907b2de
|
||||
github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e
|
||||
github.com/ayn2op/tview v0.0.0-20261012034741-d8b2704e2b8e
|
||||
github.com/ayn2op/tview v0.0.0-20261013011207-c8c627013874
|
||||
github.com/bogdanfinn/fhttp v0.6.9
|
||||
github.com/bogdanfinn/tls-client v1.16.0
|
||||
github.com/gdamore/tcell/v3 v3.6.0
|
||||
github.com/gen2brain/malgo v0.11.26
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/klauspost/compress v1.20.1
|
||||
github.com/klauspost/compress v1.20.3
|
||||
github.com/mattn/go-shellwords v1.0.16
|
||||
github.com/ncruces/zenity v0.10.15
|
||||
github.com/rivo/uniseg v0.4.7
|
||||
|
|
|
|||
8
go.sum
8
go.sum
|
|
@ -14,8 +14,8 @@ github.com/ayn2op/arikawa/v3 v3.0.0-20261012034659-5b006907b2de h1:bO825TKutshlG
|
|||
github.com/ayn2op/arikawa/v3 v3.0.0-20261012034659-5b006907b2de/go.mod h1:YARcqZmk7hDjXB+lexlXX4wDFHULafbGnF0gsD0tuNw=
|
||||
github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e h1:QAei/IVEyZV7GcpkpcFUh37+QRqwrt57z7OT3ciHrhI=
|
||||
github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e/go.mod h1:RsvYiMzi4Si1F7vmml5NCL2DaKIZXkNUQC7QlGTMtCw=
|
||||
github.com/ayn2op/tview v0.0.0-20261012034741-d8b2704e2b8e h1:uCt61XkJ0DqRiBd5ozXZVd2xWlNFcA0rR2hew9lAZx0=
|
||||
github.com/ayn2op/tview v0.0.0-20261012034741-d8b2704e2b8e/go.mod h1:3aWH6S5uOqOkZv7JFePEVHukTj9hPb6Lbynm59Sks2E=
|
||||
github.com/ayn2op/tview v0.0.0-20261013011207-c8c627013874 h1:zXM8mKeDfXx+dzGEVTTZ0Xfo9vCXNBiX2qEWz/6KMV0=
|
||||
github.com/ayn2op/tview v0.0.0-20261013011207-c8c627013874/go.mod h1:3aWH6S5uOqOkZv7JFePEVHukTj9hPb6Lbynm59Sks2E=
|
||||
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-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic=
|
||||
|
|
@ -62,8 +62,8 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq
|
|||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/josephspurrier/goversioninfo v1.7.0 h1:LQzXOlVm/CtbwJ9/UHl5a2HT0BjcLAwid5gqGd7ZUJ8=
|
||||
github.com/josephspurrier/goversioninfo v1.7.0/go.mod h1:z9y0r2G6g5jwSJaFE0cxW9to0aeIibK7UYeLx53aQRU=
|
||||
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
|
||||
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/klauspost/compress v1.20.3 h1:zWNoFqq/lSuYQRim3dUXlXeA0IqwuZGicdG5ZTfY5j4=
|
||||
github.com/klauspost/compress v1.20.3/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
|
||||
|
|
|
|||
|
|
@ -62,17 +62,6 @@ type (
|
|||
Format string `toml:"format"`
|
||||
}
|
||||
|
||||
AttachmentsConfig struct {
|
||||
ShowLinks bool `toml:"show_links"`
|
||||
// Attachment MIME types that can be opened or downloaded without confirmation.
|
||||
// Exact types and wildcards such as "image/*" are supported.
|
||||
AllowedMIMETypes MIMETypes `toml:"allowed_mime_types"`
|
||||
// Whether to draw image attachments and embedded images below their messages.
|
||||
Preview bool `toml:"preview"`
|
||||
// How previews are drawn: "auto", "kitty" or "halfblocks".
|
||||
Protocol string `toml:"protocol"`
|
||||
}
|
||||
|
||||
DateSeparator struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
// https://pkg.go.dev/time#Layout
|
||||
|
|
@ -198,8 +187,6 @@ type (
|
|||
// The number of messages to fetch when a text-based channel is selected, from 1 to 100.
|
||||
MessagesLimit uint8 `toml:"messages_limit"`
|
||||
|
||||
Attachments AttachmentsConfig `toml:"attachments"`
|
||||
|
||||
Markdown MarkdownConfig `toml:"markdown"`
|
||||
Help HelpConfig `toml:"help"`
|
||||
Picker PickerConfig `toml:"picker"`
|
||||
|
|
@ -242,13 +229,6 @@ func Default() Config {
|
|||
AutocompleteLimit: 20,
|
||||
MessagesLimit: 50,
|
||||
|
||||
Attachments: AttachmentsConfig{
|
||||
ShowLinks: true,
|
||||
AllowedMIMETypes: MIMETypes{"image/*", "video/*"},
|
||||
Preview: true,
|
||||
Protocol: "auto",
|
||||
},
|
||||
|
||||
Markdown: MarkdownConfig{Enabled: true, Theme: "monokai"},
|
||||
Help: HelpConfig{Enabled: true, CompactModifiers: true, Padding: [2]int{1, 1}, Separator: " • "},
|
||||
Picker: PickerConfig{Width: 80, Height: 25},
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ func TestLoad(t *testing.T) {
|
|||
|
||||
t.Run("invalid allowed MIME type returns error", func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "bad-mime.toml")
|
||||
if err := os.WriteFile(path, []byte("[attachments]\nallowed_mime_types = [\"image/\"]"), os.ModePerm); err != nil {
|
||||
if err := os.WriteFile(path, []byte("[ui.messages_list.attachments]\nallowed_mime_types = [\"image/\"]"), os.ModePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -173,6 +173,26 @@ type (
|
|||
|
||||
ReplyIndicator string `toml:"reply_indicator"`
|
||||
ForwardedIndicator string `toml:"forwarded_indicator"`
|
||||
|
||||
Attachments AttachmentsUIConfig `toml:"attachments"`
|
||||
Avatars AvatarsUIConfig `toml:"avatars"`
|
||||
}
|
||||
|
||||
AvatarsUIConfig struct {
|
||||
Preview bool `toml:"preview"`
|
||||
// The number of rows an avatar takes. It is recommended to use `1` with compact.
|
||||
Height uint8 `toml:"height"`
|
||||
}
|
||||
|
||||
AttachmentsUIConfig struct {
|
||||
ShowLinks bool `toml:"show_links"`
|
||||
// Attachment MIME types that can be opened or downloaded without confirmation.
|
||||
// Exact types and wildcards such as "image/*" are supported.
|
||||
AllowedMIMETypes MIMETypes `toml:"allowed_mime_types"`
|
||||
// Whether to draw image attachments and embedded images below their messages.
|
||||
Preview bool `toml:"preview"`
|
||||
// How previews are drawn: "auto", "kitty" or "halfblocks".
|
||||
Protocol string `toml:"protocol"`
|
||||
}
|
||||
|
||||
MentionsListUIConfig struct {
|
||||
|
|
@ -186,6 +206,7 @@ type (
|
|||
func defaultUI() UIConfig {
|
||||
left := TitleUIConfig{AlignmentWrapper{tview.AlignmentLeft}}
|
||||
round := BorderSetWrapper{tview.BorderSetRound()}
|
||||
attachments := AttachmentsUIConfig{ShowLinks: true, AllowedMIMETypes: MIMETypes{"image/*", "video/*"}, Preview: true, Protocol: "auto"}
|
||||
return UIConfig{
|
||||
Tabs: TabsUIConfig{
|
||||
Alignment: AlignmentWrapper{tview.AlignmentCenter},
|
||||
|
|
@ -200,7 +221,7 @@ func defaultUI() UIConfig {
|
|||
GuildsTree: GuildsTreeUIConfig{TreeUIConfig{Graphics: true}, true},
|
||||
MembersTree: TreeUIConfig{Graphics: true},
|
||||
ScrollBar: ScrollBarUIConfig{SymbolSet: SymbolSetWrapper{boxDrawing}, Visibility: ScrollBarVisibilityWrapper{viewport.ScrollBarVisibilityAutomatic}},
|
||||
MessagesList: MessagesListUIConfig{Gap: 1, ReplyIndicator: ">", ForwardedIndicator: "<"},
|
||||
MessagesList: MessagesListUIConfig{Gap: 1, ReplyIndicator: ">", ForwardedIndicator: "<", Attachments: attachments, Avatars: AvatarsUIConfig{Preview: true, Height: 2}},
|
||||
MentionsList: MentionsListUIConfig{MinWidth: 20},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,12 +93,6 @@ func (m *Model) SetChannel(channel *discord.Channel) {
|
|||
m.disabled = !isDM && !m.state.HasPermissions(channel.ID, discord.PermissionSendMessages)
|
||||
}
|
||||
|
||||
// Height returns the number of rows the composer takes, including its border.
|
||||
func (m *Model) Height() int {
|
||||
_, frame := m.frame()
|
||||
return min(strings.Count(m.editState.Value(), "\n")+1, max(m.cfg.Composer.MaxHeight, 1)) + frame
|
||||
}
|
||||
|
||||
func (m Model) Disabled() bool {
|
||||
return m.disabled
|
||||
}
|
||||
|
|
@ -148,8 +142,9 @@ func (m *Model) box(focused bool) box.Widget {
|
|||
Placeholder(m.placeholder()).
|
||||
Keybind(m.editAction).
|
||||
Focused(focused && !m.disabled).
|
||||
OnChange(func(a textarea.Change) tview.Msg { return editMsg(a) })
|
||||
return ui.Box(text, m.cfg, focused).Title(m.title()).Footer(m.footer())
|
||||
OnChange(func(a textarea.Change) tview.Msg { return editMsg(a) }).
|
||||
Height(layout.Shrink.Max(m.cfg.Composer.MaxHeight))
|
||||
return ui.Box(text, m.cfg, focused).Title(m.title()).Footer(m.footer()).Height(layout.Shrink)
|
||||
}
|
||||
|
||||
// title returns what the composer is doing: editing, replying, or nothing.
|
||||
|
|
@ -202,13 +197,6 @@ func (m *Model) pasteText() {
|
|||
m.insert(string(data))
|
||||
}
|
||||
|
||||
// frame returns the cells the composer's border and padding take on each side.
|
||||
func (m *Model) frame() (left, vertical int) {
|
||||
const size = 100
|
||||
inner := m.box(false).InnerArea(tview.Rectangle{Width: size, Height: size})
|
||||
return inner.X, size - inner.Height
|
||||
}
|
||||
|
||||
func (m *Model) reset() {
|
||||
m.editing = nil
|
||||
m.sendMessageData = &api.SendMessageData{}
|
||||
|
|
@ -487,7 +475,7 @@ func (m Model) OnGuildMemberRemove(event *gateway.GuildMemberRemoveEvent) {
|
|||
}
|
||||
}
|
||||
|
||||
// MentionsListView places the mentions list just above the composer, near the cursor, or returns nil if it is empty or the composer is not focused.
|
||||
// MentionsListView places the mentions list at the bottom of the area above the composer, near the cursor, or returns nil if it is empty or the composer is not focused.
|
||||
func (m Model) MentionsListView(focused bool) tview.Widget {
|
||||
if !focused || !m.mentionsListVisible() {
|
||||
return nil
|
||||
|
|
@ -495,7 +483,7 @@ func (m Model) MentionsListView(focused bool) tview.Widget {
|
|||
return mentionsPopup{m}
|
||||
}
|
||||
|
||||
// mentionsPopup lays out the mentions list within the area above the composer, where it is drawn over the messages.
|
||||
// mentionsPopup places the mentions list within the area of the messages, which it is drawn over.
|
||||
type mentionsPopup struct {
|
||||
c Model
|
||||
}
|
||||
|
|
@ -516,29 +504,14 @@ func (p mentionsPopup) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg {
|
|||
return p.c.mentionsList.View().Handle(msg, p.area(area))
|
||||
}
|
||||
|
||||
// area returns where the list goes in area, the messages and composer together.
|
||||
// area returns where the list goes in area: at its bottom, at the column of the cursor where it fits.
|
||||
func (p mentionsPopup) area(area tview.Rectangle) tview.Rectangle {
|
||||
c := p.c
|
||||
borders := 0
|
||||
if c.cfg.UI.Border.Enabled {
|
||||
borders = 1
|
||||
}
|
||||
x, _ := c.frame()
|
||||
maxW, maxH := area.Width, area.Height-c.Height()
|
||||
if t := int(c.cfg.UI.MentionsList.MaxHeight); t != 0 {
|
||||
maxH = min(maxH, t)
|
||||
}
|
||||
h := min(c.mentionsList.ItemCount()+borders, maxH) + borders + c.cfg.UI.Border.Padding[1]
|
||||
w := int(c.cfg.UI.MentionsList.MinWidth)
|
||||
if w == 0 {
|
||||
w = maxW
|
||||
} else {
|
||||
w = min(max(w, c.mentionsList.MaxDisplayWidth())+borders*2, maxW)
|
||||
value, cursor := c.editState.Value(), c.editState.Cursor()
|
||||
col := uniseg.StringWidth(value[strings.LastIndex(value[:cursor], "\n")+1 : cursor])
|
||||
x += min(col, maxW-w)
|
||||
}
|
||||
return tview.Rectangle{X: area.X + x, Y: area.Y + area.Height - c.Height() - h, Width: w, Height: h}
|
||||
size := c.mentionsList.View().Layout(layout.Limits{Max: layout.Size{Width: area.Width, Height: area.Height}})
|
||||
value, cursor := c.editState.Value(), c.editState.Cursor()
|
||||
col := uniseg.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 {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,13 @@ func NewModel(cfg *config.Config) Model {
|
|||
func (m Model) View() tview.Widget {
|
||||
set := m.cfg.UI.Border.NormalSet.BorderSet
|
||||
set.BottomLeft, set.BottomRight = set.BottomT, set.BottomT
|
||||
return ui.Box(m.listView(), m.cfg, false).Title("Mentions").BorderSet(set)
|
||||
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("Mentions").BorderSet(set).Width(width).Height(layout.Shrink)
|
||||
}
|
||||
|
||||
func (Model) Init() tview.Cmd { return nil }
|
||||
|
|
@ -80,7 +86,7 @@ func (m Model) SelectedInsertText() (string, bool) {
|
|||
return m.items[index].InsertText, true
|
||||
}
|
||||
|
||||
func (m Model) MaxDisplayWidth() int {
|
||||
func (m Model) maxDisplayWidth() int {
|
||||
width := 0
|
||||
for _, item := range m.items {
|
||||
width = max(width, uniseg.StringWidth(item.DisplayText))
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import (
|
|||
"github.com/ayn2op/discordo/internal/voice"
|
||||
"github.com/ayn2op/ningen/v3"
|
||||
"github.com/ayn2op/tview"
|
||||
"github.com/ayn2op/tview/column"
|
||||
tviewimage "github.com/ayn2op/tview/image"
|
||||
"github.com/ayn2op/tview/keybind"
|
||||
"github.com/ayn2op/tview/list"
|
||||
|
|
@ -44,7 +45,8 @@ import (
|
|||
|
||||
type messageItem struct {
|
||||
message discord.Message
|
||||
view tview.Widget
|
||||
// text and reply are the message and what it replies to as wrapped, or nil until they are shown.
|
||||
text, reply *messageView
|
||||
// A separator shows the date of the message after it.
|
||||
separator bool
|
||||
|
||||
|
|
@ -63,6 +65,7 @@ type Model struct {
|
|||
items []messageItem
|
||||
|
||||
renderer *markdown.Renderer
|
||||
avatars map[discord.URL]tviewimage.Widget
|
||||
// voicePlayer plays the voice message played last, and voiceStatus is what it last said of it.
|
||||
voicePlayer *voice.Player
|
||||
voiceStatus voice.PlayerStatus
|
||||
|
|
@ -81,6 +84,7 @@ func NewModel(cfg *config.Config, state *ningen.State) Model {
|
|||
cfg: cfg,
|
||||
state: state,
|
||||
renderer: markdown.NewRenderer(cfg),
|
||||
avatars: map[discord.URL]tviewimage.Widget{},
|
||||
voicePlayer: voice.NewPlayer(),
|
||||
}
|
||||
|
||||
|
|
@ -182,6 +186,7 @@ func (m Model) indexOf(channelID discord.ChannelID, id discord.MessageID) int {
|
|||
|
||||
func (m *Model) reset() {
|
||||
m.items = nil
|
||||
clear(m.avatars)
|
||||
m.selectionState = list.NewSelectionState()
|
||||
m.scrollState = viewport.ScrollState{}
|
||||
m.scrollState.ScrollToEnd()
|
||||
|
|
@ -247,31 +252,45 @@ func (m Model) buildItem(index int) tview.Widget {
|
|||
return dateSeparator{date: date, fill: m.cfg.DateSeparator.Character, style: m.cfg.Theme.MessagesList.MessageStyle.Style.Dim(true)}
|
||||
}
|
||||
|
||||
if item.view == nil {
|
||||
message := item.message
|
||||
view := &messageView{render: func(width int) richtext.Text {
|
||||
return m.renderMessage(message, m.cfg.Theme.MessagesList.MessageStyle.Style, width)
|
||||
message, style := item.message, m.cfg.Theme.MessagesList.MessageStyle.Style
|
||||
if item.text == nil {
|
||||
item.text = &messageView{render: func(width int) richtext.Text { return m.renderMessage(message, style, width) }}
|
||||
item.reply = &messageView{render: func(int) richtext.Text {
|
||||
builder := new(richtext.Builder)
|
||||
m.drawReply(builder, message, style)
|
||||
return builder.Finish()
|
||||
}}
|
||||
if isVoiceMessage(message) {
|
||||
view.player = m.voiceMessageView(message.Attachments[0])
|
||||
}
|
||||
|
||||
for _, s := range previewSources(message) {
|
||||
if preview, ok := item.previews[s.proxy]; ok {
|
||||
view.images = append(view.images, preview)
|
||||
}
|
||||
}
|
||||
item.view = view
|
||||
}
|
||||
return item.view
|
||||
|
||||
body := column.New(item.text).Height(layout.Shrink)
|
||||
if isVoiceMessage(message) {
|
||||
body = body.Push(m.voiceMessageView(message.Attachments[0]))
|
||||
}
|
||||
|
||||
for _, s := range previewSources(message) {
|
||||
if preview, ok := item.previews[s.proxy]; ok {
|
||||
body = body.Push(preview)
|
||||
}
|
||||
}
|
||||
|
||||
if avatar, ok := m.avatars[avatarURL(message.Author)]; ok {
|
||||
body = row.New(avatar, body).Spacing(1).Height(layout.Shrink)
|
||||
}
|
||||
|
||||
if message.Type == discord.InlinedReplyMessage && !m.blocked(message.Author) {
|
||||
body = column.New(item.reply, body).Height(layout.Shrink)
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
// messageView renders a message followed by its voice message player, if it is one, and its image previews, caching the wrapped lines until the width changes.
|
||||
func (m Model) blocked(user discord.User) bool {
|
||||
return m.cfg.HideBlockedUsers && m.state.UserIsBlocked(user.ID)
|
||||
}
|
||||
|
||||
// messageView is the text of a message. It keeps the wrapped lines until the width changes, as the list measures every message when it draws.
|
||||
type messageView struct {
|
||||
render func(width int) richtext.Text
|
||||
// player is a row high, or nil.
|
||||
player tview.Widget
|
||||
images []tviewimage.Widget
|
||||
render func(width int) richtext.Text
|
||||
width int
|
||||
rendered bool
|
||||
lines richtext.Text
|
||||
|
|
@ -287,49 +306,20 @@ func (v *messageView) at(width int) textview.Widget {
|
|||
return textview.New(v.lines).Wrap(false)
|
||||
}
|
||||
|
||||
// Size returns Fill and Shrink, as the message is as tall as its content.
|
||||
func (v *messageView) Size() (width, height layout.Length) { return layout.Fill, layout.Shrink }
|
||||
|
||||
// Layout returns the size of the message within limits, as tall as the rows it takes at the width of limits.
|
||||
func (v *messageView) Layout(limits layout.Limits) layout.Size {
|
||||
return layout.Sized(limits, layout.Fill, layout.Shrink, func(limits layout.Limits) layout.Size {
|
||||
v.at(limits.Max.Width)
|
||||
rows := len(v.lines)
|
||||
if v.player != nil {
|
||||
rows++
|
||||
}
|
||||
|
||||
for _, preview := range v.images {
|
||||
_, height := preview.Size()
|
||||
rows += height.Cells()
|
||||
}
|
||||
return layout.Size{Height: rows}
|
||||
return layout.Size{Height: len(v.lines)}
|
||||
})
|
||||
}
|
||||
|
||||
func (v *messageView) Draw(screen tview.Screen, area tview.Rectangle) {
|
||||
v.at(area.Width).Draw(screen, tview.Rectangle{X: area.X, Y: area.Y, Width: area.Width, Height: len(v.lines)})
|
||||
y := area.Y + len(v.lines)
|
||||
if v.player != nil {
|
||||
v.player.Draw(screen, tview.Rectangle{X: area.X, Y: y, Width: area.Width, Height: 1})
|
||||
y++
|
||||
}
|
||||
|
||||
for _, preview := range v.images {
|
||||
_, height := preview.Size()
|
||||
preview.Draw(screen, tview.Rectangle{X: area.X, Y: y, Width: area.Width, Height: height.Cells()})
|
||||
y += height.Cells()
|
||||
}
|
||||
v.at(area.Width).Draw(screen, area)
|
||||
}
|
||||
|
||||
// Handle passes msg to the player, for its waveform to be clicked.
|
||||
func (v *messageView) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg {
|
||||
if v.player == nil {
|
||||
return msg
|
||||
}
|
||||
|
||||
return v.player.Handle(msg, tview.Rectangle{X: area.X, Y: area.Y + len(v.lines), Width: area.Width, Height: 1})
|
||||
}
|
||||
func (v *messageView) Handle(msg tview.Msg, _ tview.Rectangle) tview.Msg { return msg }
|
||||
|
||||
// dateSeparator is a line with the date in the middle, filled to the width it is drawn at.
|
||||
type dateSeparator struct {
|
||||
|
|
@ -413,12 +403,9 @@ func (m *Model) onRowCursorChanged(index int) {
|
|||
}
|
||||
|
||||
func (m Model) writeMessage(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, width int) {
|
||||
if m.cfg.HideBlockedUsers {
|
||||
isBlocked := m.state.UserIsBlocked(message.Author.ID)
|
||||
if isBlocked {
|
||||
builder.Write("Blocked message", baseStyle.Foreground(color.Red).Bold(true))
|
||||
return
|
||||
}
|
||||
if m.blocked(message.Author) {
|
||||
builder.Write("Blocked message", baseStyle.Foreground(color.Red).Bold(true))
|
||||
return
|
||||
}
|
||||
|
||||
switch message.Type {
|
||||
|
|
@ -431,7 +418,7 @@ func (m Model) writeMessage(builder *richtext.Builder, message discord.Message,
|
|||
case discord.GuildMemberJoinMessage:
|
||||
m.drawLayout(builder, message, baseStyle, func() { builder.Write("joined the server.", baseStyle) })
|
||||
case discord.InlinedReplyMessage:
|
||||
m.drawReplyMessage(builder, message, baseStyle, width)
|
||||
m.drawDefaultMessage(builder, message, baseStyle, width)
|
||||
case discord.ChannelPinnedMessage:
|
||||
m.drawPinnedMessage(builder, message, baseStyle)
|
||||
default:
|
||||
|
|
@ -631,7 +618,7 @@ func (m Model) drawDefaultMessage(builder *richtext.Builder, message discord.Mes
|
|||
|
||||
for _, a := range message.Attachments {
|
||||
builder.NewLine()
|
||||
if m.cfg.Attachments.ShowLinks {
|
||||
if m.cfg.UI.MessagesList.Attachments.ShowLinks {
|
||||
builder.Write(a.Filename+":", attachmentStyle)
|
||||
builder.NewLine()
|
||||
builder.Write(a.URL, attachmentStyle.Url(a.URL))
|
||||
|
|
@ -704,7 +691,7 @@ func (m Model) voiceMessageView(a discord.Attachment) tview.Widget {
|
|||
return rail[cell], style.Dim(true)
|
||||
})
|
||||
|
||||
return row.New(text.New(symbol), waveform, text.New(label)).Spacing(1)
|
||||
return row.New(text.New(symbol), waveform, text.New(label)).Spacing(1).Height(layout.Fixed(1))
|
||||
}
|
||||
|
||||
func (m Model) drawEmbeds(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, width int, contentRoot ast.Node, contentSource []byte) {
|
||||
|
|
@ -938,7 +925,7 @@ func (m Model) drawForwardedMessage(builder *richtext.Builder, message discord.M
|
|||
})
|
||||
}
|
||||
|
||||
func (m Model) drawReplyMessage(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, width int) {
|
||||
func (m Model) drawReply(builder *richtext.Builder, message discord.Message, baseStyle tview.Style) {
|
||||
dimStyle := baseStyle.Dim(true)
|
||||
builder.Write(m.cfg.UI.MessagesList.ReplyIndicator+" ", dimStyle)
|
||||
|
||||
|
|
@ -950,9 +937,6 @@ func (m Model) drawReplyMessage(builder *richtext.Builder, message discord.Messa
|
|||
} else {
|
||||
builder.Write("Original message was deleted", dimStyle)
|
||||
}
|
||||
|
||||
builder.NewLine()
|
||||
m.drawDefaultMessage(builder, message, baseStyle, width)
|
||||
}
|
||||
|
||||
func (m Model) drawPinnedMessage(builder *richtext.Builder, message discord.Message, baseStyle tview.Style) {
|
||||
|
|
@ -1035,30 +1019,29 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
|||
}
|
||||
return m, m.loadPreviews()
|
||||
case previewLoadedMsg:
|
||||
index := slices.IndexFunc(m.items, func(item messageItem) bool { return !item.separator && item.message.ID == msg.messageID })
|
||||
if index < 0 || m.items[index].previews == nil {
|
||||
return m, nil
|
||||
}
|
||||
preview, cmd := msg.preview, tview.Cmd(nil)
|
||||
if m.kitty {
|
||||
m.kittyImages++
|
||||
preview = preview.Kitty(m.kittyImages%63488 + 1)
|
||||
cmd = preview.Transmit()
|
||||
}
|
||||
|
||||
if _, ok := m.avatars[msg.proxy]; ok {
|
||||
m.avatars[msg.proxy] = preview
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
index := slices.IndexFunc(m.items, func(item messageItem) bool { return !item.separator && item.message.ID == msg.messageID })
|
||||
if index < 0 || m.items[index].previews == nil {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
m.items[index].previews[msg.proxy] = preview
|
||||
// Invalidate the view so it is laid out with the preview.
|
||||
m.items[index].view = nil
|
||||
return m, cmd
|
||||
case seekMsg:
|
||||
return m, m.seek(time.Duration(msg))
|
||||
case voiceMsg:
|
||||
m.voiceStatus = m.voicePlayer.Status()
|
||||
for i := range m.items {
|
||||
if isVoiceMessage(m.items[i].message) {
|
||||
m.items[i].view = nil
|
||||
}
|
||||
}
|
||||
|
||||
return m, m.Init()
|
||||
case tview.ResizeMsg:
|
||||
cols, rows := msg.Size()
|
||||
|
|
@ -1067,7 +1050,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
|||
m.cellWidth, m.cellHeight = uint(width/cols), uint(height/rows)
|
||||
}
|
||||
case tview.TerminalInfoMsg:
|
||||
switch m.cfg.Attachments.Protocol {
|
||||
switch m.cfg.UI.MessagesList.Attachments.Protocol {
|
||||
case "kitty":
|
||||
m.kitty = true
|
||||
case "halfblocks":
|
||||
|
|
@ -1267,6 +1250,6 @@ func (m Model) canDeleteMessage(message discord.Message) bool {
|
|||
|
||||
func (m Model) InvalidateRendered() {
|
||||
for i := range m.items {
|
||||
m.items[i].view = nil
|
||||
m.items[i].text = nil
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
"github.com/ayn2op/discordo/internal/config"
|
||||
"github.com/ayn2op/ningen/v3"
|
||||
"github.com/ayn2op/tview"
|
||||
"github.com/ayn2op/tview/layout"
|
||||
)
|
||||
|
||||
func testConfig(t *testing.T) *config.Config {
|
||||
|
|
@ -40,9 +41,15 @@ func TestModelBuildItem(t *testing.T) {
|
|||
if ml.items[ml.messageIndex(-1, 1)].message.ID != 2 {
|
||||
t.Fatal("messages are not oldest first")
|
||||
}
|
||||
view := ml.buildItem(ml.messageIndex(len(ml.items), -1))
|
||||
if ml.buildItem(ml.messageIndex(len(ml.items), -1)) != view {
|
||||
t.Fatal("message view was not reused")
|
||||
// text returns the wrapped text kept for the newest message once it is shown.
|
||||
text := func() *messageView {
|
||||
index := ml.messageIndex(len(ml.items), -1)
|
||||
ml.buildItem(index)
|
||||
return ml.items[index].text
|
||||
}
|
||||
view := text()
|
||||
if text() != view {
|
||||
t.Fatal("message text was not reused")
|
||||
}
|
||||
if ml.selectTop() == nil || ml.selectTop() != nil || ml.selectUp() != nil {
|
||||
t.Fatal("older messages were not fetched once on reaching the top")
|
||||
|
|
@ -54,21 +61,21 @@ func TestModelBuildItem(t *testing.T) {
|
|||
ml.selectBottom()
|
||||
*ml, _ = ml.Update(olderMessagesLoadedMsg{ChannelID: 1, Older: []discord.Message{{ID: 1}}})
|
||||
selected, ok := ml.selectedMessage()
|
||||
if !ok || selected.ID != 3 || ml.buildItem(ml.messageIndex(len(ml.items), -1)) != view {
|
||||
t.Fatal("prepend lost selection or cached view")
|
||||
if !ok || selected.ID != 3 || text() != view {
|
||||
t.Fatal("prepend lost selection or cached text")
|
||||
}
|
||||
ml.deleteMessage(ml.messageIndex(-1, 1))
|
||||
if ml.buildItem(ml.messageIndex(len(ml.items), -1)) != view {
|
||||
t.Fatal("deletion discarded another message's view")
|
||||
if text() != view {
|
||||
t.Fatal("deletion discarded another message's text")
|
||||
}
|
||||
ml.setMessage(ml.messageIndex(len(ml.items), -1), discord.Message{ID: 3, Content: "edited"})
|
||||
edited := ml.buildItem(ml.messageIndex(len(ml.items), -1))
|
||||
edited := text()
|
||||
if edited == view {
|
||||
t.Fatal("edit reused a stale view")
|
||||
t.Fatal("edit reused stale text")
|
||||
}
|
||||
ml.InvalidateRendered()
|
||||
if ml.buildItem(ml.messageIndex(len(ml.items), -1)) == edited {
|
||||
t.Fatal("global invalidation reused a stale view")
|
||||
if text() == edited {
|
||||
t.Fatal("global invalidation reused stale text")
|
||||
}
|
||||
ml.reset()
|
||||
if len(ml.items) != 0 {
|
||||
|
|
@ -224,3 +231,18 @@ func TestModelDrawLayout(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageViewLayout(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
cfg.UI.MessagesList.Compact = true
|
||||
ml := newTestModel(t, cfg)
|
||||
limits := layout.Limits{Max: layout.Size{Width: 80}, Infinite: layout.Axes{Height: true}}
|
||||
|
||||
// The avatar is 2 rows, below the row of a reply.
|
||||
for want, messageType := range map[int]discord.MessageType{2: discord.DefaultMessage, 3: discord.InlinedReplyMessage} {
|
||||
ml.SetChannel(ml.channel, []discord.Message{{ID: 1, Type: messageType, Content: "hi"}})
|
||||
if got := ml.buildItem(ml.messageIndex(-1, 1)).Layout(limits).Height; got != want {
|
||||
t.Fatalf("height = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -272,7 +272,7 @@ func (m Model) openAttachment(attachment discord.Attachment) tview.Cmd {
|
|||
}
|
||||
|
||||
func (m Model) confirmAttachment(attachment discord.Attachment, action tview.Cmd) tview.Cmd {
|
||||
if !m.cfg.Attachments.AllowedMIMETypes.Has(attachment.ContentType) {
|
||||
if !m.cfg.UI.MessagesList.Attachments.AllowedMIMETypes.Has(attachment.ContentType) {
|
||||
return ui.ShowDialog(
|
||||
"This attachment type is not allowed and may be unsafe. Continue anyway?",
|
||||
ui.DialogButton{Label: "No"},
|
||||
|
|
|
|||
|
|
@ -73,13 +73,19 @@ func (m Model) previewCell() (width, height uint) {
|
|||
}
|
||||
|
||||
func (m Model) loadPreviews() tview.Cmd {
|
||||
if !m.cfg.Attachments.Preview {
|
||||
return nil
|
||||
}
|
||||
cellWidth, cellHeight := m.previewCell()
|
||||
var cmds []tview.Cmd
|
||||
for i := range m.items {
|
||||
item := &m.items[i]
|
||||
if item.separator {
|
||||
continue
|
||||
}
|
||||
|
||||
cmds = append(cmds, m.loadAvatar(item.message.Author))
|
||||
if !m.cfg.UI.MessagesList.Attachments.Preview {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, s := range previewSources(item.message) {
|
||||
if _, ok := item.previews[s.proxy]; ok {
|
||||
continue
|
||||
|
|
@ -101,6 +107,26 @@ func (m Model) loadPreviews() tview.Cmd {
|
|||
return tview.Batch(cmds...)
|
||||
}
|
||||
|
||||
func avatarURL(user discord.User) discord.URL {
|
||||
return user.AvatarURLWithType(discord.PNGImage) + "?size=128"
|
||||
}
|
||||
|
||||
func (m Model) loadAvatar(user discord.User) tview.Cmd {
|
||||
url, cfg := avatarURL(user), m.cfg.UI.MessagesList.Avatars
|
||||
if _, ok := m.avatars[url]; ok || !cfg.Preview || cfg.Height == 0 || m.blocked(user) {
|
||||
return nil
|
||||
}
|
||||
|
||||
cellWidth, cellHeight := m.previewCell()
|
||||
cols := uint(cfg.Height) * cellHeight / cellWidth
|
||||
bounds := image.Rect(0, 0, int(cols*cellWidth), int(cols*cellWidth))
|
||||
widget := func(img image.Image) tviewimage.Widget {
|
||||
return tviewimage.New(img).Width(int(cols)).CellSize(int(cellWidth), int(cellHeight))
|
||||
}
|
||||
m.avatars[url] = widget(image.NewAlpha(bounds))
|
||||
return m.loadPreview(0, url, bounds, widget)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return func() tview.Msg {
|
||||
|
|
|
|||
|
|
@ -427,14 +427,13 @@ func (m Model) canFocus(p pane) bool {
|
|||
func (m Model) View() tview.Widget {
|
||||
picker := m.pickerView()
|
||||
|
||||
var right tview.Widget = column.New(
|
||||
m.paneView(paneMessagesList),
|
||||
column.New(m.paneView(paneComposer)).Height(layout.Fixed(m.composer.Height())),
|
||||
)
|
||||
messages := m.paneView(paneMessagesList)
|
||||
if mentions := m.composer.MentionsListView(m.focused == paneComposer); mentions != nil {
|
||||
right = stack.New(right, mentions)
|
||||
messages = stack.New(messages, mentions)
|
||||
}
|
||||
|
||||
var right tview.Widget = column.New(messages, m.paneView(paneComposer))
|
||||
|
||||
main := right
|
||||
if m.membersTreeShown() {
|
||||
width := m.cfg.MembersTree.WidthPercent
|
||||
|
|
|
|||
|
|
@ -70,6 +70,8 @@ func TestModelAddMessageOrNotify(t *testing.T) {
|
|||
t.Run(tt.name, func(t *testing.T) {
|
||||
m := newTestModel(t)
|
||||
m.cfg.Notifications.WhenUnfocused = tt.whenUnfocused
|
||||
// Loading an avatar is a command too.
|
||||
m.cfg.UI.MessagesList.Avatars.Preview = false
|
||||
m.setSelectedChannel(&discord.Channel{ID: 1})
|
||||
*m, _ = m.Update(tcell.NewEventFocus(tt.focused))
|
||||
cmd := m.addMessageOrNotify(&gateway.MessageCreateEvent{Message: discord.Message{ID: 1, ChannelID: 1}})
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"github.com/ayn2op/discordo/internal/ui/login/qr"
|
||||
"github.com/ayn2op/discordo/internal/ui/login/token"
|
||||
"github.com/ayn2op/tview"
|
||||
"github.com/ayn2op/tview/column"
|
||||
)
|
||||
|
||||
// tab is a login method shown as a tab.
|
||||
|
|
@ -62,10 +63,9 @@ func (m Model) View() tview.Widget {
|
|||
}
|
||||
t := m.tabs().
|
||||
Active(int(m.active)).
|
||||
Content(content).
|
||||
Keybind(tabAction).
|
||||
OnSelect(func(i int) tview.Msg { return selectTabMsg(i) })
|
||||
return ui.Box(t, m.cfg, false)
|
||||
return ui.Box(column.New(t, content), m.cfg, false)
|
||||
}
|
||||
|
||||
// tabs returns the login methods as tabs set up by the tabs config.
|
||||
|
|
|
|||
|
|
@ -78,8 +78,8 @@ func (m Model) View() tview.Widget {
|
|||
Focused(m.focus == focusButton).
|
||||
OnClick(submitMsg{})
|
||||
return column.New(
|
||||
row.New(column.New(text.New("Login")).Width(labelWidth), login).Height(layout.Fixed(1)),
|
||||
row.New(column.New(text.New("Password")).Width(labelWidth), password).Height(layout.Fixed(1)),
|
||||
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),
|
||||
submit,
|
||||
).Spacing(1)
|
||||
).Spacing(1).Align(tview.AlignmentCenter)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ func (m Model) View() tview.Widget {
|
|||
code := m.code()
|
||||
return center.New(
|
||||
viewport.New(textview.New(code).Wrap(false).Alignment(tview.AlignmentCenter), m.scrollState).
|
||||
Height(layout.Fixed(len(code))).
|
||||
Height(layout.Shrink).
|
||||
Focused(true).
|
||||
OnChange(func(a viewport.Change) tview.Msg { return scrollMsg(a) }),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ func (m Model) View() tview.Widget {
|
|||
Focused(m.buttonFocused).
|
||||
OnClick(submitMsg{})
|
||||
return column.New(
|
||||
row.New(column.New(text.New("Token")).Width(layout.Fixed(len("Token")+1)), input).Height(layout.Fixed(1)),
|
||||
row.New(column.New(text.New("Token")).Width(layout.Fixed(len("Token")+1)), input).Height(layout.Shrink),
|
||||
submit,
|
||||
).Spacing(1)
|
||||
).Spacing(1).Align(tview.AlignmentCenter)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -144,11 +144,6 @@ func (m *Model) updateScreen(msg tview.Msg) tview.Cmd {
|
|||
return cmd
|
||||
}
|
||||
|
||||
// helpHeight returns the rows the help takes at an unlimited width, as the width of the screen is not known in View.
|
||||
func (m Model) helpHeight() int {
|
||||
return m.helpView().Layout(layout.Limits{Infinite: layout.Axes{Height: true}}).Height
|
||||
}
|
||||
|
||||
// View shows the inner model above help, with the dialog on top of both when one is open.
|
||||
func (m Model) View() tview.Widget {
|
||||
var innerView, helpView tview.Widget
|
||||
|
|
@ -159,8 +154,7 @@ func (m Model) View() tview.Widget {
|
|||
innerView = m.chat.View()
|
||||
}
|
||||
if m.helpVisible {
|
||||
padded := box.New(m.helpView()).Padding(0, 0, m.cfg.Help.Padding[0], m.cfg.Help.Padding[1])
|
||||
helpView = column.New(padded).Height(layout.Fixed(max(m.helpHeight(), 1)))
|
||||
helpView = box.New(m.helpView()).Padding(0, 0, m.cfg.Help.Padding[0], m.cfg.Help.Padding[1]).Height(layout.Shrink)
|
||||
}
|
||||
content := column.New(innerView, helpView)
|
||||
if m.dialog == nil {
|
||||
|
|
|
|||
|
|
@ -21,10 +21,6 @@ func TestNewModel(t *testing.T) {
|
|||
if !m.helpVisible {
|
||||
t.Fatal("help is hidden")
|
||||
}
|
||||
if height := m.helpHeight(); height != 1 {
|
||||
t.Fatalf("height = %d, want 1", height)
|
||||
}
|
||||
|
||||
m, _ = m.Update(tcell.NewEventKey(tcell.KeyRune, ".", tcell.ModCtrl))
|
||||
if !m.helpShowAll {
|
||||
t.Fatal("toggle_full_help did not enable full help")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue