refactor(ui): migrate to declarative tview

This commit is contained in:
ayn2op 2026-09-27 16:48:03 -04:00
parent 08b41176c0
commit 30810afde2
49 changed files with 2031 additions and 1529 deletions

View file

@ -62,8 +62,10 @@ func Run() error {
screen.EnableMouse()
}
screen.EnablePaste()
if cfg.Notifications.WhenUnfocused {
screen.EnableFocus()
}
tview.Styles = tview.Theme{}
model := root.NewModel(cfg)
return tview.NewApplication(model, tview.WithScreen(screen)).Run()
}

2
go.mod
View file

@ -15,7 +15,7 @@ require (
github.com/andybalholm/brotli v1.2.4
github.com/ayn2op/arikawa/v3 v3.0.0-20260904042054-7df757872273
github.com/ayn2op/ningen/v3 v3.0.1-0.20260904045623-5978c9be899f
github.com/ayn2op/tview v0.0.0-20260924220355-e85f797abc96
github.com/ayn2op/tview v0.0.0-20260927211607-7f36ae65f25b
github.com/bogdanfinn/fhttp v0.6.9
github.com/bogdanfinn/tls-client v1.16.0
github.com/gdamore/tcell/v3 v3.5.0

4
go.sum
View file

@ -14,8 +14,8 @@ github.com/ayn2op/arikawa/v3 v3.0.0-20260904042054-7df757872273 h1:cJ1donaon2Cjh
github.com/ayn2op/arikawa/v3 v3.0.0-20260904042054-7df757872273/go.mod h1:bzMgZqBnJr1pu7EHq2d9906sAtHA+iklIxxvnS5kfDo=
github.com/ayn2op/ningen/v3 v3.0.1-0.20260904045623-5978c9be899f h1:RrbCgoBOmYsLJAaf4lES+eoJYOG7zQMykACIFZgEvdU=
github.com/ayn2op/ningen/v3 v3.0.1-0.20260904045623-5978c9be899f/go.mod h1:j8CBNjCkOpsgQm6X2xYJ0kXu5Y/PiQ+6QxxmExCq1Fs=
github.com/ayn2op/tview v0.0.0-20260924220355-e85f797abc96 h1:QzXy33BUMRjBRu+/SKV7k7Lk4FI07ZXC5krHNKrWP2U=
github.com/ayn2op/tview v0.0.0-20260924220355-e85f797abc96/go.mod h1:oRPTSZgaEJPhv3dmmNvo6grBq/BemrLG3wFQLMTZJkY=
github.com/ayn2op/tview v0.0.0-20260927211607-7f36ae65f25b h1:UD8mwrLSZ7ms33rMJwcRIL+QUOW6udDMYKjjMIlbu6k=
github.com/ayn2op/tview v0.0.0-20260927211607-7f36ae65f25b/go.mod h1:oRPTSZgaEJPhv3dmmNvo6grBq/BemrLG3wFQLMTZJkY=
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=

View file

@ -69,7 +69,8 @@ type (
}
Notifications struct {
Enabled bool `toml:"enabled"`
Enabled bool `toml:"enabled"`
WhenUnfocused bool `toml:"when_unfocused"`
}
TypingIndicator struct {

View file

@ -63,6 +63,8 @@ character = "─"
[notifications]
enabled = true
# Whether to also notify about messages in the open channel while the terminal window is unfocused. Requires a terminal that reports focus.
when_unfocused = true
[typing_indicator]
# Whether to send typing status or not.

View file

@ -12,29 +12,33 @@ type Keybind struct {
var _ toml.Unmarshaler = (*Keybind)(nil)
func (k *Keybind) UnmarshalTOML(value any) error {
var keys []string
switch value := value.(type) {
case string:
k.SetKeys(value)
keys = []string{value}
case []any:
keys := make([]string, 0, len(value))
for _, key := range value {
if key, ok := key.(string); ok {
keys = append(keys, key)
}
}
k.SetKeys(keys...)
default:
return nil
}
kb := keybind.New(keys...)
// Keep displayed help key aligned with configured key(s).
if keys := k.Keys(); len(keys) > 0 {
k.SetHelp(keys[0], k.Help().Desc)
help := k.Help()
if keys := kb.Keys(); len(keys) > 0 {
help.Key = keys[0]
}
k.Keybind = kb.WithHelp(help.Key, help.Desc)
return nil
}
// desc builds a Keybind with only a help description; keys come from config.toml.
func desc(s string) Keybind {
return Keybind{
Keybind: keybind.NewKeybind(keybind.WithHelp("", s)),
Keybind: keybind.New().WithHelp("", s),
}
}

View file

@ -6,6 +6,7 @@ import (
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/list"
"github.com/ayn2op/tview/scrollbar"
"github.com/gdamore/tcell/v3"
)
@ -135,7 +136,7 @@ func (bw *BorderSetWrapper) UnmarshalTOML(val any) error {
return nil
}
type GlyphSetWrapper struct{ tview.GlyphSet }
type GlyphSetWrapper struct{ scrollbar.GlyphSet }
func (gw *GlyphSetWrapper) UnmarshalTOML(val any) error {
s, ok := val.(string)
@ -144,11 +145,11 @@ func (gw *GlyphSetWrapper) UnmarshalTOML(val any) error {
}
switch s {
case "minimal":
gw.GlyphSet = tview.MinimalGlyphSet()
gw.GlyphSet = scrollbar.MinimalGlyphSet()
case "box_drawing", "boxdrawing", "box":
gw.GlyphSet = tview.BoxDrawingGlyphSet()
gw.GlyphSet = scrollbar.BoxDrawingGlyphSet()
case "unicode":
gw.GlyphSet = tview.UnicodeGlyphSet()
gw.GlyphSet = scrollbar.UnicodeGlyphSet()
default:
return fmt.Errorf("unknown value: %q", s)
}

View file

@ -9,7 +9,8 @@ import (
const Name = "discordo"
var cacheDir = sync.OnceValue(func() string {
// CacheDir returns the cache directory, creating it on first use.
var CacheDir = sync.OnceValue(func() string {
userCacheDir, err := os.UserCacheDir()
if err != nil {
userCacheDir = os.TempDir()
@ -23,7 +24,3 @@ var cacheDir = sync.OnceValue(func() string {
return path
})
func CacheDir() string {
return cacheDir()
}

View file

@ -11,7 +11,7 @@ import (
)
func NewClient(token string) *api.Client {
stdClient := http.Client{Transport: NewTransport()}
stdClient := http.Client{Transport: newTransport()}
httpClient := httputil.NewClientWithDriver(httpdriver.WrapClient(stdClient))
client := api.NewCustomClient(token, httpClient)
client.UserAgent = BrowserUserAgent()

View file

@ -23,14 +23,14 @@ func Headers() stdHttp.Header {
headers.Set("Sec-Fetch-Mode", "cors")
headers.Set("Sec-Fetch-Site", "same-origin")
headers.Set("Sec-Ch-Ua", BrowserBrands())
headers.Set("Sec-Ch-Ua", browserBrands())
headers.Set("Sec-Ch-Ua-Mobile", "?0")
headers.Set("Sec-Ch-Ua-Platform", `"`+OS+`"`)
headers.Set("Sec-Ch-Ua-Platform", `"`+osName+`"`)
headers.Set("X-Debug-Options", "bugReporterEnabled")
headers.Set("X-Discord-Locale", string(Locale))
headers.Set("X-Discord-Locale", string(locale))
superProps, err := json.Marshal(XSuperProperties())
superProps, err := json.Marshal(xSuperProperties())
if err != nil {
slog.Error("failed to marshal super props", "err", err)
} else {

View file

@ -11,26 +11,26 @@ import (
//go:generate go run generator.go
const (
OS = "Windows"
OSVersion = "10"
Locale = discord.EnglishUS
osName = "Windows"
osVersion = "10"
locale = discord.EnglishUS
)
func BrowserUserAgent() string {
return "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " + tls.BrowserName() + "/" + tls.BrowserVersion() + " Safari/537.36"
}
func BrowserBrands() string {
func browserBrands() string {
version := tls.BrowserMajorVersion()
return `"Chromium";v="` + version + `", "Not-A.Brand";v="24", "Google ` + tls.BrowserName() + `";v="` + version + `"`
}
func CommonProperties() gateway.IdentifyProperties {
func commonProperties() gateway.IdentifyProperties {
return gateway.IdentifyProperties{
gateway.IdentifyDevice: "",
gateway.IdentifyOS: OS,
"os_version": OSVersion,
gateway.IdentifyOS: osName,
"os_version": osVersion,
gateway.IdentifyBrowser: tls.BrowserName(),
"browser_version": tls.BrowserVersion(),
@ -40,7 +40,7 @@ func CommonProperties() gateway.IdentifyProperties {
"client_event_source": nil,
"client_launch_id": uuid.NewV4().String(),
"system_locale": Locale,
"system_locale": locale,
"release_channel": "stable",
"has_client_mods": false,
@ -52,13 +52,13 @@ func CommonProperties() gateway.IdentifyProperties {
}
func IdentifyProperties() gateway.IdentifyProperties {
props := CommonProperties()
props := commonProperties()
props["is_fast_connect"] = true
return props
}
func XSuperProperties() gateway.IdentifyProperties {
props := CommonProperties()
func xSuperProperties() gateway.IdentifyProperties {
props := commonProperties()
props["client_app_state"] = "focused"
props["client_heartbeat_session_id"] = uuid.NewV4().String()
props["launch_signature"] = generateLaunchSignature()

View file

@ -10,7 +10,7 @@ import (
)
func TestCommonProperties(t *testing.T) {
properties := CommonProperties()
properties := commonProperties()
tests := []struct {
name string
@ -59,7 +59,7 @@ func TestHeaders(t *testing.T) {
}
func TestXSuperProperties(t *testing.T) {
properties := XSuperProperties()
properties := xSuperProperties()
for _, key := range []gateway.IdentifyPropertyKey{"client_launch_id", "client_heartbeat_session_id", "launch_signature"} {
t.Run(string(key), func(t *testing.T) {
value, ok := properties[key].(string)

View file

@ -8,17 +8,17 @@ import (
"github.com/klauspost/compress/gzhttp"
)
type Transport struct {
type transport struct {
base http.RoundTripper
}
func NewTransport() *Transport {
return &Transport{
func newTransport() *transport {
return &transport{
base: gzhttp.Transport(http.DefaultTransport, gzhttp.TransportAlwaysDecompress(true)),
}
}
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) {
resp, err := t.base.RoundTrip(req)
if err != nil {
return nil, err

View file

@ -10,7 +10,7 @@ import (
md "github.com/ayn2op/arikawa/v3/markdown"
"github.com/ayn2op/discordo/internal/config"
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/text"
"github.com/ayn2op/tview/richtext"
"github.com/gdamore/tcell/v3"
"github.com/yuin/goldmark/ast"
)
@ -28,11 +28,11 @@ func NewRenderer(cfg *config.Config) *Renderer {
return &Renderer{cfg: cfg}
}
func (r *Renderer) RenderText(source []byte, node ast.Node, base tcell.Style) text.Text {
func (r *Renderer) RenderText(source []byte, node ast.Node, base tcell.Style) richtext.Text {
r.listIx = nil
r.listNested = 0
builder := new(text.Builder)
builder := new(richtext.Builder)
styleStack := []tcell.Style{base}
linkDepth := 0
@ -145,7 +145,7 @@ func (r *Renderer) RenderText(source []byte, node ast.Node, base tcell.Style) te
return builder.Finish()
}
func (r *Renderer) renderFencedCodeBlock(builder *text.Builder, source []byte, node *ast.FencedCodeBlock, base tcell.Style) {
func (r *Renderer) renderFencedCodeBlock(builder *richtext.Builder, source []byte, node *ast.FencedCodeBlock, base tcell.Style) {
var code strings.Builder
lines := node.Lines()
for i := range lines.Len() {
@ -224,14 +224,7 @@ func applyChromaStyle(base tcell.Style, entry chroma.StyleEntry) tcell.Style {
int32(entry.Colour.Blue()),
))
}
// Intentionally do not apply token background colors so code blocks keep the user's terminal/chat background.
// if entry.Background.IsSet() {
// style = style.Background(tcell.NewRGBColor(
// int32(entry.Background.Red()),
// int32(entry.Background.Green()),
// int32(entry.Background.Blue()),
// ))
// }
// Token background colors are not applied, so code blocks keep the user's terminal/chat background.
switch entry.Bold {
case chroma.Yes:
style = style.Bold(true)
@ -284,8 +277,8 @@ func applyInlineAttr(style tcell.Style, attr md.Attribute, inLink bool) tcell.St
style = style.StrikeThrough(true)
}
if attr&md.AttrMonospace != 0 {
// Avoid reverse-video inside links. Link labels like `hash` should still
// look like links, not highlighted blocks.
// Avoid reverse-video inside links.
// Link labels like `hash` should still look like links, not highlighted blocks.
if !inLink {
style = style.Reverse(true)
}

View file

@ -12,12 +12,36 @@ type Item struct {
Action tview.Cmd
}
type Model struct{ *picker.Model }
type Model struct {
items picker.Items
searchState picker.SearchState
cfg *config.Config
}
func NewModel(cfg *config.Config) *Model {
m := &Model{Model: picker.NewModel()}
ui.ConfigurePicker(m.Model, cfg, "Attachments")
return m
return &Model{searchState: picker.NewSearchState(), cfg: cfg}
}
var _ tview.Model = (*Model)(nil)
// actionMsg changes the picker.
type actionMsg picker.Action
func (*Model) Init() tview.Cmd { return nil }
// View shows the picker in a box titled Attachments.
func (m *Model) View() tview.Element {
p := ui.Picker(m.items, &m.searchState, m.cfg).
OnAction(func(a picker.Action) tview.Msg { return actionMsg(a) }).
OnSelect(func(item picker.Item) tview.Msg {
action, ok := item.Reference.(tview.Cmd)
if !ok {
return nil
}
return SelectedMsg{Action: action}
}).
OnCancel(CancelMsg{})
return ui.Box(p, &m.cfg.Theme, true).Title("Attachments")
}
func (m *Model) SetItems(items []Item) {
@ -25,20 +49,13 @@ func (m *Model) SetItems(items []Item) {
for i, item := range items {
pickerItems[i] = picker.Item{Text: item.Label, Reference: item.Action}
}
m.Model.SetItems(pickerItems)
m.items = pickerItems
m.searchState.Reset()
}
func (m *Model) Update(msg tview.Msg) tview.Cmd {
switch msg := msg.(type) {
case picker.SelectedMsg:
action, ok := msg.Reference.(tview.Cmd)
if !ok {
return nil
}
return func() tview.Msg { return SelectedMsg{Action: action} }
case picker.CancelMsg:
return func() tview.Msg { return CancelMsg{} }
if msg, ok := msg.(actionMsg); ok {
m.searchState.Perform(picker.Action(msg))
}
return m.Model.Update(msg)
return nil
}

View file

@ -13,35 +13,47 @@ import (
)
type Model struct {
*picker.Model
cfg *config.Config
items picker.Items
searchState picker.SearchState
cfg *config.Config
}
func NewModel(cfg *config.Config) *Model {
p := picker.NewModel()
ui.ConfigurePicker(p, cfg, "Channels")
return &Model{
Model: p,
cfg: cfg,
}
return &Model{searchState: picker.NewSearchState(), cfg: cfg}
}
var _ tview.Model = (*Model)(nil)
func (m *Model) Update(msg tview.Msg) tview.Cmd {
switch msg := msg.(type) {
case picker.SelectedMsg:
channelID, ok := msg.Reference.(discord.ChannelID)
if !ok || !channelID.IsValid() {
return nil
}
return func() tview.Msg { return SelectedMsg{ChannelID: channelID} }
case picker.CancelMsg:
return func() tview.Msg { return CancelMsg{} }
}
return m.Model.Update(msg)
// actionMsg changes the picker.
type actionMsg picker.Action
func (*Model) Init() tview.Cmd { return nil }
// View shows the picker in a box titled Channels.
func (m *Model) View() tview.Element {
p := ui.Picker(m.items, &m.searchState, m.cfg).
OnAction(func(a picker.Action) tview.Msg { return actionMsg(a) }).
OnSelect(func(item picker.Item) tview.Msg {
channelID, ok := item.Reference.(discord.ChannelID)
if !ok || !channelID.IsValid() {
return nil
}
return SelectedMsg{ChannelID: channelID}
}).
OnCancel(CancelMsg{})
return ui.Box(p, &m.cfg.Theme, true).Title("Channels")
}
func (m *Model) Update(msg tview.Msg) tview.Cmd {
if msg, ok := msg.(actionMsg); ok {
m.searchState.Perform(picker.Action(msg))
}
return nil
}
// Reset clears the query.
func (m *Model) Reset() { m.searchState.Reset() }
func (m *Model) RefreshChannels(state *ningen.State) {
var items picker.Items
@ -74,7 +86,8 @@ func (m *Model) RefreshChannels(state *ningen.State) {
}
}
m.SetItems(items)
m.items = items
m.searchState.Reset()
}
func (m *Model) channelItem(state *ningen.State, guild *discord.Guild, channel discord.Channel) picker.Item {

View file

@ -0,0 +1,144 @@
package composer
import (
"maps"
"path/filepath"
"testing"
"github.com/ayn2op/arikawa/v3/discord"
"github.com/ayn2op/arikawa/v3/gateway"
"github.com/ayn2op/arikawa/v3/state"
"github.com/ayn2op/arikawa/v3/utils/sendpart"
"github.com/ayn2op/discordo/internal/config"
"github.com/ayn2op/ningen/v3"
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/keybind"
"github.com/gdamore/tcell/v3"
)
// newTestModel returns an enabled composer with the default config.
func newTestModel(t *testing.T) (*Model, *config.Config) {
t.Helper()
cfg, err := config.Load(filepath.Join(t.TempDir(), "config.toml"))
if err != nil {
t.Fatal(err)
}
c := NewModel(cfg, ningen.FromState(state.New("")))
c.disabled = false
return c, cfg
}
func TestModelUpdate(t *testing.T) {
up := tcell.NewEventKey(tcell.KeyUp, "", tcell.ModNone)
for _, tt := range []struct {
name string
setup func(*Model, *config.Config) tview.KeyMsg
want bool
}{
{"empty", func(*Model, *config.Config) tview.KeyMsg { return up }, true},
{"draft", func(c *Model, _ *config.Config) tview.KeyMsg { c.setText("draft"); return up }, false},
{"reply", func(c *Model, _ *config.Config) tview.KeyMsg {
c.StartReply(discord.Message{ID: 3}, "name", false)
return up
}, false},
{"attachment", func(c *Model, _ *config.Config) tview.KeyMsg {
c.sendMessageData.Files = []sendpart.File{{Name: "file"}}
return up
}, false},
{"editing", func(c *Model, _ *config.Config) tview.KeyMsg { c.StartEdit(discord.Message{}); return up }, false},
{"disabled", func(c *Model, _ *config.Config) tview.KeyMsg { c.disabled = true; return up }, false},
{"remapped", func(_ *Model, cfg *config.Config) tview.KeyMsg {
cfg.Keybinds.Composer.EditLast.Keybind = keybind.New("ctrl+p")
return tcell.NewEventKey(tcell.KeyCtrlP, "", tcell.ModNone)
}, true},
{"unbound", func(_ *Model, cfg *config.Config) tview.KeyMsg {
cfg.Keybinds.Composer.EditLast.Keybind = keybind.New()
return up
}, false},
} {
t.Run("edit last message: "+tt.name, func(t *testing.T) {
c, cfg := newTestModel(t)
cmd := c.Update(tt.setup(c, cfg))
if got := cmd != nil && cmd() == (EditLastMsg{}); got != tt.want {
t.Fatalf("asked to edit the last message = %v, want %v", got, tt.want)
}
})
}
}
func TestModelHandle(t *testing.T) {
c, _ := newTestModel(t)
area := tview.Rectangle{Width: 20, Height: 3}
t.Run("typing edits the text", func(t *testing.T) {
msg := c.View(true).Handle(tcell.NewEventKey(tcell.KeyRune, "a", tcell.ModNone), area)
if _, ok := msg.(editMsg); !ok {
t.Fatalf("got %v, want an editMsg", msg)
}
c.Update(msg)
if c.editState.Value() != "a" {
t.Fatalf("text = %q", c.editState.Value())
}
})
t.Run("the newline keybind inserts a newline", func(t *testing.T) {
msg := c.View(true).Handle(tcell.NewEventKey(tcell.KeyEnter, "", tcell.ModShift), area)
if _, ok := msg.(editMsg); !ok {
t.Fatalf("got %v, want an editMsg", msg)
}
c.Update(msg)
if c.editState.Value() != "a\n" {
t.Fatalf("text = %q", c.editState.Value())
}
})
t.Run("bound keys are left to Update", func(t *testing.T) {
enter := tcell.NewEventKey(tcell.KeyEnter, "", tcell.ModNone)
if got := c.View(true).Handle(enter, area); got != enter {
t.Fatalf("got %v", got)
}
})
}
func TestModelSearchMember(t *testing.T) {
c, _ := newTestModel(t)
c.state.MemberState.SearchLimit = 2
c.CacheMemberSearch(&gateway.GuildMembersChunkEvent{
Nonce: memberSearchNonce + "1 ab",
Members: []discord.Member{{}},
})
if cmd := c.searchMember(1, "ab"); cmd != nil {
t.Fatal("cached query returned a network command")
}
if cmd := c.searchMember(1, "abc"); cmd != nil || c.memberSearchCache["1 abc"] != 1 {
t.Fatal("incomplete prefix results were not reused")
}
c.memberSearchCache["1 a"] = 2
c.memberSearchCache["2 a"] = 2
c.OnGuildMemberRemove(&gateway.GuildMemberRemoveEvent{
GuildID: 1,
User: discord.User{Username: "abc"},
})
want := map[string]uint{"1 ab": 1, "1 abc": 1, "2 a": 2}
if !maps.Equal(c.memberSearchCache, want) {
t.Fatalf("cache after invalidation = %v, want %v", c.memberSearchCache, want)
}
}
func TestModelTabSuggest(t *testing.T) {
c, _ := newTestModel(t)
if err := c.state.Cabinet.MyselfSet(discord.User{ID: 1, Username: "1"}, false); err != nil {
t.Fatal(err)
}
channel := discord.Channel{ID: 1, Type: discord.DirectMessage}
for id, author := range []discord.UserID{2, 3, 2, 1} {
message := discord.Message{ID: discord.MessageID(id + 1), ChannelID: 1, Author: discord.User{ID: author, Username: author.String()}}
if err := c.state.Cabinet.MessageSet(&message, false); err != nil {
t.Fatal(err)
}
}
c.SetChannel(&channel)
c.setText("@")
c.tabSuggest()
if got := c.mentionsList.ItemCount(); got != 2 {
t.Fatalf("suggested %d authors, want 2", got)
}
}

View file

@ -1,111 +0,0 @@
package chat
import (
"maps"
"path/filepath"
"slices"
"testing"
"github.com/ayn2op/arikawa/v3/discord"
"github.com/ayn2op/arikawa/v3/gateway"
"github.com/ayn2op/arikawa/v3/utils/sendpart"
"github.com/ayn2op/discordo/internal/config"
"github.com/gdamore/tcell/v3"
)
func TestComposerEditLastMessage(t *testing.T) {
for _, scenario := range []string{"latest", "old", "draft", "multiline", "reply", "attachment", "editing", "disabled", "no own message", "remapped", "unbound"} {
t.Run(scenario, func(t *testing.T) {
cfg, err := config.Load(filepath.Join(t.TempDir(), "config.toml"))
if err != nil {
t.Fatal(err)
}
m := NewModel(cfg, "")
if err := m.state.Cabinet.MyselfSet(discord.User{ID: 1}, false); err != nil {
t.Fatal(err)
}
m.SetSelectedChannel(&discord.Channel{ID: 1})
c := m.composer
c.SetDisabled(false)
messages := []discord.Message{
{ID: 1, ChannelID: 1, Author: discord.User{ID: 1}, Content: "old message"},
{ID: 2, ChannelID: 1, Author: discord.User{ID: 1}, Content: "latest message"},
{ID: 3, ChannelID: 1, Author: discord.User{ID: 2}, Content: "someone else"},
{ID: 4, ChannelID: 1, Author: discord.User{ID: 1}, Type: discord.ChannelPinnedMessage, Content: "system"},
{ID: 5, ChannelID: 1, Author: discord.User{ID: 1}},
}
want := ""
key := tcell.NewEventKey(tcell.KeyUp, "", tcell.ModNone)
switch scenario {
case "latest":
want = "latest message"
case "old":
messages = messages[:1]
want = "old message"
case "draft":
c.SetText("draft", true)
case "multiline":
c.SetText("first line\nsecond line", true)
case "reply":
c.sendMessageData.Reference = &discord.MessageReference{MessageID: 3}
case "attachment":
c.sendMessageData.Files = []sendpart.File{{Name: "file"}}
case "editing":
c.edit = true
case "disabled":
c.SetDisabled(true)
case "no own message":
messages = messages[2:3]
case "remapped":
cfg.Keybinds.Composer.EditLast.SetKeys("ctrl+p")
key = tcell.NewEventKey(tcell.KeyCtrlP, "", tcell.ModNone)
want = "latest message"
case "unbound":
cfg.Keybinds.Composer.EditLast.SetKeys()
}
slices.Reverse(messages)
m.messagesList.setMessages(messages)
before := c.Text()
wasEditing := c.edit
c.Update(key)
if want != "" {
selected, ok := m.messagesList.selectedMessage()
if !c.edit || c.Text() != want || !ok || selected.Content != want {
t.Fatalf("edit = %v, text = %q, selected = %v", c.edit, c.Text(), selected)
}
} else if c.Text() != before || c.edit != wasEditing {
t.Fatalf("composer changed: edit = %v, text = %q", c.edit, c.Text())
}
})
}
}
func TestComposerSearchMember(t *testing.T) {
cfg, err := config.Load(filepath.Join(t.TempDir(), "config.toml"))
if err != nil {
t.Fatal(err)
}
m := NewModel(cfg, "")
m.state.MemberState.SearchLimit = 2
c := m.composer
c.onGuildMembersChunk(&gateway.GuildMembersChunkEvent{
Nonce: memberSearchNonce + "1 ab",
Members: []discord.Member{{}},
})
if cmd := c.searchMember(1, "ab"); cmd != nil {
t.Fatal("cached query returned a network command")
}
if cmd := c.searchMember(1, "abc"); cmd != nil || c.memberSearchCache["1 abc"] != 1 {
t.Fatal("incomplete prefix results were not reused")
}
c.memberSearchCache["1 a"] = 2
c.memberSearchCache["2 a"] = 2
m.onGuildMemberRemove(&gateway.GuildMemberRemoveEvent{
GuildID: 1,
User: discord.User{Username: "abc"},
})
want := map[string]uint{"1 ab": 1, "1 abc": 1, "2 a": 2}
if !maps.Equal(c.memberSearchCache, want) {
t.Fatalf("cache after invalidation = %v, want %v", c.memberSearchCache, want)
}
}

View file

@ -20,46 +20,22 @@ import (
type dmNode struct{}
type Model struct {
*tree.Model
root *tree.Node
selectionState tree.SelectionState
cfg *config.Config
state *ningen.State
// Fast-path indexes for frequent event handlers (read updates, picker
// navigation). They mirror the current rendered tree and are rebuilt on
// READY before nodes are added.
// Fast-path indexes for frequent event handlers (read updates, picker navigation).
// They mirror the current rendered tree and are rebuilt on READY before nodes are added.
guildNodeByID map[discord.GuildID]*tree.Node
channelNodeByID map[discord.ChannelID]*tree.Node
dmRootNode *tree.Node
}
func NewModel(cfg *config.Config, state *ningen.State) *Model {
t := tree.NewModel()
ui.ConfigureBox(t.Box, &cfg.Theme)
t.
SetRoot(tree.NewNode("")).
SetTopLevel(1).
SetMarkers(tree.Markers{
Expanded: cfg.Sidebar.Markers.Expanded,
Collapsed: cfg.Sidebar.Markers.Collapsed,
Leaf: cfg.Sidebar.Markers.Leaf,
}).
SetGraphics(cfg.Theme.GuildsTree.Graphics).
SetGraphicsColor(tcell.GetColor(cfg.Theme.GuildsTree.GraphicsColor)).
SetTitle("Guilds")
kbs := cfg.Keybinds.GuildsTree
t.SetKeybinds(tree.Keybinds{
Up: kbs.SelectUp.Keybind,
Down: kbs.SelectDown.Keybind,
Top: kbs.SelectTop.Keybind,
Bottom: kbs.SelectBottom.Keybind,
MoveToParent: kbs.MoveToParentNode.Keybind,
Select: kbs.SelectCurrent.Keybind,
})
return &Model{
Model: t,
root: tree.NewNode(""),
cfg: cfg,
state: state,
@ -69,6 +45,45 @@ func NewModel(cfg *config.Config, state *ningen.State) *Model {
}
}
// CurrentNode returns the selected node, or nil for none.
func (m *Model) CurrentNode() *tree.Node {
return m.selectionState.CurrentNode()
}
// Msg moves or scrolls the tree.
type Msg tree.Action
// View shows the tree in a box titled Guilds.
func (m *Model) View(focused bool) tview.Element {
theme := &m.cfg.Theme
set := theme.Border.NormalSet.BorderSet
if focused {
set = theme.Border.ActiveSet.BorderSet
}
kbs := m.cfg.Keybinds.GuildsTree
t := tree.New(m.root, &m.selectionState).
TopLevel(1).
Markers(tree.Markers{
Expanded: m.cfg.Sidebar.Markers.Expanded,
Collapsed: m.cfg.Sidebar.Markers.Collapsed,
Leaf: m.cfg.Sidebar.Markers.Leaf,
}).
Graphics(theme.GuildsTree.Graphics).
GraphicsSet(set).
GraphicsStyle(tcell.StyleDefault.Foreground(tcell.GetColor(theme.GuildsTree.GraphicsColor))).
Keybinds(tree.Keybinds{
Up: kbs.SelectUp.Keybind,
Down: kbs.SelectDown.Keybind,
Top: kbs.SelectTop.Keybind,
Bottom: kbs.SelectBottom.Keybind,
MoveToParent: kbs.MoveToParentNode.Keybind,
Select: kbs.SelectCurrent.Keybind,
}).
Focused(focused).
OnAction(func(a tree.Action) tview.Msg { return Msg(a) })
return ui.Box(t, theme, focused).Title("Guilds")
}
func (m *Model) reset() *tree.Node {
// Keep allocated map capacity; READY can rebuild often during reconnects.
clear(m.guildNodeByID)
@ -77,7 +92,7 @@ func (m *Model) reset() *tree.Node {
SetReference(dmNode{}).
SetExpandable(true).
SetExpanded(false)
return m.Root().ClearChildren().AddChild(m.dmRootNode)
return m.root.ClearChildren().AddChild(m.dmRootNode)
}
func (m *Model) rebuild(event *gateway.ReadyEvent) {
@ -116,7 +131,7 @@ func (m *Model) rebuild(event *gateway.ReadyEvent) {
m.createFolderNode(folder, guildsByID)
}
}
m.SetCurrentNode(root)
m.selectionState.SetCurrentNode(root)
}
func (m *Model) refreshReadStyles(event *read.UpdateEvent) {
@ -176,7 +191,7 @@ func (m *Model) createFolderNode(folder gateway.GuildFolder, guildsByID map[disc
folderStyle := tcell.StyleDefault.Foreground(tcell.NewHexColor(int32(folder.Color)))
m.setNodeLineStyle(folderNode, folderStyle)
}
m.Root().AddChild(folderNode)
m.root.AddChild(folderNode)
for _, guildID := range folder.GuildIDs {
if guildEvent, ok := guildsByID[guildID]; ok {
@ -293,8 +308,7 @@ func (m *Model) createChannelNodes(node *tree.Node, channels []discord.Channel)
for _, channel := range channels {
if channel.ParentID.IsValid() {
// Parent categories are inserted earlier in this function, so this
// lookup is O(1) and avoids per-channel subtree walks.
// Parent categories are inserted earlier in this function, so this lookup is O(1) and avoids per-channel subtree walks.
parent := m.channelNodeByID[channel.ParentID]
if parent != nil {
m.createChannelNode(parent, channel)
@ -313,13 +327,13 @@ func isThread(t discord.ChannelType) bool {
}
func (m *Model) collapseParentNode(node *tree.Node) {
path := m.GetPath(node)
path := m.root.PathTo(node)
if len(path) < 3 {
return
}
parent := path[len(path)-2]
parent.Collapse()
m.SetCurrentNode(parent)
m.selectionState.SetCurrentNode(parent)
}
func (m *Model) Update(msg tview.Msg) tview.Cmd {
@ -341,23 +355,27 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd {
case NavigateMsg:
return m.navigate(msg.ChannelID)
case Msg:
m.selectionState.Perform(tree.Action(msg))
return nil
case tree.SelectedMsg:
m.selectionState.SetCurrentNode(msg.Node)
return m.selectNode(msg.Node)
case tview.KeyMsg:
switch {
case keybind.Matches(msg, m.cfg.Keybinds.GuildsTree.CollapseAll.Keybind):
for _, node := range m.Root().Children() {
for _, node := range m.root.Children() {
node.CollapseAll()
}
return nil
case keybind.Matches(msg, m.cfg.Keybinds.GuildsTree.CollapseParentNode.Keybind):
m.collapseParentNode(m.CurrentNode())
m.collapseParentNode(m.selectionState.CurrentNode())
return nil
case keybind.Matches(msg, m.cfg.Keybinds.GuildsTree.YankID.Keybind):
return yankID(m.CurrentNode())
return yankID(m.selectionState.CurrentNode())
}
}
return m.Model.Update(msg)
return nil
}
func (m *Model) findNodeByReference(reference any) *tree.Node {
@ -371,7 +389,7 @@ func (m *Model) findNodeByReference(reference any) *tree.Node {
default:
// Fallback keeps this helper safe for non-indexed custom references.
var found *tree.Node
m.Root().Walk(func(node, _ *tree.Node) bool {
m.root.Walk(func(node, _ *tree.Node) bool {
if node.Reference() == reference {
found = node
return false
@ -409,7 +427,7 @@ func (m *Model) expandPathToNode(node *tree.Node) {
if node == nil {
return
}
for _, n := range m.GetPath(node) {
for _, n := range m.root.PathTo(node) {
n.Expand()
}
}
@ -420,7 +438,7 @@ func (m *Model) selectCurrentKeybind() keybind.Keybind {
selectCurrent := m.cfg.Keybinds.GuildsTree.SelectCurrent.Keybind
selectHelp := selectCurrent.Help()
selectDesc := selectHelp.Desc
if node := m.CurrentNode(); node != nil {
if node := m.selectionState.CurrentNode(); node != nil {
if len(node.Children()) > 0 {
if node.Expanded() {
selectDesc = "collapse"
@ -434,14 +452,13 @@ func (m *Model) selectCurrentKeybind() keybind.Keybind {
}
}
}
selectCurrent.SetHelp(selectHelp.Key, selectDesc)
return selectCurrent
return selectCurrent.WithHelp(selectHelp.Key, selectDesc)
}
func (m *Model) ShortHelp() []keybind.Keybind {
cfg := m.cfg.Keybinds.GuildsTree
shortHelp := []keybind.Keybind{cfg.SelectUp.Keybind, cfg.SelectDown.Keybind, m.selectCurrentKeybind()}
if m.canCollapseParent(m.CurrentNode()) {
if m.canCollapseParent(m.selectionState.CurrentNode()) {
shortHelp = append(shortHelp, cfg.CollapseParentNode.Keybind)
}
return shortHelp
@ -463,7 +480,7 @@ func (m *Model) collapseKeybinds() []keybind.Keybind {
cfg := m.cfg.Keybinds.GuildsTree
var keybinds []keybind.Keybind
if m.canCollapseParent(m.CurrentNode()) {
if m.canCollapseParent(m.selectionState.CurrentNode()) {
keybinds = append(keybinds, cfg.CollapseParentNode.Keybind)
}
if m.canCollapseAll() {
@ -473,12 +490,12 @@ func (m *Model) collapseKeybinds() []keybind.Keybind {
}
func (m *Model) canCollapseParent(node *tree.Node) bool {
return node != nil && len(m.GetPath(node)) >= 3
return node != nil && len(m.root.PathTo(node)) >= 3
}
func (m *Model) canCollapseAll() bool {
var can bool
for _, node := range m.Root().Children() {
for _, node := range m.root.Children() {
if node.Expanded() {
can = true
break

View file

@ -32,7 +32,7 @@ func (m *Model) navigate(channelID discord.ChannelID) tview.Cmd {
return nil
}
m.expandPathToNode(node)
m.SetCurrentNode(node)
m.selectionState.SetCurrentNode(node)
if channel.Type == discord.GuildCategory {
return nil
}

View file

@ -1,6 +1,7 @@
package chat
import (
"github.com/ayn2op/discordo/internal/ui"
"github.com/ayn2op/tview/help"
"github.com/ayn2op/tview/keybind"
)
@ -26,14 +27,11 @@ func (m *Model) FullHelp() [][]keybind.Keybind {
}
func (m *Model) activeKeyMap() help.KeyMap {
if m.GetVisible(channelsPickerLayerName) {
return m.channelsPicker
}
if m.GetVisible(attachmentsPickerLayerName) {
return m.messagesList.attachmentsPicker
if m.picker() != nil {
return ui.PickerKeybinds(m.cfg)
}
switch m.focusedModel() {
switch m.focused {
case m.guildsTree:
return m.guildsTree
case m.messagesList:
@ -64,7 +62,7 @@ func (m *Model) baseFullHelp() [][]keybind.Keybind {
func (m *Model) focusHelp() []keybind.Keybind {
kbs := m.cfg.Keybinds
focused := m.focusedModel()
focused := m.focused
focusKbs := make([]keybind.Keybind, 0, 3)
if focused != m.guildsTree {

View file

@ -5,37 +5,58 @@ import (
"github.com/ayn2op/discordo/internal/ui"
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/list"
"github.com/ayn2op/tview/text"
"github.com/gdamore/tcell/v3"
"github.com/rivo/uniseg"
)
// Model shows the mention suggestions as a list in a box.
type Model struct {
*list.Model
items []Item
cfg *config.Config
selectionState list.SelectionState
items []Item
entries []list.Item
keybinds list.Keybinds
}
// Msg moves the list.
type Msg list.Action
func NewModel(cfg *config.Config) *Model {
l := list.NewModel()
ui.ConfigureBox(l.Box, &cfg.Theme)
l.
SetSelectedStyle(tcell.StyleDefault.Reverse(true)).
SetSnapToItems(true).
SetTitle("Mentions")
m := &Model{cfg: cfg, selectionState: list.NewSelectionState()}
kbs := cfg.Keybinds.MentionsList
l.SetKeybinds(list.Keybinds{
m.keybinds = list.Keybinds{
SelectUp: kbs.SelectUp.Keybind,
SelectDown: kbs.SelectDown.Keybind,
SelectTop: kbs.SelectTop.Keybind,
SelectBottom: kbs.SelectBottom.Keybind,
})
}
return m
}
b := l.BorderSet()
b.BottomLeft, b.BottomRight = b.BottomT, b.BottomT
l.SetBorderSet(b)
// View shows the list in a box whose bottom corners join the composer's border below it.
func (m *Model) View() tview.Element {
set := m.cfg.Theme.Border.NormalSet.BorderSet
set.BottomLeft, set.BottomRight = set.BottomT, set.BottomT
return ui.Box(m.listView(), &m.cfg.Theme, false).Title("Mentions").BorderSet(set)
}
return &Model{Model: l}
func (*Model) Init() tview.Cmd { return nil }
func (m *Model) listView() list.Widget {
return list.New(&m.selectionState, len(m.entries), func(i int) list.Item { return m.entries[i] }).
SelectedStyle(tcell.StyleDefault.Reverse(true)).
Keybinds(m.keybinds).
// The list is only shown while mentions are being completed, so it takes its keys first.
Focused(true).
OnAction(func(a list.Action) tview.Msg { return Msg(a) })
}
func (m *Model) Update(msg tview.Msg) tview.Cmd {
if msg, ok := msg.(Msg); ok {
m.selectionState.Perform(list.Action(msg))
}
return nil
}
func (m *Model) Append(item Item) {
@ -43,8 +64,8 @@ func (m *Model) Append(item Item) {
}
func (m *Model) Clear() {
m.items = nil
m.Model.Clear()
m.items, m.entries = nil, nil
m.selectionState.SetCursor(-1)
}
func (m *Model) ItemCount() int {
@ -52,7 +73,7 @@ func (m *Model) ItemCount() int {
}
func (m *Model) SelectedInsertText() (string, bool) {
index := m.Cursor()
index := m.selectionState.Cursor()
if index < 0 || index >= len(m.items) {
return "", false
}
@ -68,26 +89,28 @@ func (m *Model) MaxDisplayWidth() int {
}
func (m *Model) Rebuild() {
m.SetBuilder(func(index int) list.Item {
if index < 0 || index >= len(m.items) {
return nil
}
item := m.items[index]
style := item.Style
line := text.NewLine(text.NewSegment(item.DisplayText, style))
return tview.NewTextView().
SetScrollable(false).
SetWrap(false).
SetWordWrap(false).
SetTextStyle(style).
SetContent(text.Text{line})
})
if len(m.items) == 0 {
m.SetCursor(-1)
return
m.entries = make([]list.Item, len(m.items))
for i, item := range m.items {
m.entries[i] = row{text: item.DisplayText, style: item.Style}
}
m.SetCursor(0)
m.selectionState.SetCursor(min(0, len(m.items)-1))
}
var _ tview.Model = (*Model)(nil)
// row is a mention on one line in its style.
type row struct {
text string
style tcell.Style
}
func (row) Rows(int) int { return 1 }
func (r row) Draw(screen tview.Screen, area tview.Rectangle) {
for x := area.X; x < area.X+area.Width; x++ {
screen.Put(x, area.Y, " ", r.style)
}
tview.Print(screen, r.text, area.X, area.Y, area.Width, tview.AlignmentLeft, r.style)
}
func (row) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg { return msg }

View file

@ -1,4 +1,4 @@
package chat
package messageslist
import (
"encoding/json"
@ -7,17 +7,33 @@ import (
"testing"
"github.com/ayn2op/arikawa/v3/discord"
"github.com/ayn2op/arikawa/v3/state"
"github.com/ayn2op/discordo/internal/config"
"github.com/ayn2op/ningen/v3"
)
func TestMessagesListBuildItem(t *testing.T) {
func testConfig(t *testing.T) *config.Config {
t.Helper()
cfg, err := config.Load(filepath.Join(t.TempDir(), "config.toml"))
if err != nil {
t.Fatal(err)
}
m := NewModel(cfg, "")
m.SetSelectedChannel(&discord.Channel{ID: 1})
ml := m.messagesList
return cfg
}
// newTestModel returns a list showing channel 1, logged in as user 1.
func newTestModel(t *testing.T, cfg *config.Config) *Model {
t.Helper()
ml := NewModel(cfg, ningen.FromState(state.New("")))
if err := ml.state.Cabinet.MyselfSet(discord.User{ID: 1}, false); err != nil {
t.Fatal(err)
}
ml.channel = &discord.Channel{ID: 1}
return ml
}
func TestModelBuildItem(t *testing.T) {
ml := newTestModel(t, testConfig(t))
ml.setMessages([]discord.Message{{ID: 3}, {ID: 2}})
if ml.items[ml.messageIndex(-1, 1)].message.ID != 2 {
t.Fatal("messages are not oldest first")
@ -41,7 +57,7 @@ func TestMessagesListBuildItem(t *testing.T) {
if edited == view {
t.Fatal("edit reused a stale view")
}
ml.invalidateRenderedMessages()
ml.InvalidateRendered()
if ml.buildItem(ml.messageIndex(len(ml.items), -1)) == edited {
t.Fatal("global invalidation reused a stale view")
}
@ -51,7 +67,7 @@ func TestMessagesListBuildItem(t *testing.T) {
}
}
func TestMessagesListRebuildItems(t *testing.T) {
func TestModelRebuildItems(t *testing.T) {
for _, tt := range []struct {
name string
separators bool
@ -60,14 +76,9 @@ func TestMessagesListRebuildItems(t *testing.T) {
{name: "separators_enabled", separators: true},
} {
t.Run(tt.name, func(t *testing.T) {
cfg, err := config.Load(filepath.Join(t.TempDir(), "config.toml"))
if err != nil {
t.Fatal(err)
}
cfg := testConfig(t)
cfg.DateSeparator.Enabled = tt.separators
m := NewModel(cfg, "")
m.SetSelectedChannel(&discord.Channel{ID: 1})
ml := m.messagesList
ml := newTestModel(t, cfg)
var messages []discord.Message
if err := json.Unmarshal([]byte(`[
{"id":"3","timestamp":"2026-09-03T12:00:00Z"},
@ -118,17 +129,48 @@ func TestMessagesListRebuildItems(t *testing.T) {
assertItems(1, 2, 3)
assertSelected(1)
ml.selectDown()
ml.deleteMessage(ml.Cursor())
ml.deleteMessage(ml.cursor())
assertItems(1, 3)
assertSelected(1)
ml.deleteMessage(ml.Cursor())
ml.deleteMessage(ml.cursor())
assertItems(3)
assertSelected(3)
ml.deleteMessage(ml.Cursor())
ml.deleteMessage(ml.cursor())
assertItems()
if ml.Cursor() != -1 {
if ml.cursor() != -1 {
t.Fatal("empty list retained selection")
}
})
}
}
func TestModelSelectLastOwn(t *testing.T) {
messages := []discord.Message{
{ID: 1, ChannelID: 1, Author: discord.User{ID: 1}, Content: "old message"},
{ID: 2, ChannelID: 1, Author: discord.User{ID: 1}, Content: "latest message"},
{ID: 3, ChannelID: 1, Author: discord.User{ID: 2}, Content: "someone else"},
{ID: 4, ChannelID: 1, Author: discord.User{ID: 1}, Type: discord.ChannelPinnedMessage, Content: "system"},
{ID: 5, ChannelID: 1, Author: discord.User{ID: 1}},
}
for _, tt := range []struct {
name string
messages []discord.Message
want string
}{
{"latest", messages, "latest message"},
{"old", messages[:1], "old message"},
{"no own message", messages[2:3], ""},
} {
t.Run(tt.name, func(t *testing.T) {
ml := newTestModel(t, testConfig(t))
newest := slices.Clone(tt.messages)
slices.Reverse(newest)
ml.setMessages(newest)
message, _ := ml.SelectLastOwn()
selected, _ := ml.selectedMessage()
if message.Content != tt.want || tt.want != "" && selected.Content != tt.want {
t.Fatalf("returned %q, selected %v, want %q", message.Content, selected, tt.want)
}
})
}
}

View file

@ -0,0 +1,36 @@
package messageslist
import (
"github.com/ayn2op/arikawa/v3/discord"
"github.com/ayn2op/discordo/internal/ui/chat/attachmentspicker"
"github.com/ayn2op/tview"
)
// Msg is implemented by the messages the list sends itself, which must reach it whatever has the focus.
type Msg interface{ messagesList() }
type olderMessagesLoadedMsg struct {
ChannelID discord.ChannelID
Older []discord.Message
}
type deleteMessageMsg discord.Message
type attachmentActionMsg struct{ Action tview.Cmd }
func (olderMessagesLoadedMsg) messagesList() {}
func (deleteMessageMsg) messagesList() {}
func (attachmentActionMsg) messagesList() {}
// ReplyMsg asks to reply to Message by Name, mentioning its author if Mention is set.
type ReplyMsg struct {
Message discord.Message
Name string
Mention bool
}
// EditMsg asks to edit a message.
type EditMsg discord.Message
// ShowAttachmentsMsg asks to pick one of the attachments and links of the selected message.
type ShowAttachmentsMsg []attachmentspicker.Item

View file

@ -3,7 +3,6 @@ package chat
import (
"fmt"
"log/slog"
"sync"
"time"
"github.com/ayn2op/arikawa/v3/discord"
@ -13,67 +12,60 @@ import (
"github.com/ayn2op/arikawa/v3/state/store/defaultstore"
"github.com/ayn2op/arikawa/v3/utils/handler"
"github.com/ayn2op/arikawa/v3/utils/httputil"
"github.com/ayn2op/arikawa/v3/utils/ws"
"github.com/ayn2op/discordo/internal/config"
"github.com/ayn2op/discordo/internal/consts"
clientgateway "github.com/ayn2op/discordo/internal/gateway"
"github.com/ayn2op/discordo/internal/http"
"github.com/ayn2op/discordo/internal/ui"
"github.com/ayn2op/discordo/internal/ui/chat/attachmentspicker"
"github.com/ayn2op/discordo/internal/ui/chat/channelspicker"
"github.com/ayn2op/discordo/internal/ui/chat/composer"
"github.com/ayn2op/discordo/internal/ui/chat/guildstree"
"github.com/ayn2op/discordo/internal/ui/chat/mentionslist"
"github.com/ayn2op/discordo/internal/ui/chat/messageslist"
"github.com/ayn2op/ningen/v3"
"github.com/ayn2op/ningen/v3/states/read"
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/flex"
"github.com/ayn2op/tview/backdrop"
"github.com/ayn2op/tview/center"
"github.com/ayn2op/tview/column"
"github.com/ayn2op/tview/keybind"
"github.com/ayn2op/tview/layers"
"github.com/ayn2op/tview/text"
"github.com/gdamore/tcell/v3"
)
const typingDuration = 10 * time.Second
const (
flexLayerName = "flex"
mentionsListLayerName = "mentionsList"
channelsPickerLayerName = "channelsPicker"
attachmentsPickerLayerName = "attachmentsPicker"
"github.com/ayn2op/tview/opaque"
"github.com/ayn2op/tview/row"
"github.com/ayn2op/tview/stack"
)
type Model struct {
*layers.Layers
// guildsTree (sidebar) + rightFlex
mainFlex *flex.Model
// messagesList + composer
rightFlex *flex.Model
// focused is the pane that receives keys: guildsTree, messagesList, composer, or nil.
focused pane
// guildsTreeVisible reports whether the guilds tree is shown left of the messages.
guildsTreeVisible bool
// channelsPickerOpen and attachmentsPickerOpen report whether that picker is shown on top and takes all input.
channelsPickerOpen bool
attachmentsPickerOpen bool
guildsTree *guildstree.Model
messagesList *messagesList
composer *composer
messagesList *messageslist.Model
composer *composer.Model
channelsPicker *channelspicker.Model
// attachmentsPicker picks an attachment or link of the selected message.
attachmentsPicker *attachmentspicker.Model
selectedChannel *discord.Channel
selectedChannelMu sync.RWMutex
selectedChannel *discord.Channel
// windowUnfocused reports whether the terminal window lost focus.
windowUnfocused bool
state *ningen.State
events chan gateway.Event
typersMu sync.RWMutex
typers map[discord.UserID]*time.Timer
// typers maps the users typing in the selected channel to when their typing indicator expires.
typers map[discord.UserID]time.Time
cfg *config.Config
}
func NewModel(cfg *config.Config, token string) *Model {
m := &Model{
Layers: layers.New(),
mainFlex: flex.NewModel(),
rightFlex: flex.NewModel(),
typers: make(map[discord.UserID]*time.Timer),
typers: make(map[discord.UserID]time.Time),
cfg: cfg,
}
@ -105,95 +97,62 @@ func NewModel(cfg *config.Config, token string) *Model {
m.state.OnRequest = append(m.state.OnRequest, httputil.WithHeaders(http.Headers()), m.onRequest)
m.guildsTree = guildstree.NewModel(cfg, m.state)
m.messagesList = newMessagesList(cfg, m)
m.composer = newComposer(cfg, m)
m.messagesList = messageslist.NewModel(cfg, m.state)
m.composer = composer.NewModel(cfg, m.state)
m.channelsPicker = channelspicker.NewModel(cfg)
m.attachmentsPicker = attachmentspicker.NewModel(cfg)
m.SetBackgroundLayerStyle(m.cfg.Theme.Dialog.BackgroundStyle.Style)
m.buildLayout()
m.guildsTreeVisible = cfg.Sidebar.Visible
if m.guildsTreeVisible {
// The guilds tree is focused first at start-up when visible.
m.focused = m.guildsTree
} else {
m.focused = m.messagesList
}
return m
}
func (m *Model) SelectedChannel() (*discord.Channel, bool) {
m.selectedChannelMu.RLock()
defer m.selectedChannelMu.RUnlock()
return m.selectedChannel, m.selectedChannel != nil
}
func (m *Model) SetSelectedChannel(channel *discord.Channel) {
m.selectedChannelMu.Lock()
func (m *Model) setSelectedChannel(channel *discord.Channel) {
m.selectedChannel = channel
m.selectedChannelMu.Unlock()
}
func (m *Model) isMe(id discord.UserID) bool {
me, _ := m.state.Cabinet.Me()
return me != nil && id == me.ID
}
func (m *Model) isGuildsTreeVisible() bool {
return m.mainFlex.GetItemCount() == 2
}
func (m *Model) buildLayout() {
m.Clear()
m.rightFlex.Clear()
m.mainFlex.Clear()
m.rightFlex.
SetDirection(flex.DirectionRow).
AddItem(m.messagesList, 0, 1, false).
AddItem(m.composer, 3, 1, false)
if m.cfg.Sidebar.Visible {
// The guilds tree is focused first at start-up when visible.
m.mainFlex.
AddItem(m.guildsTree, 0, m.cfg.Sidebar.WidthPercent, true).
AddItem(m.rightFlex, 0, 100-m.cfg.Sidebar.WidthPercent, false)
} else {
m.mainFlex.AddItem(m.rightFlex, 0, 100, true)
m.rightFlex.SetFocus(0)
}
m.AddLayer(m.mainFlex, layers.WithName(flexLayerName), layers.WithResize(true), layers.WithVisible(true))
m.AddLayer(
m.composer.mentionsList,
layers.WithName(mentionsListLayerName),
layers.WithResize(false),
layers.WithVisible(false),
layers.WithEnabled(false),
)
m.composer.SetChannel(channel)
}
func (m *Model) togglePicker() tview.Cmd {
if m.HasLayer(channelsPickerLayerName) {
if m.channelsPickerOpen {
return m.closePicker()
}
return m.openPicker()
}
func (m *Model) openPicker() tview.Cmd {
m.AddLayer(
ui.Centered(m.channelsPicker, m.cfg.Picker.Width, m.cfg.Picker.Height),
layers.WithName(channelsPickerLayerName),
layers.WithResize(true),
layers.WithVisible(true),
layers.WithOverlay(),
).SendToFront(channelsPickerLayerName)
m.channelsPickerOpen = true
m.channelsPicker.RefreshChannels(m.state)
return nil
}
func (m *Model) closePicker() tview.Cmd {
m.RemoveLayer(channelsPickerLayerName)
m.channelsPicker.Refresh()
m.channelsPickerOpen = false
m.channelsPicker.Reset()
return nil
}
func (m *Model) closeAttachmentsPicker() tview.Cmd {
m.RemoveLayer(attachmentsPickerLayerName)
m.attachmentsPickerOpen = false
return nil
}
// picker returns the picker shown on top, or nil if none is open.
func (m *Model) picker() tview.Model {
switch {
case m.channelsPickerOpen:
return m.channelsPicker
case m.attachmentsPickerOpen:
return m.attachmentsPicker
default:
return nil
}
}
func (m *Model) navigateToChannel(channelID discord.ChannelID) tview.Cmd {
return tview.Sequence(
m.closePicker(),
@ -202,28 +161,21 @@ func (m *Model) navigateToChannel(channelID discord.ChannelID) tview.Cmd {
}
func (m *Model) toggleGuildsTree() tview.Cmd {
if m.isGuildsTreeVisible() {
if m.focusedModel() == m.guildsTree {
if m.guildsTreeVisible {
if m.focused == m.guildsTree {
m.setFocus(m.messagesList)
}
m.mainFlex.RemoveItem(m.guildsTree)
m.guildsTreeVisible = false
} else {
m.showGuildsTree()
m.guildsTreeVisible = true
m.setFocus(m.guildsTree)
}
return nil
}
func (m *Model) showGuildsTree() {
m.mainFlex.Clear()
m.mainFlex.
AddItem(m.guildsTree, 0, m.cfg.Sidebar.WidthPercent, true).
AddItem(m.rightFlex, 0, 100-m.cfg.Sidebar.WidthPercent, false)
}
func (m *Model) focusGuildsTree() bool {
m.setFocus(m.guildsTree)
return m.focusedModel() == m.guildsTree
return m.focused == m.guildsTree
}
func (m *Model) focusComposer() bool {
@ -235,7 +187,7 @@ func (m *Model) focusComposer() bool {
}
func (m *Model) focusPrevious() {
switch m.focusedModel() {
switch m.focused {
case m.guildsTree:
if m.focusComposer() {
return
@ -255,7 +207,7 @@ func (m *Model) focusPrevious() {
}
func (m *Model) focusNext() {
switch m.focusedModel() {
switch m.focused {
case m.guildsTree:
m.setFocus(m.messagesList)
case m.messagesList:
@ -280,46 +232,10 @@ func (m *Model) Init() tview.Cmd {
func (m *Model) Update(msg tview.Msg) tview.Cmd {
switch msg := msg.(type) {
case gateway.Event:
switch eventMsg := msg.(type) {
case *ws.RawEvent:
m.onRaw(eventMsg)
case *gateway.ReadyEvent:
return tview.Batch(m.onReady(eventMsg), listen(m.events))
case *gateway.MessageCreateEvent:
return tview.Batch(m.onMessageCreate(eventMsg), listen(m.events))
case *gateway.MessageUpdateEvent:
m.onMessageUpdate(eventMsg)
case *gateway.PresenceUpdateEvent:
m.onPresenceUpdate(eventMsg)
case *gateway.MessageDeleteEvent:
m.onMessageDelete(eventMsg)
case *gateway.MessageReactionAddEvent:
m.onMessageReaction(eventMsg.ChannelID, eventMsg.MessageID)
case *gateway.MessageReactionAddManyEvent:
m.onMessageReaction(eventMsg.ChannelID, eventMsg.MessageID)
case *gateway.MessageReactionRemoveEvent:
m.onMessageReaction(eventMsg.ChannelID, eventMsg.MessageID)
case *gateway.MessageReactionRemoveAllEvent:
m.onMessageReaction(eventMsg.ChannelID, eventMsg.MessageID)
case *gateway.MessageReactionRemoveEmojiEvent:
m.onMessageReaction(eventMsg.ChannelID, eventMsg.MessageID)
case *gateway.GuildMembersChunkEvent:
return tview.Batch(m.onGuildMembersChunk(eventMsg), listen(m.events))
case *gateway.GuildMemberRemoveEvent:
m.onGuildMemberRemove(eventMsg)
case *gateway.TypingStartEvent:
if m.cfg.TypingIndicator.Receive {
m.onTypingStart(eventMsg)
}
case *read.UpdateEvent:
m.onReadUpdate(eventMsg)
}
return listen(m.events)
return tview.Batch(m.applyEvent(msg), listen(m.events))
case tview.FocusMsg:
m.windowUnfocused = !msg.Focused
return nil
case guildstree.ChannelLoadedMsg:
node := m.guildsTree.CurrentNode()
if node == nil {
@ -330,34 +246,21 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd {
return nil
}
m.SetSelectedChannel(&msg.Channel)
m.setSelectedChannel(&msg.Channel)
m.clearTypers()
m.composer.typingUntil = time.Time{}
m.messagesList.reset()
m.messagesList.setTitle(msg.Channel)
m.messagesList.setMessages(msg.Messages)
m.messagesList.ScrollBottom()
isDM := msg.Channel.Type == discord.DirectMessage || msg.Channel.Type == discord.GroupDM
hasNoPerm := !isDM && !m.state.HasPermissions(msg.Channel.ID, discord.PermissionSendMessages)
m.composer.SetDisabled(hasNoPerm)
placeholder := "Message..."
if hasNoPerm {
placeholder = "You do not have permission to send messages in this channel."
} else if m.cfg.AutoFocus {
if m.cfg.AutoFocus {
m.focusComposer()
}
m.composer.SetPlaceholder(text.NewLine(text.NewSegment(placeholder, tcell.StyleDefault.Dim(true))))
if msg.Channel.GuildID.IsValid() {
return m.messagesList.requestGuildMembers(msg.Channel.GuildID, msg.Messages)
title := ui.ChannelToString(msg.Channel, m.cfg.Icons, m.state) + " - " + consts.Name
return tview.Batch(tview.SetTitle(title), m.messagesList.SetChannel(&msg.Channel, msg.Messages))
case messageslist.Msg:
return m.messagesList.Update(msg)
case typingExpiredMsg:
if m.typers[msg.userID] == msg.until {
m.removeTyper(msg.userID)
}
return nil
case deleteMessageMsg:
return m.messagesList.deleteMessageRequest(discord.Message(msg))
case attachmentActionMsg:
return msg.Action
case channelspicker.SelectedMsg:
return m.navigateToChannel(msg.ChannelID)
case channelspicker.CancelMsg:
@ -366,16 +269,36 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd {
return tview.Sequence(msg.Action, m.closeAttachmentsPicker())
case attachmentspicker.CancelMsg:
return m.closeAttachmentsPicker()
case messageslist.ShowAttachmentsMsg:
m.attachmentsPicker.SetItems(msg)
m.attachmentsPickerOpen = true
return nil
case messageslist.ReplyMsg:
m.composer.StartReply(msg.Message, msg.Name, msg.Mention)
m.focusComposer()
return nil
case messageslist.EditMsg:
m.composer.StartEdit(discord.Message(msg))
m.focusComposer()
return nil
case composer.EditLastMsg:
if message, ok := m.messagesList.SelectLastOwn(); ok {
m.composer.StartEdit(message)
}
return nil
case composer.SentMsg:
m.messagesList.ShowNewest()
return nil
case QuitMsg:
return closeState(m.state)
case tview.KeyMsg:
switch {
case keybind.Matches(msg, m.cfg.Keybinds.FocusGuildsTree.Keybind):
m.composer.removeMentionsList()
m.composer.CloseMentions()
m.focusGuildsTree()
return nil
case keybind.Matches(msg, m.cfg.Keybinds.FocusMessagesList.Keybind):
m.composer.removeMentionsList()
m.composer.CloseMentions()
m.setFocus(m.messagesList)
return nil
case keybind.Matches(msg, m.cfg.Keybinds.FocusComposer.Keybind):
@ -397,111 +320,141 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd {
case keybind.Matches(msg, m.cfg.Keybinds.Logout.Keybind):
return tview.Sequence(closeState(m.state), logout())
}
case tabSuggestMsg:
case composer.TabSuggestMsg, mentionslist.Msg:
return m.composer.Update(msg)
case paneMsg:
if msg.focus {
m.setFocus(msg.pane)
}
if msg.msg == nil {
return nil
}
return msg.pane.Update(msg.msg)
}
return m.Layers.Update(msg)
return m.route(msg)
}
func (m *Model) setFocus(target tview.Model) {
if target == nil || target == m.focusedModel() {
// route sends msg to the open picker, which takes all input, and otherwise to the focused pane.
func (m *Model) route(msg tview.Msg) tview.Cmd {
if picker := m.picker(); picker != nil {
return picker.Update(msg)
}
if m.focused != nil {
return m.focused.Update(msg)
}
return nil
}
// paneMsg is what a pane's element made of a mouse message within it. A left mouse button press also focuses the pane.
type paneMsg struct {
pane pane
msg tview.Msg
focus bool
}
// paneElement passes input to the element of a pane and marks mouse input within the pane as meant for it.
type paneElement struct {
pane pane
child tview.Element
}
func (p paneElement) Draw(screen tview.Screen, area tview.Rectangle) {
p.child.Draw(screen, area)
}
func (p paneElement) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg {
out := p.child.Handle(msg, area)
if mouse, ok := msg.(tview.MouseMsg); ok && area.Contains(mouse.Position()) {
return paneMsg{pane: p.pane, msg: out, focus: mouse.Action == tview.MouseLeftDown}
}
// Keys and paste reach only the focused pane, so what it makes of them goes to that pane through route.
return out
}
// pane is a model shown side by side with the others, one of which has the focus.
type pane interface {
Update(tview.Msg) tview.Cmd
View(focused bool) tview.Element
}
// paneView returns the view of p, focused if it has the focus and no picker is open, marked so that clicking it focuses it.
func (m *Model) paneView(p pane) tview.Element {
return paneElement{pane: p, child: p.View(m.focused == p && m.picker() == nil)}
}
func (m *Model) setFocus(target pane) {
if target == nil || target == m.focused || (target == m.guildsTree && !m.guildsTreeVisible) {
return
}
switch target {
case m.guildsTree:
if !m.isGuildsTreeVisible() {
return
}
m.mainFlex.SetFocus(0)
case m.messagesList:
m.rightFlex.SetFocus(0)
m.mainFlex.SetFocus(m.mainFlex.GetItemCount() - 1)
case m.composer:
m.rightFlex.SetFocus(1)
m.mainFlex.SetFocus(m.mainFlex.GetItemCount() - 1)
case m.mainFlex:
m.mainFlex.SetFocus(-1)
}
m.focused = target
}
func (m *Model) View(screen tview.Screen) {
ui.BlurBox(m.guildsTree.Box, &m.cfg.Theme)
ui.BlurBox(m.messagesList.Box, &m.cfg.Theme)
ui.BlurBox(m.composer.Box, &m.cfg.Theme)
if !m.GetVisible(channelsPickerLayerName) && !m.GetVisible(attachmentsPickerLayerName) {
switch m.focusedModel() {
case m.guildsTree:
ui.FocusBox(m.guildsTree.Box, &m.cfg.Theme)
case m.messagesList:
ui.FocusBox(m.messagesList.Box, &m.cfg.Theme)
case m.composer:
ui.FocusBox(m.composer.Box, &m.cfg.Theme)
}
// View shows the guilds tree left of the messages above the composer, with the mentions list over the messages and an open picker on top.
func (m *Model) View() tview.Element {
picker := m.picker()
var right tview.Element = column.New(
m.paneView(m.messagesList),
column.New(m.paneView(m.composer)).Height(tview.Fixed(m.composer.Height())),
)
if mentions := m.composer.MentionsView(); mentions != nil {
right = stack.New(right, mentions)
}
m.Layers.View(screen)
main := right
if m.guildsTreeVisible {
width := m.cfg.Sidebar.WidthPercent
main = row.New(
column.New(m.paneView(m.guildsTree)).Width(tview.FillPortion(width)),
column.New(right).Width(tview.FillPortion(100-width)),
)
}
if picker == nil {
return main
}
return stack.New(
main,
// The backdrop keeps clicks from reaching the panes behind the picker.
opaque.New(backdrop.New().Style(m.cfg.Theme.Dialog.BackgroundStyle.Style)),
center.New(column.New(picker.View()).Width(tview.Fixed(m.cfg.Picker.Width)).Height(tview.Fixed(m.cfg.Picker.Height))),
)
}
func (m *Model) focusedModel() tview.Model {
count := m.mainFlex.GetItemCount()
focused := m.mainFlex.Focused()
if count == 2 && focused == 0 {
return m.guildsTree
}
if count > 0 && focused == count-1 {
if m.rightFlex.Focused() == 0 {
return m.messagesList
}
if m.rightFlex.Focused() == 1 {
return m.composer
}
}
return m.mainFlex
// typingExpiredMsg ends a user's typing indicator unless they typed again after it was set to expire at until.
type typingExpiredMsg struct {
userID discord.UserID
until time.Time
}
func (m *Model) clearTypers() {
m.typersMu.Lock()
for _, timer := range m.typers {
timer.Stop()
}
clear(m.typers)
m.typersMu.Unlock()
m.updateFooter()
}
func (m *Model) addTyper(userID discord.UserID) {
m.typersMu.Lock()
typer, ok := m.typers[userID]
if ok {
typer.Reset(typingDuration)
} else {
m.typers[userID] = time.AfterFunc(typingDuration, func() {
m.removeTyper(userID)
})
}
m.typersMu.Unlock()
// addTyper shows userID as typing and returns a command that ends it after the typing duration.
func (m *Model) addTyper(userID discord.UserID) tview.Cmd {
until := time.Now().Add(composer.TypingDuration)
m.typers[userID] = until
m.updateFooter()
return func() tview.Msg {
time.Sleep(composer.TypingDuration)
return typingExpiredMsg{userID, until}
}
}
func (m *Model) removeTyper(userID discord.UserID) {
m.typersMu.Lock()
if typer, ok := m.typers[userID]; ok {
typer.Stop()
delete(m.typers, userID)
}
m.typersMu.Unlock()
delete(m.typers, userID)
m.updateFooter()
}
func (m *Model) updateFooter() {
selectedChannel, ok := m.SelectedChannel()
if !ok {
selectedChannel := m.selectedChannel
if selectedChannel == nil {
return
}
guildID := selectedChannel.GuildID
m.typersMu.RLock()
defer m.typersMu.RUnlock()
var footer string
if len(m.typers) > 0 {
var names []string

View file

@ -0,0 +1,79 @@
package chat
import (
"path/filepath"
"testing"
"github.com/ayn2op/arikawa/v3/discord"
"github.com/ayn2op/arikawa/v3/gateway"
"github.com/ayn2op/discordo/internal/config"
"github.com/ayn2op/tview"
"github.com/gdamore/tcell/v3"
)
func newTestModel(t *testing.T) *Model {
t.Helper()
cfg, err := config.Load(filepath.Join(t.TempDir(), "config.toml"))
if err != nil {
t.Fatal(err)
}
return NewModel(cfg, "")
}
func TestModelHandle(t *testing.T) {
t.Run("typing in the channels picker", func(t *testing.T) {
m := newTestModel(t)
m.openPicker()
area := tview.Rectangle{Width: 120, Height: 40}
// The picker's action passes through the panes below it, which must leave it alone.
msg := m.View().Handle(tcell.NewEventKey(tcell.KeyRune, "a", tcell.ModNone), area)
if msg == nil {
t.Fatal("typing produced no message")
}
if _, ok := msg.(paneMsg); ok {
t.Fatalf("the picker's action was marked for a pane: %v", msg)
}
m.Update(msg)
})
}
func TestModelUpdate(t *testing.T) {
t.Run("typing expires unless extended", func(t *testing.T) {
m := newTestModel(t)
m.addTyper(1)
first := m.typers[1]
m.addTyper(1)
m.Update(typingExpiredMsg{userID: 1, until: first})
if _, ok := m.typers[1]; !ok {
t.Fatal("an extended typing indicator expired early")
}
m.Update(typingExpiredMsg{userID: 1, until: m.typers[1]})
if _, ok := m.typers[1]; ok {
t.Fatal("the typing indicator did not expire")
}
})
}
func TestModelAddMessageOrNotify(t *testing.T) {
for _, tt := range []struct {
name string
focused bool
whenUnfocused bool
want bool
}{
{"focused", true, true, false},
{"unfocused", false, true, true},
{"unfocused but disabled", false, false, false},
} {
t.Run(tt.name, func(t *testing.T) {
m := newTestModel(t)
m.cfg.Notifications.WhenUnfocused = tt.whenUnfocused
m.setSelectedChannel(&discord.Channel{ID: 1})
m.Update(tcell.NewEventFocus(tt.focused))
cmd := m.addMessageOrNotify(&gateway.MessageCreateEvent{Message: discord.Message{ID: 1, ChannelID: 1}})
if got := cmd != nil; got != tt.want {
t.Fatalf("notified = %v, want %v", got, tt.want)
}
})
}
}

View file

@ -4,14 +4,11 @@ import (
"context"
"log/slog"
"github.com/ayn2op/arikawa/v3/discord"
"github.com/ayn2op/arikawa/v3/gateway"
"github.com/ayn2op/ningen/v3"
"github.com/ayn2op/tview"
)
type attachmentActionMsg struct{ Action tview.Cmd }
func openState(state *ningen.State) tview.Cmd {
return func() tview.Msg {
if err := state.Open(context.Background()); err != nil {
@ -40,13 +37,6 @@ func listen(events <-chan gateway.Event) tview.Cmd {
}
}
type olderMessagesLoadedMsg struct {
ChannelID discord.ChannelID
Older []discord.Message
}
type deleteMessageMsg discord.Message
type LogoutMsg struct{}
func logout() tview.Cmd {

View file

@ -2,12 +2,12 @@ package chat
import (
"log/slog"
"slices"
"github.com/ayn2op/arikawa/v3/discord"
"github.com/ayn2op/arikawa/v3/gateway"
"github.com/ayn2op/arikawa/v3/utils/httputil/httpdriver"
"github.com/ayn2op/arikawa/v3/utils/ws"
"github.com/ayn2op/discordo/internal/ui"
"github.com/ayn2op/ningen/v3/states/read"
"github.com/ayn2op/tview"
)
@ -28,22 +28,22 @@ func (m *Model) onRaw(event *ws.RawEvent) {
)
}
func (m *Model) onReady(event *gateway.ReadyEvent) tview.Cmd {
func (m *Model) loadGuildsTree(event *gateway.ReadyEvent) tview.Cmd {
m.guildsTree.Update(event)
m.setFocus(m.guildsTree)
return nil
}
func (m *Model) onMessageCreate(message *gateway.MessageCreateEvent) tview.Cmd {
func (m *Model) addMessageOrNotify(message *gateway.MessageCreateEvent) tview.Cmd {
m.guildsTree.Update(message)
selectedChannel, ok := m.SelectedChannel()
if ok && selectedChannel.ID == message.ChannelID {
if channel := m.selectedChannel; channel != nil && channel.ID == message.ChannelID {
m.removeTyper(message.Author.ID)
m.messagesList.addMessage(message.Message)
return nil
m.messagesList.AddMessage(message.Message)
if !m.windowUnfocused || !m.cfg.Notifications.WhenUnfocused {
return nil
}
}
return m.notify(*message)
}
@ -93,90 +93,58 @@ func (m *Model) notify(message gateway.MessageCreateEvent) tview.Cmd {
}
}
func (m *Model) onPresenceUpdate(presence *gateway.PresenceUpdateEvent) {
m.guildsTree.Update(presence)
func (m *Model) refreshMemberNames(event *gateway.GuildMembersChunkEvent) tview.Cmd {
m.messagesList.InvalidateRendered()
return m.composer.CacheMemberSearch(event)
}
func (m *Model) onMessageUpdate(message *gateway.MessageUpdateEvent) {
selectedChannel, ok := m.SelectedChannel()
if !ok || selectedChannel.ID != message.ChannelID {
return
func (m *Model) showTypingIndicator(event *gateway.TypingStartEvent) tview.Cmd {
if channel := m.selectedChannel; channel == nil || channel.ID != event.ChannelID || ui.IsMe(m.state, event.UserID) {
return nil
}
index := slices.IndexFunc(m.messagesList.items, func(m messageItem) bool {
return !m.separator && m.message.ID == message.ID
})
if index < 0 {
return
}
m.messagesList.setMessage(index, message.Message)
return m.addTyper(event.UserID)
}
func (m *Model) onMessageDelete(message *gateway.MessageDeleteEvent) {
selectedChannel, ok := m.SelectedChannel()
if !ok || selectedChannel.ID != message.ChannelID {
return
}
// applyEvent updates the model with a gateway event and returns what the event needs done.
func (m *Model) applyEvent(event gateway.Event) tview.Cmd {
switch event := event.(type) {
case *ws.RawEvent:
m.onRaw(event)
deletedIndex := slices.IndexFunc(m.messagesList.items, func(m messageItem) bool {
return !m.separator && m.message.ID == message.ID
})
if deletedIndex < 0 {
return
}
case *gateway.ReadyEvent:
return m.loadGuildsTree(event)
m.messagesList.deleteMessage(deletedIndex)
case *gateway.MessageCreateEvent:
return m.addMessageOrNotify(event)
case *gateway.MessageUpdateEvent:
m.messagesList.UpdateMessage(event.Message)
case *gateway.PresenceUpdateEvent:
m.guildsTree.Update(event)
case *gateway.MessageDeleteEvent:
m.messagesList.DeleteMessage(event.ChannelID, event.ID)
case *gateway.MessageReactionAddEvent:
m.messagesList.RefreshMessage(event.ChannelID, event.MessageID)
case *gateway.MessageReactionAddManyEvent:
m.messagesList.RefreshMessage(event.ChannelID, event.MessageID)
case *gateway.MessageReactionRemoveEvent:
m.messagesList.RefreshMessage(event.ChannelID, event.MessageID)
case *gateway.MessageReactionRemoveAllEvent:
m.messagesList.RefreshMessage(event.ChannelID, event.MessageID)
case *gateway.MessageReactionRemoveEmojiEvent:
m.messagesList.RefreshMessage(event.ChannelID, event.MessageID)
}
case *gateway.GuildMembersChunkEvent:
return m.refreshMemberNames(event)
case *gateway.GuildMemberRemoveEvent:
m.composer.OnGuildMemberRemove(event)
func (m *Model) onMessageReaction(channelID discord.ChannelID, messageID discord.MessageID) {
selectedChannel, ok := m.SelectedChannel()
if !ok || selectedChannel.ID != channelID {
return
}
index := slices.IndexFunc(m.messagesList.items, func(item messageItem) bool {
return !item.separator && item.message.ID == messageID
})
message, err := m.state.Cabinet.Message(channelID, messageID)
if index >= 0 && err == nil {
m.messagesList.setMessage(index, *message)
}
}
func (m *Model) onGuildMembersChunk(event *gateway.GuildMembersChunkEvent) tview.Cmd {
m.messagesList.invalidateRenderedMessages()
return m.composer.onGuildMembersChunk(event)
}
func (m *Model) onGuildMemberRemove(event *gateway.GuildMemberRemoveEvent) {
memberSearchCache := m.composer.memberSearchCache
for name := event.GuildID.String() + " " + event.User.Username; name != ""; name = name[:len(name)-1] {
if count, ok := memberSearchCache[name]; ok && count >= m.state.MemberState.SearchLimit {
// A full result set may now be missing members; search these prefixes again.
for name != "" {
delete(memberSearchCache, name)
name = name[:len(name)-1]
}
return
case *gateway.TypingStartEvent:
if m.cfg.TypingIndicator.Receive {
return m.showTypingIndicator(event)
}
case *read.UpdateEvent:
m.guildsTree.Update(event)
}
}
func (m *Model) onTypingStart(event *gateway.TypingStartEvent) {
selectedChannel, ok := m.SelectedChannel()
if !ok || selectedChannel.ID != event.ChannelID {
return
}
if m.isMe(event.UserID) {
return
}
m.addTyper(event.UserID)
}
func (m *Model) onReadUpdate(event *read.UpdateEvent) {
m.guildsTree.Update(event)
return nil
}

View file

@ -0,0 +1,89 @@
// Package form lays out labeled text inputs above a submit button.
package form
import (
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/button"
"github.com/ayn2op/tview/column"
"github.com/ayn2op/tview/row"
"github.com/ayn2op/tview/text"
"github.com/ayn2op/tview/textinput"
"github.com/gdamore/tcell/v3"
"github.com/rivo/uniseg"
)
// Field is a labeled text input. A non-empty Mask hides what is typed, such as for passwords.
type Field struct {
Label, Mask string
EditState textinput.EditState
}
// Model is a column of fields above a button. Tab and the arrow keys move the focus, Enter in a field moves to the next one, and Enter on the button or clicking it submits.
type Model struct {
fields []Field
button string
focus int
}
// SubmitMsg is returned when the button is pressed.
type SubmitMsg struct{}
type editMsg struct {
index int
action textinput.Action
}
type nextMsg struct{}
func New(button string, fields ...Field) *Model {
return &Model{fields: fields, button: button}
}
// Value returns the text of field index.
func (m *Model) Value(index int) string {
return m.fields[index].EditState.Value()
}
func (m *Model) Init() tview.Cmd { return nil }
func (m *Model) Update(msg tview.Msg) tview.Cmd {
count := len(m.fields) + 1
switch msg := msg.(type) {
case editMsg:
m.fields[msg.index].EditState.Perform(msg.action)
case nextMsg:
m.focus = (m.focus + 1) % count
case tview.KeyMsg:
switch msg.Key() {
case tcell.KeyTab, tcell.KeyDown:
m.focus = (m.focus + 1) % count
case tcell.KeyBacktab, tcell.KeyUp:
m.focus = (m.focus + count - 1) % count
}
}
return nil
}
func (m *Model) View() tview.Element {
labelWidth := 0
for _, f := range m.fields {
labelWidth = max(labelWidth, uniseg.StringWidth(f.Label)+1)
}
layout := column.New().Spacing(1)
for i := range m.fields {
f := &m.fields[i]
input := textinput.New(&f.EditState).
Mask(f.Mask).
Focused(m.focus == i).
OnAction(func(a textinput.Action) tview.Msg { return editMsg{index: i, action: a} }).
OnSubmit(nextMsg{})
layout = layout.Push(row.New(column.New(text.New(f.Label)).Width(tview.Fixed(labelWidth)), input).Height(tview.Fixed(1)))
}
submit := button.New().
Label(m.button).
Width(tview.Fixed(uniseg.StringWidth(m.button) + 4)).
Height(tview.Fixed(1)).
Focused(m.focus == len(m.fields)).
OnClick(SubmitMsg{})
return layout.Push(submit)
}

View file

@ -0,0 +1,36 @@
package form
import (
"testing"
"github.com/ayn2op/tview"
"github.com/gdamore/tcell/v3"
)
func TestModelUpdate(t *testing.T) {
area := tview.Rectangle{Width: 30, Height: 5}
// send delivers msg the way the application does: through the view, then Update.
send := func(m *Model, msg tview.Msg) tview.Msg {
if msg = m.View().Handle(msg, area); msg != nil {
m.Update(msg)
}
return msg
}
key := func(k tcell.Key, str string) tview.KeyMsg { return tcell.NewEventKey(k, str, tcell.ModNone) }
m := New("Login", Field{Label: "Login"}, Field{Label: "Password", Mask: "*"})
send(m, key(tcell.KeyRune, "a"))
send(m, key(tcell.KeyEnter, ""))
send(m, key(tcell.KeyRune, "b"))
t.Run("typing fills the focused field and Enter moves on", func(t *testing.T) {
if m.Value(0) != "a" || m.Value(1) != "b" {
t.Fatalf("values = %q, %q", m.Value(0), m.Value(1))
}
})
t.Run("enter on the button submits", func(t *testing.T) {
send(m, key(tcell.KeyTab, ""))
if got := send(m, key(tcell.KeyEnter, "")); got != (SubmitMsg{}) {
t.Fatalf("got %v", got)
}
})
}

View file

@ -1,16 +0,0 @@
package login
import (
"github.com/ayn2op/tview/help"
"github.com/ayn2op/tview/keybind"
)
var _ help.KeyMap = (*Model)(nil)
func (m *Model) ShortHelp() []keybind.Keybind {
return m.tabs.ShortHelp()
}
func (m *Model) FullHelp() [][]keybind.Keybind {
return m.tabs.FullHelp()
}

View file

@ -0,0 +1,39 @@
package login
import (
"github.com/ayn2op/tview/help"
"github.com/ayn2op/tview/keybind"
)
var _ help.KeyMap = (*Model)(nil)
func (m *Model) ShortHelp() []keybind.Keybind {
short := m.navHelp()
if keyMap, ok := m.tabs[m.active].(help.KeyMap); ok {
short = append(short, keyMap.ShortHelp()...)
}
return short
}
func (m *Model) FullHelp() [][]keybind.Keybind {
var full [][]keybind.Keybind
if nav := m.navHelp(); len(nav) > 0 {
full = append(full, nav)
}
if keyMap, ok := m.tabs[m.active].(help.KeyMap); ok {
full = append(full, keyMap.FullHelp()...)
}
return full
}
// navHelp returns the tab keybinds that switch to another tab from the active one.
func (m *Model) navHelp() []keybind.Keybind {
var nav []keybind.Keybind
if m.active > 0 {
nav = append(nav, m.keybinds.Previous)
}
if m.active < len(m.tabs)-1 {
nav = append(nav, m.keybinds.Next)
}
return nav
}

View file

@ -0,0 +1,38 @@
package login
import (
"path/filepath"
"slices"
"testing"
"github.com/ayn2op/discordo/internal/config"
"github.com/ayn2op/tview/keybind"
)
func TestModelShortHelp(t *testing.T) {
cfg, err := config.Load(filepath.Join(t.TempDir(), "config.toml"))
if err != nil {
t.Fatal(err)
}
m := NewModel(cfg)
for _, tt := range []struct {
name string
active int
wantPrev, wantNext bool
}{
{"first tab", 0, false, true},
{"middle tab", 1, true, true},
{"last tab", len(m.tabs) - 1, true, false},
} {
t.Run(tt.name, func(t *testing.T) {
m.active = tt.active
short := m.ShortHelp()
has := func(desc string) bool {
return slices.ContainsFunc(short, func(k keybind.Keybind) bool { return k.Help().Desc == desc })
}
if has("prev tab") != tt.wantPrev || has("next tab") != tt.wantNext {
t.Fatalf("prev = %v, next = %v", has("prev tab"), has("next tab"))
}
})
}
}

View file

@ -3,7 +3,6 @@ package login
import (
"log/slog"
"github.com/ayn2op/tview/layers"
"github.com/ayn2op/tview/tabs"
"github.com/ayn2op/discordo/internal/config"
@ -14,36 +13,61 @@ import (
"github.com/ayn2op/tview"
)
const (
tabsLayerName = "tabs"
)
// tab is a login method shown as a tab.
type tab interface {
tview.Model
Label() string
}
// Model shows the login methods as tabs inside a bordered box.
type Model struct {
*layers.Layers
tabs *tabs.Model
cfg *config.Config
tabs []tab
active int
keybinds tabs.Keybinds
}
func NewModel(cfg *config.Config) *Model {
tabs := tabs.NewModel([]tabs.Tab{password.NewModel(), qr.NewModel(), token.NewModel()})
l := layers.New()
ui.ConfigureBox(l.Box, &cfg.Theme)
l.SetBackgroundLayerStyle(cfg.Theme.Dialog.BackgroundStyle.Style)
l.AddLayer(tabs, layers.WithName(tabsLayerName), layers.WithResize(true), layers.WithVisible(true))
return &Model{
Layers: l,
tabs: tabs,
cfg: cfg,
tabs: []tab{password.NewModel(), qr.NewModel(), token.NewModel()},
keybinds: tabs.DefaultKeybinds(),
}
}
// Init initializes the active tab.
func (m *Model) Init() tview.Cmd {
return m.tabs[m.active].Init()
}
// selectTabMsg switches to the tab at an index.
type selectTabMsg int
// View shows the tabs in a box.
func (m *Model) View() tview.Element {
labels := make([]string, len(m.tabs))
for i, tab := range m.tabs {
labels[i] = tab.Label()
}
t := tabs.New(labels...).
Active(m.active).
Content(m.tabs[m.active].View()).
Keybinds(m.keybinds).
OnSelect(func(i int) tview.Msg { return selectTabMsg(i) })
return ui.Box(t, &m.cfg.Theme, false)
}
func (m *Model) Update(msg tview.Msg) tview.Cmd {
switch msg := msg.(type) {
case selectTabMsg:
m.active = int(msg)
return m.tabs[m.active].Init()
case error:
return showErrorDialog(msg)
case copyErrorMsg:
return setClipboard(string(msg))
}
return m.Layers.Update(msg)
return m.tabs[m.active].Update(msg)
}
func showErrorDialog(err error) tview.Cmd {

View file

@ -1,36 +1,29 @@
package password
import (
"github.com/ayn2op/discordo/internal/ui/login/form"
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/tabs"
)
type Model struct {
*tview.Form
*form.Model
}
func NewModel() *Model {
form := tview.NewForm().
AddInputField("Login", "", 0).
AddPasswordField("Password", "", 0, 0).
AddButton("Login")
return &Model{Form: form}
return &Model{Model: form.New("Login", form.Field{Label: "Login"}, form.Field{Label: "Password", Mask: "*"})}
}
var _ tabs.Tab = (*Model)(nil)
func (m *Model) Label() string {
return "Password"
}
func (m *Model) Update(msg tview.Msg) tview.Cmd {
if _, ok := msg.(tview.FormSubmitMsg); ok {
login := m.GetFormItem(0).(*tview.InputField).Text()
password := m.GetFormItem(1).(*tview.InputField).Text()
if _, ok := msg.(form.SubmitMsg); ok {
login, password := m.Value(0), m.Value(1)
if login == "" || password == "" {
return nil
}
return loginCmd(login, password)
return submitLogin(login, password)
}
return m.Form.Update(msg)
return m.Model.Update(msg)
}

View file

@ -13,7 +13,7 @@ type (
ErrMsg error
)
func loginCmd(login, password string) tview.Cmd {
func submitLogin(login, password string) tview.Cmd {
return func() tview.Msg {
headers := http.Headers()
headers.Set("Referer", "https://discord.com/login")

View file

@ -6,15 +6,18 @@ import (
"time"
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/tabs"
"github.com/ayn2op/tview/text"
"github.com/ayn2op/tview/center"
"github.com/ayn2op/tview/richtext"
"github.com/ayn2op/tview/textview"
"github.com/gdamore/tcell/v3"
"github.com/gorilla/websocket"
"github.com/skip2/go-qrcode"
)
type Model struct {
*tview.TextView
// code is the QR code above the status, scrolled when it does not fit.
code richtext.Text
scrollState textview.ScrollState
conn *websocket.Conn
heartbeatInterval time.Duration
@ -25,21 +28,15 @@ type Model struct {
status string
}
func NewModel() *Model {
m := &Model{
TextView: tview.NewTextView(),
}
m.
SetScrollable(true).
SetWrap(false).
SetTextAlign(tview.AlignmentCenter)
// scrollMsg scrolls the code.
type scrollMsg textview.Action
func NewModel() *Model {
m := &Model{}
m.setStatus("Press Ctrl+N to open QR login")
return m
}
var _ tabs.Tab = (*Model)(nil)
func (m *Model) Label() string {
return "QR"
}
@ -56,7 +53,10 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd {
m.setStatus("Canceled")
return closeConn(m.conn)
}
return m.TextView.Update(msg)
return nil
case scrollMsg:
m.scrollState.Perform(textview.Action(msg))
return nil
case connCreateMsg:
m.conn = msg.conn
@ -68,7 +68,7 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd {
case helloMsg:
m.heartbeatInterval = time.Duration(msg.heartbeatInterval) * time.Millisecond
return tview.Batch(listen(m.conn), heartbeat(m.heartbeatInterval), generatePrivateKey())
return tview.Batch(listen(m.conn), scheduleHeartbeat(m.heartbeatInterval), generatePrivateKey())
case privateKeyMsg:
m.privateKey = msg.privateKey
return tview.Batch(listen(m.conn), sendInit(m.conn, m.privateKey))
@ -101,7 +101,7 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd {
if m.conn == nil {
return nil
}
return tview.Batch(heartbeat(m.heartbeatInterval), sendHeartbeat(m.conn))
return tview.Batch(scheduleHeartbeat(m.heartbeatInterval), sendHeartbeat(m.conn))
case errMsg:
m.setStatus(msg.Error())
@ -111,8 +111,7 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd {
return nil
}
// halfBlock packs a vertical pair of QR pixels into one glyph, fitting two
// bitmap rows into a single terminal row.
// halfBlock packs a vertical pair of QR pixels into one glyph, fitting two bitmap rows into a single terminal row.
func halfBlock(top, bottom bool) rune {
switch [2]bool{top, bottom} {
case [2]bool{true, true}:
@ -126,12 +125,17 @@ func halfBlock(top, bottom bool) rune {
}
}
func (m *Model) SetRect(x, y, width, height int) {
_, _, _, oldHeight := m.Rect()
m.TextView.SetRect(x, y, width, height)
if oldHeight != height {
m.render()
}
// View centers the code, which scrolls when it is taller than the tab.
func (m *Model) View() tview.Element {
return center.New(
textview.New(m.code).
ScrollState(&m.scrollState).
Wrap(false).
Alignment(tview.AlignmentCenter).
Height(tview.Fixed(len(m.code))).
Focused(true).
OnAction(func(a textview.Action) tview.Msg { return scrollMsg(a) }),
)
}
func (m *Model) setStatus(status string) {
@ -159,29 +163,7 @@ func (m *Model) render() {
out.WriteString(m.status)
}
builder := new(text.Builder)
builder := new(richtext.Builder)
builder.Write(out.String(), tcell.StyleDefault)
m.SetContent(m.centerText(builder.Finish()))
m.TextView.SetRect(m.Rect())
}
func (m *Model) centerText(content text.Text) text.Text {
_, _, _, height := m.InnerRect()
if height == 0 {
height = 40
}
padding := (height - len(content)) / 2
if padding < 0 {
padding = 0
} else if padding < 1 && height > len(content) {
padding = 1
}
if padding == 0 {
return content
}
centered := make(text.Text, 0, padding+len(content))
centered = append(centered, make(text.Text, padding)...)
centered = append(centered, content...)
return centered
m.code = builder.Finish()
}

View file

@ -4,21 +4,43 @@ import (
"strings"
"testing"
"github.com/ayn2op/tview"
"github.com/gdamore/tcell/v3"
"github.com/gdamore/tcell/v3/vt"
"github.com/skip2/go-qrcode"
)
func TestModelSetRect(t *testing.T) {
t.Run("renders content", func(t *testing.T) {
func TestModelView(t *testing.T) {
t.Run("draws the code above the status", func(t *testing.T) {
const width, height = 80, 24
screen, err := tcell.NewTerminfoScreenFromTty(vt.NewMockTerm(vt.MockOptSize{X: width, Y: height}))
if err != nil {
t.Fatal(err)
}
if err := screen.Init(); err != nil {
t.Fatal(err)
}
t.Cleanup(screen.Fini)
m := NewModel()
code, err := qrcode.New("test", qrcode.Low)
if err != nil {
t.Fatal(err)
}
m.qrCode = code
m.SetRect(0, 0, 80, 24)
text := m.Text()
if !strings.Contains(text, m.status) || !strings.ContainsAny(text, "█▀▄") {
t.Fatal("missing content")
m.setStatus("status")
m.View().Draw(screen, tview.Rectangle{Width: width, Height: height})
var drawn strings.Builder
for y := range height {
for x := range width {
str, _, _ := screen.Get(x, y)
drawn.WriteString(str)
}
drawn.WriteByte('\n')
}
if !strings.Contains(drawn.String(), "status") || !strings.ContainsAny(drawn.String(), "█▀▄") {
t.Fatalf("missing content:\n%s", drawn.String())
}
})
}

View file

@ -128,7 +128,7 @@ func decodeMessage(data []byte) tview.Msg {
type heartbeatTickMsg struct{}
func heartbeat(interval time.Duration) tview.Cmd {
func scheduleHeartbeat(interval time.Duration) tview.Cmd {
return func() tview.Msg {
time.Sleep(interval)
return heartbeatTickMsg{}

View file

@ -1,34 +1,29 @@
package token
import (
"github.com/ayn2op/discordo/internal/ui/login/form"
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/tabs"
)
type Model struct {
*tview.Form
*form.Model
}
func NewModel() *Model {
form := tview.NewForm().
AddPasswordField("Token", "", 0, 0).
AddButton("Login")
return &Model{Form: form}
return &Model{Model: form.New("Login", form.Field{Label: "Token", Mask: "*"})}
}
var _ tabs.Tab = (*Model)(nil)
func (m *Model) Label() string {
return "Token"
}
func (m *Model) Update(msg tview.Msg) tview.Cmd {
if _, ok := msg.(tview.FormSubmitMsg); ok {
token := m.GetFormItem(0).(*tview.InputField).Text()
if _, ok := msg.(form.SubmitMsg); ok {
token := m.Value(0)
if token == "" {
return nil
}
return tokenCmd(token)
return submitToken(token)
}
return m.Form.Update(msg)
return m.Model.Update(msg)
}

View file

@ -6,7 +6,7 @@ import (
type TokenMsg string
func tokenCmd(token string) tview.Cmd {
func submitToken(token string) tview.Cmd {
return func() tview.Msg {
return TokenMsg(token)
}

18
internal/ui/root/inert.go Normal file
View file

@ -0,0 +1,18 @@
package root
import "github.com/ayn2op/tview"
// inert draws child but gives it no input, like HTML's inert attribute, for what is behind the dialog.
type inert struct {
child tview.Element
}
var _ tview.Element = inert{}
func (i inert) Draw(screen tview.Screen, area tview.Rectangle) {
i.child.Draw(screen, area)
}
func (inert) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg {
return msg
}

View file

@ -12,85 +12,31 @@ 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/flex"
"github.com/ayn2op/tview/backdrop"
"github.com/ayn2op/tview/box"
"github.com/ayn2op/tview/column"
"github.com/ayn2op/tview/dialog"
"github.com/ayn2op/tview/help"
"github.com/ayn2op/tview/keybind"
"github.com/ayn2op/tview/layers"
"github.com/ayn2op/tview/modal"
"github.com/ayn2op/tview/stack"
"github.com/gdamore/tcell/v3"
)
const (
tokenEnvVarKey = "DISCORDO_TOKEN"
contentLayerName = "content"
modalLayerName = "modal"
)
const tokenEnvVarKey = "DISCORDO_TOKEN"
type Model struct {
*layers.Layers
inner tview.Model
// helpVisible reports whether help is shown below the inner model, and helpShowAll whether it shows every keybind.
helpVisible, helpShowAll bool
// modal is the request shown in a dialog, or nil when none is open, and dialogFocus its focused button.
modal *ui.ModalMsg
dialogFocus int
rootFlex *flex.Model // inner + help
inner tview.Model
help *help.Model
modal *ui.ModalMsg
cfg *config.Config
cfg *config.Config
}
func NewModel(cfg *config.Config) *Model {
m := &Model{
Layers: layers.New(),
rootFlex: flex.NewModel(),
cfg: cfg,
}
m.SetBackgroundLayerStyle(cfg.Theme.Dialog.BackgroundStyle.Style)
m.rootFlex.SetDirection(flex.DirectionRow)
if cfg.Help.Enabled {
m.help = newHelpModel(cfg, m)
}
m.buildLayout()
return m
}
func newHelpModel(cfg *config.Config, keyMap help.KeyMap) *help.Model {
styles := help.DefaultStyles()
styles.ShortKey = cfg.Theme.Help.ShortKeyStyle.Style
styles.ShortDesc = cfg.Theme.Help.ShortDescStyle.Style
styles.FullKey = cfg.Theme.Help.FullKeyStyle.Style
styles.FullDesc = cfg.Theme.Help.FullDescStyle.Style
h := help.NewModel()
h.SetStyles(styles).
SetKeyMap(keyMap).
SetCompactModifiers(cfg.Help.CompactModifiers).
SetShortSeparator(cfg.Help.Separator)
h.SetBorderPadding(0, 0, cfg.Help.Padding[0], cfg.Help.Padding[1])
return h
}
func (m *Model) showLogin() tview.Cmd {
m.inner = login.NewModel(m.cfg)
m.buildLayout()
return m.inner.Init()
}
func (m *Model) showChat(token string) tview.Cmd {
m.inner = chat.NewModel(m.cfg, token)
m.buildLayout()
return m.inner.Init()
}
func (m *Model) buildLayout() {
m.Clear()
m.modal = nil
m.rootFlex.Clear()
if m.inner != nil {
m.rootFlex.AddItem(m.inner, 0, 1, true)
}
if m.help != nil {
m.rootFlex.AddItem(m.help, m.helpHeight(), 0, false)
}
m.AddLayer(m.rootFlex, layers.WithName(contentLayerName), layers.WithResize(true), layers.WithVisible(true))
return &Model{cfg: cfg, helpVisible: cfg.Help.Enabled}
}
var _ tview.Model = (*Model)(nil)
@ -98,7 +44,7 @@ var _ tview.Model = (*Model)(nil)
func (m *Model) Init() tview.Cmd {
var cmd tview.Cmd
if token := os.Getenv(tokenEnvVarKey); token != "" {
cmd = tokenCmd(token)
cmd = useToken(token)
} else {
cmd = getToken()
}
@ -130,23 +76,25 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd {
)
case ui.ModalMsg:
return m.showModal(msg)
case modal.DoneMsg:
return m.finishModal(msg)
case dialogFocusMsg:
m.dialogFocus = int(msg)
return nil
case dialogDoneMsg:
return m.finishModal(int(msg))
case tview.KeyMsg:
// The dialog takes all input while it is open, so nothing behind it reacts.
if m.modal != nil {
return m.Layers.Update(msg)
return nil
}
switch {
case keybind.Matches(msg, m.cfg.Keybinds.ToggleHelp.Keybind):
m.toggleHelp()
return nil
case keybind.Matches(msg, m.cfg.Keybinds.ToggleFullHelp.Keybind):
if m.help == nil {
return nil
if m.cfg.Help.Enabled {
m.helpShowAll = !m.helpShowAll
}
m.help.SetShowAll(!m.help.ShowAll())
m.rootFlex.ResizeItem(m.help, m.helpHeight(), 0)
return nil
case keybind.Matches(msg, m.cfg.Keybinds.Suspend.Keybind):
return suspend()
@ -157,9 +105,9 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd {
}
return tview.Batch(innerCmd, tview.Quit())
}
case tview.MouseMsg, tview.PasteMsg, tview.FormSubmitMsg, tview.FormCancelMsg:
case tview.MouseMsg, tview.PasteMsg:
if m.modal != nil {
return m.Layers.Update(msg)
return nil
}
}
@ -169,75 +117,114 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd {
return nil
}
func (m *Model) toggleHelp() {
if m.help == nil {
return
// View shows the inner model above help, with the modal dialog on top of both when one is open.
func (m *Model) View() tview.Element {
var innerView, helpView tview.Element
if m.inner != nil {
innerView = m.inner.View()
}
count := m.rootFlex.GetItemCount()
if count > 0 && m.rootFlex.GetItem(count-1) == m.help {
m.rootFlex.RemoveItem(m.help)
} else {
m.rootFlex.AddItem(m.help, m.helpHeight(), 0, false)
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(tview.Fixed(max(m.helpView().Rows(0), 1)))
}
content := column.New(innerView, helpView)
if m.modal == nil {
return content
}
return stack.New(inert{content}, backdrop.New().Style(m.cfg.Theme.Dialog.BackgroundStyle.Style), m.dialogView())
}
func (m *Model) showModal(request ui.ModalMsg) tview.Cmd {
if m.modal != nil {
return nil
}
labels := make([]string, len(request.Buttons))
for i, button := range request.Buttons {
labels[i] = button.Label
}
dialog := modal.NewModel().SetText(request.Text).AddButtons(labels)
style := m.cfg.Theme.Dialog.Style.Style
if bg := style.GetBackground(); bg != tcell.ColorDefault {
dialog.SetBackgroundColor(bg)
}
if fg := style.GetForeground(); fg != tcell.ColorDefault {
dialog.SetTextColor(fg)
}
dialog.SetButtonStyle(style).SetButtonActivatedStyle(style.Reverse(true))
m.modal = &request
m.AddLayer(dialog,
layers.WithName(modalLayerName),
layers.WithResize(true),
layers.WithVisible(true),
layers.WithOverlay(),
)
m.modal, m.dialogFocus = &request, 0
return nil
}
func (m *Model) finishModal(done modal.DoneMsg) tview.Cmd {
func (m *Model) showLogin() tview.Cmd {
return m.show(login.NewModel(m.cfg))
}
func (m *Model) showChat(token string) tview.Cmd {
return m.show(chat.NewModel(m.cfg, token))
}
// show replaces the inner model, closing any open modal.
func (m *Model) show(inner tview.Model) tview.Cmd {
m.inner = inner
m.modal = nil
return tview.Batch(tview.SetTitle(consts.Name), m.inner.Init())
}
// helpView returns the help for the keybinds that currently apply.
func (m *Model) helpView() help.Widget {
cfg := m.cfg
styles := help.DefaultStyles()
styles.ShortKey = cfg.Theme.Help.ShortKeyStyle.Style
styles.ShortDesc = cfg.Theme.Help.ShortDescStyle.Style
styles.FullKey = cfg.Theme.Help.FullKeyStyle.Style
styles.FullDesc = cfg.Theme.Help.FullDescStyle.Style
return help.New(m).
Styles(styles).
CompactModifiers(cfg.Help.CompactModifiers).
ShortSeparator(cfg.Help.Separator).
ShowAll(m.helpShowAll)
}
type (
dialogFocusMsg int
dialogDoneMsg int
)
// dialogView returns the dialog for the open modal request.
func (m *Model) dialogView() tview.Element {
labels := make([]string, len(m.modal.Buttons))
for i, button := range m.modal.Buttons {
labels[i] = button.Label
}
style := m.cfg.Theme.Dialog.Style.Style
d := dialog.New().
Text(m.modal.Text).
Buttons(labels...).
Focus(m.dialogFocus).
ButtonStyle(style).
ActivatedStyle(style.Reverse(true)).
OnFocus(func(i int) tview.Msg { return dialogFocusMsg(i) }).
OnDone(func(i int, _ string) tview.Msg { return dialogDoneMsg(i) })
if bg := style.GetBackground(); bg != tcell.ColorDefault {
d = d.Background(bg)
}
if fg := style.GetForeground(); fg != tcell.ColorDefault {
d = d.TextColor(fg)
}
return d
}
func (m *Model) toggleHelp() {
if m.cfg.Help.Enabled {
m.helpVisible = !m.helpVisible
}
}
func (m *Model) finishModal(index int) tview.Cmd {
state := m.modal
if state == nil {
return nil
}
var result tview.Msg
if done.ButtonIndex >= 0 && done.ButtonIndex < len(state.Buttons) {
button := state.Buttons[done.ButtonIndex]
if index >= 0 && index < len(state.Buttons) {
button := state.Buttons[index]
if button.KeepOpen {
return func() tview.Msg { return button.Result }
}
result = button.Result
}
m.RemoveLayer(modalLayerName)
m.modal = nil
if result == nil {
return nil
}
return func() tview.Msg { return result }
}
func (m *Model) helpHeight() int {
height := 1
if m.help.ShowAll() {
height = max(len(m.help.FullHelpLines(m.FullHelp(), 0)), 1)
}
return height
}

View file

@ -18,36 +18,26 @@ func TestNewModel(t *testing.T) {
cfg := *cfg
cfg.Help.Enabled = true
m := NewModel(&cfg)
if m.help == nil {
t.Fatal("help model was not created")
if !m.helpVisible {
t.Fatal("help is hidden")
}
if count := m.rootFlex.GetItemCount(); count != 1 {
t.Fatalf("layout item count = %d, want 1", count)
}
if height := m.helpHeight(); height != 1 {
if height := m.helpView().Rows(0); height != 1 {
t.Fatalf("height = %d, want 1", height)
}
m.Update(tcell.NewEventKey(tcell.KeyRune, ".", tcell.ModCtrl))
if !m.help.ShowAll() {
if !m.helpShowAll {
t.Fatal("toggle_full_help did not enable full help")
}
help := m.help
m.Update(tcell.NewEventKey(tcell.KeyRune, ".", tcell.ModAlt))
if m.help != help {
t.Fatal("toggle_help replaced help model")
}
if count := m.rootFlex.GetItemCount(); count != 0 {
t.Fatalf("layout item count = %d, want 0", count)
if m.helpVisible {
t.Fatal("help is visible")
}
m.Update(tcell.NewEventKey(tcell.KeyRune, ".", tcell.ModAlt))
if m.help != help {
t.Fatal("toggle_help replaced help model")
}
if count := m.rootFlex.GetItemCount(); count != 1 {
t.Fatalf("layout item count = %d, want 1", count)
if !m.helpVisible {
t.Fatal("help is hidden")
}
})
@ -55,25 +45,18 @@ func TestNewModel(t *testing.T) {
cfg := *cfg
cfg.Help.Enabled = false
m := NewModel(&cfg)
if m.help != nil {
t.Fatal("help model was created")
}
if count := m.rootFlex.GetItemCount(); count != 0 {
t.Fatalf("layout item count = %d, want 0", count)
if m.helpVisible {
t.Fatal("help is visible")
}
m.Update(tcell.NewEventKey(tcell.KeyRune, ".", tcell.ModCtrl))
if m.help != nil {
t.Fatal("toggle_full_help created help model")
if m.helpShowAll {
t.Fatal("toggle_full_help enabled full help")
}
m.Update(tcell.NewEventKey(tcell.KeyRune, ".", tcell.ModAlt))
if m.help != nil {
t.Fatal("toggle_help created help model")
}
if count := m.rootFlex.GetItemCount(); count != 0 {
t.Fatalf("layout item count = %d, want 0", count)
if m.helpVisible {
t.Fatal("help is visible")
}
})
}

View file

@ -10,7 +10,7 @@ import (
type tokenMsg string
func tokenCmd(token string) tview.Cmd {
func useToken(token string) tview.Cmd {
return func() tview.Msg {
return tokenMsg(token)
}

View file

@ -9,52 +9,49 @@ import (
"github.com/ayn2op/discordo/internal/config"
"github.com/ayn2op/ningen/v3"
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/grid"
"github.com/ayn2op/tview/box"
"github.com/ayn2op/tview/picker"
"github.com/ayn2op/tview/scrollbar"
)
// ConfigureBox configures the provided box according to the provided theme.
func ConfigureBox(box *tview.Box, cfg *config.Theme) {
padding := cfg.Border.Padding
BlurBox(box, cfg)
box.
SetBorderPadding(padding[0], padding[1], padding[2], padding[3]).
SetTitleAlignment(cfg.Title.Alignment.Alignment).
SetFooterAlignment(cfg.Footer.Alignment.Alignment)
if cfg.Border.Enabled {
box.SetBorders(tview.BordersAll)
// Box returns child in a box styled by the theme, highlighted while focused.
func Box(child tview.Element, theme *config.Theme, focused bool) box.Widget {
padding := theme.Border.Padding
b := box.New(child).
Padding(padding[0], padding[1], padding[2], padding[3]).
TitleAlignment(theme.Title.Alignment.Alignment).
FooterAlignment(theme.Footer.Alignment.Alignment)
if theme.Border.Enabled {
b = b.Borders(tview.BordersAll)
}
if focused {
return b.BorderStyle(theme.Border.ActiveStyle.Style).
BorderSet(theme.Border.ActiveSet.BorderSet).
TitleStyle(theme.Title.ActiveStyle.Style).
FooterStyle(theme.Footer.ActiveStyle.Style)
}
return b.BorderStyle(theme.Border.NormalStyle.Style).
BorderSet(theme.Border.NormalSet.BorderSet).
TitleStyle(theme.Title.NormalStyle.Style).
FooterStyle(theme.Footer.NormalStyle.Style)
}
func FocusBox(box *tview.Box, cfg *config.Theme) {
box.SetBorderStyle(cfg.Border.ActiveStyle.Style).
SetBorderSet(cfg.Border.ActiveSet.BorderSet).
SetTitleStyle(cfg.Title.ActiveStyle.Style).
SetFooterStyle(cfg.Footer.ActiveStyle.Style)
// Picker returns a picker of items searched with searchState, with the configured scroll bar and keybinds.
func Picker(items picker.Items, searchState *picker.SearchState, cfg *config.Config) picker.Widget {
bar := scrollbar.New().
TrackStyle(cfg.Theme.ScrollBar.TrackStyle.Style).
ThumbStyle(cfg.Theme.ScrollBar.ThumbStyle.Style).
GlyphSet(cfg.Theme.ScrollBar.GlyphSet.GlyphSet).
Arrows(scrollbar.ArrowsBoth)
return picker.New(items, searchState).
ScrollBar(bar, cfg.Theme.ScrollBar.Visibility.ScrollBarVisibility).
Keybinds(PickerKeybinds(cfg))
}
func BlurBox(box *tview.Box, cfg *config.Theme) {
box.SetBorderStyle(cfg.Border.NormalStyle.Style).
SetBorderSet(cfg.Border.NormalSet.BorderSet).
SetTitleStyle(cfg.Title.NormalStyle.Style).
SetFooterStyle(cfg.Footer.NormalStyle.Style)
}
func ConfigurePicker(model *picker.Model, cfg *config.Config, title string) {
model.Box = tview.NewBox()
ConfigureBox(model.Box, &cfg.Theme)
FocusBox(model.Box, &cfg.Theme)
model.SetTitle(title)
model.SetScrollBarVisibility(cfg.Theme.ScrollBar.Visibility.ScrollBarVisibility)
model.SetScrollBar(tview.NewScrollBar().
SetTrackStyle(cfg.Theme.ScrollBar.TrackStyle.Style).
SetThumbStyle(cfg.Theme.ScrollBar.ThumbStyle.Style).
SetGlyphSet(cfg.Theme.ScrollBar.GlyphSet.GlyphSet))
// PickerKeybinds returns the configured picker keybinds.
func PickerKeybinds(cfg *config.Config) picker.Keybinds {
kbs := cfg.Keybinds.Picker
model.SetKeybinds(picker.Keybinds{
return picker.Keybinds{
Cancel: kbs.Cancel.Keybind,
Select: kbs.Select.Keybind,
@ -62,17 +59,10 @@ func ConfigurePicker(model *picker.Model, cfg *config.Config, title string) {
SelectDown: kbs.SelectDown.Keybind,
SelectTop: kbs.SelectTop.Keybind,
SelectBottom: kbs.SelectBottom.Keybind,
})
}
// Centered creates a new grid with provided primitive aligned in the center.
func Centered(m tview.Model, width, height int) tview.Model {
return grid.NewModel().
SetColumns(0, width, 0).
SetRows(0, height, 0).
AddItem(m, 1, 1, 1, 1, 0, 0, true)
}
}
// ChannelToString returns how channel is shown in the UI: an icon for its type and its name, or the recipients' names for a DM.
func ChannelToString(channel discord.Channel, icons config.Icons, state *ningen.State) string {
var icon string
switch channel.Type {
@ -142,3 +132,9 @@ func getMessageIDFromChannel(channel discord.Channel) discord.MessageID {
}
return discord.MessageID(channel.ID)
}
// IsMe reports whether id is the logged in user.
func IsMe(state *ningen.State, id discord.UserID) bool {
me, _ := state.Cabinet.Me()
return me != nil && id == me.ID
}