diff --git a/cmd/root.go b/cmd/root.go index d88e9e1..7fb3a36 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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() } diff --git a/go.mod b/go.mod index 8a1d0ba..29f1d4c 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 323bfbf..bfda9a3 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go index be73e4c..9efa482 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -69,7 +69,8 @@ type ( } Notifications struct { - Enabled bool `toml:"enabled"` + Enabled bool `toml:"enabled"` + WhenUnfocused bool `toml:"when_unfocused"` } TypingIndicator struct { diff --git a/internal/config/config.toml b/internal/config/config.toml index d0693ae..4bbf60b 100644 --- a/internal/config/config.toml +++ b/internal/config/config.toml @@ -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. diff --git a/internal/config/keybinds.go b/internal/config/keybinds.go index 59c910a..83e3381 100644 --- a/internal/config/keybinds.go +++ b/internal/config/keybinds.go @@ -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), } } diff --git a/internal/config/theme.go b/internal/config/theme.go index 7d7f26d..06b4f2a 100644 --- a/internal/config/theme.go +++ b/internal/config/theme.go @@ -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) } diff --git a/internal/consts/consts.go b/internal/consts/consts.go index a0680da..29fc608 100644 --- a/internal/consts/consts.go +++ b/internal/consts/consts.go @@ -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() -} diff --git a/internal/http/client_standard.go b/internal/http/client_standard.go index 6c28eb4..79c0b0a 100644 --- a/internal/http/client_standard.go +++ b/internal/http/client_standard.go @@ -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() diff --git a/internal/http/headers.go b/internal/http/headers.go index f04b5fb..b10e1b4 100644 --- a/internal/http/headers.go +++ b/internal/http/headers.go @@ -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 { diff --git a/internal/http/properties.go b/internal/http/properties.go index 7762657..a2b6394 100644 --- a/internal/http/properties.go +++ b/internal/http/properties.go @@ -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() diff --git a/internal/http/properties_test.go b/internal/http/properties_test.go index 640d96e..7885c75 100644 --- a/internal/http/properties_test.go +++ b/internal/http/properties_test.go @@ -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) diff --git a/internal/http/transport.go b/internal/http/transport.go index 63cbda2..f56222b 100644 --- a/internal/http/transport.go +++ b/internal/http/transport.go @@ -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 diff --git a/internal/markdown/renderer.go b/internal/markdown/renderer.go index 8a764fd..5e769cb 100644 --- a/internal/markdown/renderer.go +++ b/internal/markdown/renderer.go @@ -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) } diff --git a/internal/ui/chat/attachmentspicker/model.go b/internal/ui/chat/attachmentspicker/model.go index 9abe0e3..1f420fd 100644 --- a/internal/ui/chat/attachmentspicker/model.go +++ b/internal/ui/chat/attachmentspicker/model.go @@ -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 } diff --git a/internal/ui/chat/channelspicker/model.go b/internal/ui/chat/channelspicker/model.go index e25e1b6..7e8ff5c 100644 --- a/internal/ui/chat/channelspicker/model.go +++ b/internal/ui/chat/channelspicker/model.go @@ -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 { diff --git a/internal/ui/chat/composer.go b/internal/ui/chat/composer/model.go similarity index 53% rename from internal/ui/chat/composer.go rename to internal/ui/chat/composer/model.go index 81876f1..91d93a2 100644 --- a/internal/ui/chat/composer.go +++ b/internal/ui/chat/composer/model.go @@ -1,4 +1,4 @@ -package chat +package composer import ( "bytes" @@ -12,6 +12,7 @@ import ( "strings" "time" "unicode" + "unicode/utf8" "github.com/ayn2op/arikawa/v3/api" "github.com/ayn2op/arikawa/v3/discord" @@ -26,11 +27,13 @@ import ( "github.com/ayn2op/discordo/internal/ui/chat/mentionslist" "github.com/ayn2op/ningen/v3" "github.com/ayn2op/tview" + "github.com/ayn2op/tview/box" "github.com/ayn2op/tview/help" "github.com/ayn2op/tview/keybind" - "github.com/ayn2op/tview/text" + "github.com/ayn2op/tview/textarea" "github.com/gdamore/tcell/v3" "github.com/ncruces/zenity" + "github.com/rivo/uniseg" "github.com/sahilm/fuzzy" "github.com/yuin/goldmark/ast" "golang.design/x/clipboard" @@ -44,119 +47,187 @@ const ( var mentionRegex = regexp.MustCompile("@[a-zA-Z0-9._]+") -type composer struct { - *tview.TextArea - chat *Model +type Model struct { + title, footer string + editState textarea.EditState + keybinds textarea.Keybinds + placeholder string + disabled bool + + state *ningen.State + // channel is the selected channel, or nil for none. + channel *discord.Channel cfg *config.Config - edit bool + editing *discord.Message sendMessageData *api.SendMessageData memberSearchCache map[string]uint mentionsList *mentionslist.Model lastSearch time.Time + // height is the number of rows the composer takes, including its border. + height int + // mentionsVisible reports whether the mentions list is shown above the composer. + mentionsVisible bool + typingUntil time.Time } -type tabSuggestMsg struct{} +// TypingDuration is how long Discord shows someone as typing after they start. +const TypingDuration = 10 * time.Second + +// TabSuggestMsg suggests mentions for the word before the cursor. +type TabSuggestMsg struct{} + +// EditLastMsg asks to edit the user's last message. +type EditLastMsg struct{} + +// SentMsg reports that the composer sent a message. +type SentMsg struct{} type editorMsg string -var _ help.KeyMap = (*composer)(nil) +// editMsg edits the composer's text. +type editMsg textarea.Action -func newComposer(cfg *config.Config, chat *Model) *composer { - c := &composer{ - TextArea: tview.NewTextArea(), +var _ help.KeyMap = (*Model)(nil) + +func NewModel(cfg *config.Config, state *ningen.State) *Model { + c := &Model{ + placeholder: "Select a channel to start chatting", + disabled: true, cfg: cfg, - chat: chat, + state: state, sendMessageData: &api.SendMessageData{}, memberSearchCache: make(map[string]uint), mentionsList: mentionslist.NewModel(cfg), + height: 3, } - ui.ConfigureBox(c.Box, &cfg.Theme) - c. - SetPlaceholder(text.NewLine(text.NewSegment("Select a channel to start chatting", tcell.StyleDefault.Dim(true)))). - SetClipboard( - func(s string) { - if _, err := clipboard.Write(context.Background(), clipboard.FmtText, []byte(s)); err != nil { - slog.Error("failed to write to clipboard", "err", err) - return - } - }, - func() string { - data, err := clipboard.Read(context.Background(), clipboard.FmtText) - if err != nil { - slog.Error("failed to read from clipboard", "err", err) - return "" - } - return string(data) - }, - ). - SetDisabled(true) - + c.keybinds = textarea.DefaultKeybinds() + c.keybinds.Newline = cfg.Keybinds.Composer.Newline.Keybind return c } -func (c *composer) forwardToTextArea(ev *tcell.EventKey) tview.Cmd { - cmd := c.TextArea.Update(ev) - c.resizeForContent() - return cmd -} - -func (c *composer) resizeForContent() { - _, _, _, outerH := c.Rect() - _, _, _, innerH := c.InnerRect() - frame := outerH - innerH - _, _, _, parentH := c.chat.rightFlex.InnerRect() - - visible := min( - strings.Count(c.Text(), "\n")+1, - max(c.cfg.Composer.MaxHeight, 1), - max(parentH-frame-1, 1), - ) - c.chat.rightFlex.ResizeItem(c, visible+frame, 1) - c.SetVisibleSize(0, visible) - - total := c.LineCount(0) - row, col := c.Offset() - if total <= visible { - c.SetOffset(0, 0) - } else if offset := total - visible; row > offset { - c.SetOffset(offset, col) +// SetChannel sets the channel messages are sent to, disabling the composer if the user may not send messages there. +func (c *Model) SetChannel(channel *discord.Channel) { + c.channel, c.typingUntil = channel, time.Time{} + isDM := channel.Type == discord.DirectMessage || channel.Type == discord.GroupDM + c.disabled = !isDM && !c.state.HasPermissions(channel.ID, discord.PermissionSendMessages) + c.placeholder = "Message..." + if c.disabled { + c.placeholder = "You do not have permission to send messages in this channel." } } -func (c *composer) reset() { - c.edit = false +// Height returns the number of rows the composer takes, including its border. +func (c *Model) Height() int { + return c.height +} + +func (c *Model) Disabled() bool { + return c.disabled +} + +// View shows the text area in a box, taking keys if focused. +func (c *Model) View(focused bool) tview.Element { return view{c, focused} } + +// view is the composer as shown, with or without the focus. +type view struct { + *Model + focused bool +} + +func (v view) Draw(screen tview.Screen, area tview.Rectangle) { + v.box(v.focused).Draw(screen, area) +} + +// Handle leaves bound keys to Update and passes other messages to the text area. +func (v view) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg { + if key, ok := msg.(tview.KeyMsg); ok && v.isBound(key) { + return msg + } + return v.box(v.focused).Handle(msg, area) +} + +// box returns the text area in a box with the title and footer. +func (c *Model) box(focused bool) box.Widget { + text := textarea.New(&c.editState). + Placeholder(c.placeholder). + Keybinds(c.keybinds). + Focused(focused && !c.disabled). + OnAction(func(a textarea.Action) tview.Msg { return editMsg(a) }) + return ui.Box(text, &c.cfg.Theme, focused).Title(c.title).Footer(c.footer) +} + +// isBound reports whether key is one of the composer's keybinds. +func (c *Model) isBound(key tview.KeyMsg) bool { + k := c.cfg.Keybinds.Composer + if keybind.Matches(key, k.Paste.Keybind, k.Send.Keybind, k.OpenEditor.Keybind, k.OpenFilePicker.Keybind, k.Cancel.Keybind, k.TabComplete.Keybind, k.ToggleReplyMention.Keybind, k.Undo.Keybind) { + return true + } + return keybind.Matches(key, k.EditLast.Keybind) && c.canEditLastMessage() +} + +// insert inserts text at the cursor. +func (c *Model) insert(text string) { + c.replace(c.editState.Cursor(), c.editState.Cursor(), text) +} + +// pasteText inserts the text on the clipboard at the cursor. +func (c *Model) pasteText() { + data, err := clipboard.Read(context.Background(), clipboard.FmtText) + if err != nil { + slog.Error("failed to read from clipboard", "err", err) + return + } + c.insert(string(data)) +} + +// frame returns the cells the composer's border and padding take on each side. +func (c *Model) frame() (left, vertical int) { + const size = 100 + inner := c.box(false).InnerArea(tview.Rectangle{Width: size, Height: size}) + return inner.X, size - inner.Height +} + +func (c *Model) resizeForContent() { + _, frame := c.frame() + c.height = min(strings.Count(c.editState.Value(), "\n")+1, max(c.cfg.Composer.MaxHeight, 1)) + frame +} + +func (c *Model) reset() { + c.editing = nil c.sendMessageData = &api.SendMessageData{} - c.SetTitle("") - c.SetFooter("") - c.SetText("", true) + c.setTitle("") + c.setFooter("") + c.setText("") } -func (c *composer) SetText(text string, cursorAtTheEnd bool) *tview.TextArea { - defer c.resizeForContent() - return c.TextArea.SetText(text, cursorAtTheEnd) +// setText replaces the text and moves the cursor to its end. +func (c *Model) setText(text string) { + c.editState.SetValue(text) + c.resizeForContent() } -func (c *composer) Replace(start, end int, text string) *tview.TextArea { - defer c.resizeForContent() - return c.TextArea.Replace(start, end, text) +// replace replaces the bytes from start to end of the text and moves the cursor after it. +func (c *Model) replace(start, end int, text string) { + c.editState.Replace(start, end, text) + c.resizeForContent() } -func (c *composer) SetTitle(title string) *tview.Box { - defer c.resizeForContent() - return c.Box.SetTitle(title) +func (c *Model) setTitle(title string) { + c.title = title + c.resizeForContent() } -func (c *composer) SetFooter(footer string) *tview.Box { - defer c.resizeForContent() - return c.Box.SetFooter(footer) +func (c *Model) setFooter(footer string) { + c.footer = footer + c.resizeForContent() } -func (c *composer) Update(msg tview.Msg) tview.Cmd { +func (c *Model) Update(msg tview.Msg) tview.Cmd { switch msg := msg.(type) { - case tabSuggestMsg: + case TabSuggestMsg: return c.tabSuggest() case imagePastedMsg: if len(msg) == 0 { @@ -165,8 +236,8 @@ func (c *composer) Update(msg tview.Msg) tview.Cmd { c.attach(imageAttachmentName, bytes.NewReader(msg)) return nil case filesPickedMsg: - selectedChannel, ok := c.chat.SelectedChannel() - if !ok || selectedChannel.ID != msg.channelID { + selectedChannel := c.channel + if selectedChannel == nil || selectedChannel.ID != msg.channelID { return closeFiles(msg.files) } for _, file := range msg.files { @@ -174,93 +245,89 @@ func (c *composer) Update(msg tview.Msg) tview.Cmd { } return nil case editorMsg: - c.SetText(string(msg), true) + c.setText(string(msg)) return nil + case editMsg: + c.editState.Perform(textarea.Action(msg)) + c.resizeForContent() + typingCmd := c.sendTyping() + if c.cfg.AutocompleteLimit > 0 { + return tview.Batch(typingCmd, c.tabSuggest()) + } + return typingCmd case tview.KeyMsg: switch { case keybind.Matches(msg, c.cfg.Keybinds.Composer.EditLast.Keybind) && c.canEditLastMessage(): return c.editLastMessage() case keybind.Matches(msg, c.cfg.Keybinds.Composer.Paste.Keybind): - return tview.Sequence(pasteImage(), c.forwardToTextArea(tcell.NewEventKey(tcell.KeyCtrlV, "", tcell.ModNone))) - case keybind.Matches(msg, c.cfg.Keybinds.Composer.Newline.Keybind): - return c.forwardToTextArea(tcell.NewEventKey(tcell.KeyEnter, "", tcell.ModNone)) + c.pasteText() + return pasteImage() case keybind.Matches(msg, c.cfg.Keybinds.Composer.Send.Keybind): - if c.chat.GetVisible(mentionsListLayerName) { + if c.mentionsVisible { return c.tabComplete() } return c.send() case keybind.Matches(msg, c.cfg.Keybinds.Composer.OpenEditor.Keybind): - return tview.Sequence(c.stopTabCompletion(), c.editor()) + return tview.Sequence(c.stopTabCompletion(), c.openEditor()) case keybind.Matches(msg, c.cfg.Keybinds.Composer.OpenFilePicker.Keybind): return tview.Sequence(c.stopTabCompletion(), c.pickFiles()) case keybind.Matches(msg, c.cfg.Keybinds.Composer.Cancel.Keybind): - if c.chat.GetVisible(mentionsListLayerName) { + if c.mentionsVisible { return c.stopTabCompletion() } c.reset() return nil case keybind.Matches(msg, c.cfg.Keybinds.Composer.TabComplete.Keybind): - if c.chat.GetVisible(mentionsListLayerName) { + if c.mentionsVisible { return c.tabComplete() } - return c.forwardToTextArea(msg) + c.insert("\t") + return nil case keybind.Matches(msg, c.cfg.Keybinds.Composer.ToggleReplyMention.Keybind): c.toggleReplyMention() return nil case keybind.Matches(msg, c.cfg.Keybinds.Composer.Undo.Keybind): - return c.forwardToTextArea(tcell.NewEventKey(tcell.KeyCtrlZ, "", tcell.ModNone)) + c.editState.Undo() + c.resizeForContent() + return nil } - - typingCmd := c.sendTyping() - - if c.cfg.AutocompleteLimit > 0 { - if c.chat.GetVisible(mentionsListLayerName) { - keybinds := c.cfg.Keybinds.MentionsList - if keybind.Matches(msg, keybinds.SelectUp.Keybind) || - keybind.Matches(msg, keybinds.SelectDown.Keybind) || - keybind.Matches(msg, keybinds.SelectTop.Keybind) || - keybind.Matches(msg, keybinds.SelectBottom.Keybind) { - return tview.Batch(typingCmd, c.mentionsList.Update(msg)) - } - } - - return tview.Batch(typingCmd, tview.Sequence(c.forwardToTextArea(msg), c.tabSuggest())) - } - return tview.Batch(typingCmd, c.forwardToTextArea(msg)) - } - return c.TextArea.Update(msg) -} - -func (c *composer) canEditLastMessage() bool { - return !c.Disabled() && !c.edit && c.Text() == "" && - c.sendMessageData.Reference == nil && len(c.sendMessageData.Files) == 0 && - !c.chat.GetVisible(mentionsListLayerName) -} - -func (c *composer) editLastMessage() tview.Cmd { - channel, ok := c.chat.SelectedChannel() - if !ok { - return nil - } - ml := c.chat.messagesList - for i, item := range slices.Backward(ml.items) { - if item.separator { - continue - } - message := item.message - if message.ChannelID != channel.ID || !c.chat.isMe(message.Author.ID) || - !message.ID.IsValid() || message.Content == "" || len(message.MessageSnapshots) > 0 || - (message.Type != discord.DefaultMessage && message.Type != discord.InlinedReplyMessage) { - continue - } - ml.SetCursor(i) - return ml.editSelectedMessage() + case mentionslist.Msg: + return c.mentionsList.Update(msg) } return nil } -func (c *composer) toggleReplyMention() { +func (c *Model) canEditLastMessage() bool { + return !c.Disabled() && c.editing == nil && c.editState.Value() == "" && + c.sendMessageData.Reference == nil && len(c.sendMessageData.Files) == 0 && + !c.mentionsVisible +} + +func (c *Model) editLastMessage() tview.Cmd { + return func() tview.Msg { return EditLastMsg{} } +} + +// StartEdit puts message in the composer to be edited. +func (c *Model) StartEdit(message discord.Message) { + c.editing = &message + c.setTitle("Editing") + c.setText(message.Content) +} + +// StartReply makes the next message a reply to message by name, mentioning its author if mention is set. +func (c *Model) StartReply(message discord.Message, name string, mention bool) { + data := c.sendMessageData + data.Reference = &discord.MessageReference{MessageID: message.ID} + data.AllowedMentions = &api.AllowedMentions{RepliedUser: option.Some(mention)} + title := "Replying to " + if mention { + title = "[@] " + title + } + c.setTitle(title + name) +} + +func (c *Model) toggleReplyMention() { data := c.sendMessageData if data.Reference == nil || data.AllowedMentions == nil || data.AllowedMentions.RepliedUser == nil { return @@ -268,11 +335,11 @@ func (c *composer) toggleReplyMention() { mention := !*data.AllowedMentions.RepliedUser data.AllowedMentions.RepliedUser = option.Some(mention) - title := strings.TrimPrefix(c.Title(), "[@] ") + title := strings.TrimPrefix(c.title, "[@] ") if mention { title = "[@] " + title } - c.SetTitle(title) + c.setTitle(title) } type imagePastedMsg []byte @@ -293,9 +360,9 @@ type filesPickedMsg struct { files []sendpart.File } -func (c *composer) pickFiles() tview.Cmd { - selectedChannel, ok := c.chat.SelectedChannel() - if !ok { +func (c *Model) pickFiles() tview.Cmd { + selectedChannel := c.channel + if selectedChannel == nil { return nil } channelID := selectedChannel.ID @@ -334,7 +401,7 @@ func closeFiles(files []sendpart.File) tview.Cmd { } } -func (c *composer) sendTyping() tview.Cmd { +func (c *Model) sendTyping() tview.Cmd { if !c.cfg.TypingIndicator.Send { return nil } @@ -343,26 +410,26 @@ func (c *composer) sendTyping() tview.Cmd { if now.Before(c.typingUntil) { return nil } - c.typingUntil = now.Add(typingDuration) + c.typingUntil = now.Add(TypingDuration) - selectedChannel, ok := c.chat.SelectedChannel() - if !ok { + selectedChannel := c.channel + if selectedChannel == nil { return nil } channelID := selectedChannel.ID return func() tview.Msg { - c.chat.state.Typing(channelID) + c.state.Typing(channelID) return nil } } -func (c *composer) send() tview.Cmd { - selectedChannel, ok := c.chat.SelectedChannel() - if !ok { +func (c *Model) send() tview.Cmd { + selectedChannel := c.channel + if selectedChannel == nil { return nil } - text := strings.TrimSpace(c.Text()) + text := strings.TrimSpace(c.editState.Value()) if text == "" && len(c.sendMessageData.Files) == 0 { return nil } @@ -371,39 +438,29 @@ func (c *composer) send() tview.Cmd { data := *c.sendMessageData data.Files = slices.Clone(data.Files) - var editMessage discord.Message - edit := c.edit - if edit { - selectedMessage, ok := c.chat.messagesList.selectedMessage() - if !ok { - return nil - } - editMessage = *selectedMessage - } - + editing := c.editing c.typingUntil = time.Time{} c.reset() - c.chat.messagesList.clearSelection() - c.chat.messagesList.ScrollBottom() - return func() tview.Msg { + sent := func() tview.Msg { return SentMsg{} } + return tview.Batch(sent, func() tview.Msg { defer closeFiles(data.Files)() - if edit { + if editing != nil { editData := api.EditMessageData{Content: option.SomeNullable(text)} - if _, err := c.chat.state.EditMessageComplex(editMessage.ChannelID, editMessage.ID, editData); err != nil { + if _, err := c.state.EditMessageComplex(editing.ChannelID, editing.ID, editData); err != nil { slog.Error("failed to edit message", "err", err) } return nil } data.Content = text - if _, err := c.chat.state.SendMessageComplex(selectedChannel.ID, data); err != nil { + if _, err := c.state.SendMessageComplex(selectedChannel.ID, data); err != nil { slog.Error("failed to send message in channel", "channel_id", selectedChannel.ID, "err", err) } return nil - } + }) } -func (c *composer) processText(channel *discord.Channel, src []byte) string { +func (c *Model) processText(channel *discord.Channel, src []byte) string { // Fast path: no mentions to expand. if bytes.IndexByte(src, '@') == -1 { return string(src) @@ -443,8 +500,8 @@ func (c *composer) processText(channel *discord.Channel, src []byte) string { return string(src) } -func (c *composer) expandMentions(channel *discord.Channel, src []byte) []byte { - state := c.chat.state +func (c *Model) expandMentions(channel *discord.Channel, src []byte) []byte { + state := c.state return mentionRegex.ReplaceAllFunc(src, func(input []byte) []byte { output := input name := string(input[1:]) @@ -474,19 +531,31 @@ func (c *composer) expandMentions(channel *discord.Channel, src []byte) []byte { }) } +// wordBeforeCursor returns cursor, the runes satisfying f just before it, and the rune before those. +func wordBeforeCursor(value string, cursor int, f func(rune) bool) (int, string, rune) { + for start := cursor; start > 0; { + r, size := utf8.DecodeLastRuneInString(value[:start]) + if !f(r) { + return cursor, value[start:cursor], r + } + start -= size + } + return cursor, value[:cursor], 0 +} + func isMentionChar(r rune) bool { return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '.' } -func (c *composer) tabComplete() tview.Cmd { - posEnd, name, r := c.GetWordUnderCursor(isMentionChar) +func (c *Model) tabComplete() tview.Cmd { + posEnd, name, r := wordBeforeCursor(c.editState.Value(), c.editState.Cursor(), isMentionChar) if r != '@' { return c.stopTabCompletion() } pos := posEnd - (len(name) + 1) - selectedChannel, ok := c.chat.SelectedChannel() - if !ok { + selectedChannel := c.channel + if selectedChannel == nil { return nil } gID := selectedChannel.GuildID @@ -496,11 +565,11 @@ func (c *composer) tabComplete() tview.Cmd { users := selectedChannel.DMRecipients res := fuzzy.FindFrom(name, userList(users)) if len(res) > 0 { - c.Replace(pos, posEnd, "@"+users[res[0].Index].Username+" ") + c.replace(pos, posEnd, "@"+users[res[0].Index].Username+" ") } } else { cmd := c.searchMember(gID, name) - members, err := c.chat.state.Cabinet.Members(gID) + members, err := c.state.Cabinet.Members(gID) if err != nil { slog.Error("failed to get members from state", "guild_id", gID, "err", err) return cmd @@ -508,8 +577,8 @@ func (c *composer) tabComplete() tview.Cmd { res := fuzzy.FindFrom(name, memberList(members)) for _, r := range res { - if channelHasUser(c.chat.state, selectedChannel.ID, members[r.Index].User.ID) { - c.Replace(pos, posEnd, "@"+members[r.Index].User.Username+" ") + if channelHasUser(c.state, selectedChannel.ID, members[r.Index].User.ID) { + c.replace(pos, posEnd, "@"+members[r.Index].User.Username+" ") return cmd } } @@ -520,80 +589,39 @@ func (c *composer) tabComplete() tview.Cmd { if c.mentionsList.ItemCount() == 0 { return nil } - name, ok = c.mentionsList.SelectedInsertText() + name, ok := c.mentionsList.SelectedInsertText() if !ok { return nil } - c.Replace(pos, posEnd, "@"+name+" ") + c.replace(pos, posEnd, "@"+name+" ") return c.stopTabCompletion() } -func (c *composer) tabSuggest() tview.Cmd { - _, name, r := c.GetWordUnderCursor(isMentionChar) +func (c *Model) tabSuggest() tview.Cmd { + _, name, r := wordBeforeCursor(c.editState.Value(), c.editState.Cursor(), isMentionChar) if r != '@' { return c.stopTabCompletion() } - selectedChannel, ok := c.chat.SelectedChannel() - if !ok { + channel := c.channel + if channel == nil { return nil } - gID := selectedChannel.GuildID - cID := selectedChannel.ID c.mentionsList.Clear() - var shown map[string]struct{} - var userDone struct{} - if name == "" { - shown = make(map[string]struct{}) - // Don't show @me in the list of recent authors - me, _ := c.chat.state.Cabinet.Me() - shown[me.Username] = userDone - } - - // DMs have recipients, not members + gID := channel.GuildID switch { + case name == "": + c.suggestRecentAuthors(channel) case !gID.IsValid(): - if name == "" { // show recent messages' authors - msgs, err := c.chat.state.Cabinet.Messages(cID) - if err != nil { - return nil - } - for _, m := range msgs { - if _, ok := shown[m.Author.Username]; ok { - continue - } - shown[m.Author.Username] = userDone - c.addMentionUser(&m.Author) - } - } else { - users := selectedChannel.DMRecipients - me, _ := c.chat.state.Cabinet.Me() - users = append(users, *me) - res := fuzzy.FindFrom(name, userList(users)) - for _, r := range res { - c.addMentionUser(&users[r.Index]) - } - } - case name == "": // show recent messages' authors - msgs, err := c.chat.state.Cabinet.Messages(cID) - if err != nil { - return nil - } - for _, m := range msgs { - if _, ok := shown[m.Author.Username]; ok { - continue - } - shown[m.Author.Username] = userDone - c.chat.state.MemberState.RequestMember(gID, m.Author.ID) - if mem, err := c.chat.state.Cabinet.Member(gID, m.Author.ID); err == nil { - if c.addMentionMember(gID, mem) { - break - } - } + // DMs have recipients, not members. + me, _ := c.state.Cabinet.Me() + users := append(slices.Clone(channel.DMRecipients), *me) + for _, r := range fuzzy.FindFrom(name, userList(users)) { + c.addMentionUser(&users[r.Index]) } default: searchCmd := c.searchMember(gID, name) - mems, err := c.chat.state.Cabinet.Members(gID) + mems, err := c.state.Cabinet.Members(gID) if err != nil { slog.Error("fetching members failed", "err", err) return searchCmd @@ -603,7 +631,7 @@ func (c *composer) tabSuggest() tview.Cmd { res = res[:int(c.cfg.AutocompleteLimit)] } for _, r := range res { - if channelHasUser(c.chat.state, cID, mems[r.Index].User.ID) && + if channelHasUser(c.state, channel.ID, mems[r.Index].User.ID) && c.addMentionMember(gID, &mems[r.Index]) { break } @@ -616,11 +644,34 @@ func (c *composer) tabSuggest() tview.Cmd { if c.mentionsList.ItemCount() == 0 { return c.stopTabCompletion() } - c.mentionsList.Rebuild() return c.showMentionsList() } +// suggestRecentAuthors suggests the authors of the channel's recent messages, other than the user. +func (c *Model) suggestRecentAuthors(channel *discord.Channel) { + messages, err := c.state.Cabinet.Messages(channel.ID) + if err != nil { + return + } + me, _ := c.state.Cabinet.Me() + shown := map[discord.UserID]bool{me.ID: true} + for _, m := range messages { + if shown[m.Author.ID] { + continue + } + shown[m.Author.ID] = true + if !channel.GuildID.IsValid() { + c.addMentionUser(&m.Author) + continue + } + c.state.MemberState.RequestMember(channel.GuildID, m.Author.ID) + if member, err := c.state.Cabinet.Member(channel.GuildID, m.Author.ID); err == nil && c.addMentionMember(channel.GuildID, member) { + return + } + } +} + type memberList []discord.Member type userList []discord.User @@ -652,7 +703,7 @@ func channelHasUser(state *ningen.State, channelID discord.ChannelID, userID dis // searchMember performs member discovery in a command goroutine. // It emits a follow-up suggestion message once results are loaded. -func (c *composer) searchMember(gID discord.GuildID, name string) tview.Cmd { +func (c *Model) searchMember(gID discord.GuildID, name string) tview.Cmd { if name == "" { return nil } @@ -661,12 +712,10 @@ func (c *composer) searchMember(gID discord.GuildID, name string) tview.Cmd { if _, ok := c.memberSearchCache[key]; ok { return nil } - // If searching for "ab" returns less than SearchLimit, - // then "abc" would not return anything new because we already searched - // everything starting with "ab". This will still be true even if a new - // member joins because arikawa loads new members into the state. + // If searching for "ab" returns less than SearchLimit, then "abc" would not return anything new because we already searched everything starting with "ab". + // This will still be true even if a new member joins because arikawa loads new members into the state. if count, ok := c.memberSearchCache[key[:len(key)-1]]; ok { - if count < c.chat.state.MemberState.SearchLimit { + if count < c.state.MemberState.SearchLimit { c.memberSearchCache[key] = count return nil } @@ -674,18 +723,18 @@ func (c *composer) searchMember(gID discord.GuildID, name string) tview.Cmd { now := time.Now() // Rate limit on our side because we can't distinguish between a successful search and SearchMember not doing anything because of its internal rate limit that we can't detect - if c.lastSearch.Add(c.chat.state.MemberState.SearchFrequency).After(now) { + if c.lastSearch.Add(c.state.MemberState.SearchFrequency).After(now) { return nil } c.lastSearch = now nonce := memberSearchNonce + key return func() tview.Msg { - if err := c.chat.state.SendGateway(context.Background(), &gateway.RequestGuildMembersCommand{ + if err := c.state.SendGateway(context.Background(), &gateway.RequestGuildMembersCommand{ GuildIDs: []discord.GuildID{gID}, Query: option.Some(name), - Presences: c.chat.state.MemberState.RequestPresences, - Limit: c.chat.state.MemberState.SearchLimit, + Presences: c.state.MemberState.RequestPresences, + Limit: c.state.MemberState.SearchLimit, Nonce: nonce, }); err != nil { slog.Error("failed to search guild members", "err", err, "guild_id", gID, "query", name) @@ -694,7 +743,7 @@ func (c *composer) searchMember(gID discord.GuildID, name string) tview.Cmd { } } -func (c *composer) onGuildMembersChunk(event *gateway.GuildMembersChunkEvent) tview.Cmd { +func (c *Model) CacheMemberSearch(event *gateway.GuildMembersChunkEvent) tview.Cmd { key, ok := strings.CutPrefix(event.Nonce, memberSearchNonce) if !ok { return nil @@ -702,42 +751,76 @@ func (c *composer) onGuildMembersChunk(event *gateway.GuildMembersChunkEvent) tv c.memberSearchCache[key] = uint(len(event.Members)) return func() tview.Msg { - return tabSuggestMsg{} + return TabSuggestMsg{} } } -func (c *composer) showMentionsList() tview.Cmd { +func (c *Model) showMentionsList() tview.Cmd { + c.mentionsVisible = true + return nil +} + +// OnGuildMemberRemove forgets cached member searches that the removed member may have filled. +func (c *Model) OnGuildMemberRemove(event *gateway.GuildMemberRemoveEvent) { + for name := event.GuildID.String() + " " + event.User.Username; name != ""; name = name[:len(name)-1] { + if count, ok := c.memberSearchCache[name]; ok && count >= c.state.MemberState.SearchLimit { + // A full result set may now be missing members; search these prefixes again. + for name != "" { + delete(c.memberSearchCache, name) + name = name[:len(name)-1] + } + return + } + } +} + +// MentionsView places the mentions list just above the composer, near the cursor, or returns nil if it is hidden. +func (c *Model) MentionsView() tview.Element { + if !c.mentionsVisible { + return nil + } + return mentionsPopup{c} +} + +// mentionsPopup lays out the mentions list within the area above the composer, where it is drawn over the messages. +type mentionsPopup struct { + c *Model +} + +func (p mentionsPopup) Draw(screen tview.Screen, area tview.Rectangle) { + p.c.mentionsList.View().Draw(screen, p.area(area)) +} + +func (p mentionsPopup) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg { + return p.c.mentionsList.View().Handle(msg, p.area(area)) +} + +// area returns where the list goes in area, the messages and composer together. +func (p mentionsPopup) area(area tview.Rectangle) tview.Rectangle { + c := p.c borders := 0 if c.cfg.Theme.Border.Enabled { borders = 1 } - l := c.mentionsList - x, _, _, _ := c.InnerRect() - _, y, _, _ := c.Rect() - _, _, maxW, maxH := c.chat.messagesList.InnerRect() + x, _ := c.frame() + maxW, maxH := area.Width, area.Height-c.height if t := int(c.cfg.Theme.MentionsList.MaxHeight); t != 0 { maxH = min(maxH, t) } - count := c.mentionsList.ItemCount() + borders - h := min(count, maxH) + borders + c.cfg.Theme.Border.Padding[1] - y -= h + h := min(c.mentionsList.ItemCount()+borders, maxH) + borders + c.cfg.Theme.Border.Padding[1] w := int(c.cfg.Theme.MentionsList.MinWidth) if w == 0 { w = maxW } else { - w = max(w, c.mentionsList.MaxDisplayWidth()) - - w = min(w+borders*2, maxW) - _, col, _, _ := c.GetCursor() + w = min(max(w, c.mentionsList.MaxDisplayWidth())+borders*2, maxW) + value, cursor := c.editState.Value(), c.editState.Cursor() + col := uniseg.StringWidth(value[strings.LastIndex(value[:cursor], "\n")+1 : cursor]) x += min(col, maxW-w) } - - l.SetRect(x, y, w, h) - c.chat.ShowLayer(mentionsListLayerName).SendToFront(mentionsListLayerName) - return nil + return tview.Rectangle{X: area.X + x, Y: area.Y + area.Height - c.height - h, Width: w, Height: h} } -func (c *composer) addMentionMember(gID discord.GuildID, m *discord.Member) bool { +func (c *Model) addMentionMember(gID discord.GuildID, m *discord.Member) bool { if m == nil { return false } @@ -751,14 +834,14 @@ func (c *composer) addMentionMember(gID discord.GuildID, m *discord.Member) bool // This avoids a slower member color lookup path. color, ok := state.MemberColor(m, func(id discord.RoleID) *discord.Role { - r, _ := c.chat.state.Cabinet.Role(gID, id) + r, _ := c.state.Cabinet.Role(gID, id) return r }) if ok { style = style.Foreground(tcell.NewHexColor(int32(color))) } - presence, err := c.chat.state.Cabinet.Presence(gID, m.User.ID) + presence, err := c.state.Cabinet.Presence(gID, m.User.ID) if err != nil { slog.Info("failed to get presence from state", "guild_id", gID, "user_id", m.User.ID, "err", err) } else if presence.Status == discord.OfflineStatus { @@ -773,14 +856,14 @@ func (c *composer) addMentionMember(gID discord.GuildID, m *discord.Member) bool return c.mentionsList.ItemCount() > int(c.cfg.AutocompleteLimit) } -func (c *composer) addMentionUser(user *discord.User) { +func (c *Model) addMentionUser(user *discord.User) { if user == nil { return } name := user.DisplayOrUsername() style := tcell.StyleDefault - presence, err := c.chat.state.Cabinet.Presence(discord.NullGuildID, user.ID) + presence, err := c.state.Cabinet.Presence(discord.NullGuildID, user.ID) if err != nil { slog.Info("failed to get presence from state", "user_id", user.ID, "err", err) } else if presence.Status == discord.OfflineStatus { @@ -794,29 +877,27 @@ func (c *composer) addMentionUser(user *discord.User) { }) } -func (c *composer) removeMentionsList() { - if c.chat.GetVisible(mentionsListLayerName) { - c.chat.HideLayer(mentionsListLayerName) - } +func (c *Model) CloseMentions() { + c.mentionsVisible = false } -func (c *composer) stopTabCompletion() tview.Cmd { +func (c *Model) stopTabCompletion() tview.Cmd { if c.cfg.AutocompleteLimit > 0 { c.mentionsList.Clear() - c.removeMentionsList() + c.CloseMentions() return nil } return nil } -func (c *composer) editor() tview.Cmd { +func (c *Model) openEditor() tview.Cmd { if c.cfg.Editor == "" { return func() tview.Msg { slog.Warn("Attempt to open file with editor, but no editor is set") return nil } } - text := c.Text() + text := c.editState.Value() cfg := c.cfg return tview.Suspend(func() tview.Msg { file, err := os.CreateTemp("", tmpFilePattern) @@ -850,23 +931,22 @@ func (c *composer) editor() tview.Cmd { }) } -func (c *composer) attach(name string, reader io.Reader) { +func (c *Model) attach(name string, reader io.Reader) { c.sendMessageData.Files = append(c.sendMessageData.Files, sendpart.File{Name: name, Reader: reader}) var names []string for _, file := range c.sendMessageData.Files { names = append(names, file.Name) } - c.SetFooter("Attached " + humanJoin(names)) + c.setFooter("Attached " + humanJoin(names)) } -func (c *composer) canAttachFiles() bool { - selectedChannel, ok := c.chat.SelectedChannel() - return ok && c.chat.state.HasPermissions(selectedChannel.ID, discord.PermissionAttachFiles) +func (c *Model) canAttachFiles() bool { + return c.channel != nil && c.state.HasPermissions(c.channel.ID, discord.PermissionAttachFiles) } -func (c *composer) ShortHelp() []keybind.Keybind { - if c.chat.GetVisible(mentionsListLayerName) { +func (c *Model) ShortHelp() []keybind.Keybind { + if c.mentionsVisible { cfg := c.cfg.Keybinds.MentionsList ccfg := c.cfg.Keybinds.Composer short := []keybind.Keybind{cfg.SelectUp.Keybind, cfg.SelectDown.Keybind, ccfg.TabComplete.Keybind, ccfg.Cancel.Keybind} @@ -890,8 +970,8 @@ func (c *composer) ShortHelp() []keybind.Keybind { return short } -func (c *composer) FullHelp() [][]keybind.Keybind { - if c.chat.GetVisible(mentionsListLayerName) { +func (c *Model) FullHelp() [][]keybind.Keybind { + if c.mentionsVisible { mcfg := c.cfg.Keybinds.MentionsList ccfg := c.cfg.Keybinds.Composer return [][]keybind.Keybind{ diff --git a/internal/ui/chat/composer/model_test.go b/internal/ui/chat/composer/model_test.go new file mode 100644 index 0000000..69a2f3b --- /dev/null +++ b/internal/ui/chat/composer/model_test.go @@ -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) + } +} diff --git a/internal/ui/chat/composer_test.go b/internal/ui/chat/composer_test.go deleted file mode 100644 index f1576de..0000000 --- a/internal/ui/chat/composer_test.go +++ /dev/null @@ -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) - } -} diff --git a/internal/ui/chat/guildstree/model.go b/internal/ui/chat/guildstree/model.go index 00d1cad..b6cad20 100644 --- a/internal/ui/chat/guildstree/model.go +++ b/internal/ui/chat/guildstree/model.go @@ -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 diff --git a/internal/ui/chat/guildstree/msg.go b/internal/ui/chat/guildstree/msg.go index 2a92807..51691fb 100644 --- a/internal/ui/chat/guildstree/msg.go +++ b/internal/ui/chat/guildstree/msg.go @@ -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 } diff --git a/internal/ui/chat/keybinds.go b/internal/ui/chat/keymap.go similarity index 88% rename from internal/ui/chat/keybinds.go rename to internal/ui/chat/keymap.go index 22e2cbb..4e4fcb5 100644 --- a/internal/ui/chat/keybinds.go +++ b/internal/ui/chat/keymap.go @@ -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 { diff --git a/internal/ui/chat/mentionslist/model.go b/internal/ui/chat/mentionslist/model.go index 474b79f..15ee872 100644 --- a/internal/ui/chat/mentionslist/model.go +++ b/internal/ui/chat/mentionslist/model.go @@ -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 } diff --git a/internal/ui/chat/messages_list.go b/internal/ui/chat/messageslist/model.go similarity index 66% rename from internal/ui/chat/messages_list.go rename to internal/ui/chat/messageslist/model.go index d864815..0425b3d 100644 --- a/internal/ui/chat/messages_list.go +++ b/internal/ui/chat/messageslist/model.go @@ -1,4 +1,4 @@ -package chat +package messageslist import ( "context" @@ -17,24 +17,23 @@ import ( "time" "unicode/utf8" - "github.com/ayn2op/tview/layers" - - "github.com/ayn2op/arikawa/v3/api" "github.com/ayn2op/arikawa/v3/discord" "github.com/ayn2op/arikawa/v3/gateway" md "github.com/ayn2op/arikawa/v3/markdown" "github.com/ayn2op/arikawa/v3/state" - "github.com/ayn2op/arikawa/v3/utils/json/option" "github.com/ayn2op/discordo/internal/config" "github.com/ayn2op/discordo/internal/consts" "github.com/ayn2op/discordo/internal/markdown" "github.com/ayn2op/discordo/internal/ui" "github.com/ayn2op/discordo/internal/ui/chat/attachmentspicker" + "github.com/ayn2op/ningen/v3" "github.com/ayn2op/tview" "github.com/ayn2op/tview/help" "github.com/ayn2op/tview/keybind" "github.com/ayn2op/tview/list" - "github.com/ayn2op/tview/text" + "github.com/ayn2op/tview/richtext" + "github.com/ayn2op/tview/scrollbar" + "github.com/ayn2op/tview/textview" "github.com/gdamore/tcell/v3" "github.com/gdamore/tcell/v3/color" "github.com/ncruces/zenity" @@ -51,65 +50,145 @@ type messageItem struct { timestamp discord.Timestamp } -type messagesList struct { - *list.Model +type Model struct { + title, footer string + selectionState list.SelectionState + + scrollBar scrollbar.Widget + scrollBarVisibility list.ScrollBarVisibility + keybinds list.Keybinds + cfg *config.Config - chat *Model - items []messageItem + state *ningen.State + // channel is the selected channel, or nil for none. + channel *discord.Channel + items []messageItem renderer *markdown.Renderer - attachmentsPicker *attachmentspicker.Model + // renderWidth is the width the message being rendered is laid out at. + renderWidth int } -var _ help.KeyMap = (*messagesList)(nil) +var _ help.KeyMap = (*Model)(nil) -func newMessagesList(cfg *config.Config, chat *Model) *messagesList { - ml := &messagesList{ - Model: list.NewModel(), - cfg: cfg, - chat: chat, - renderer: markdown.NewRenderer(cfg), +func NewModel(cfg *config.Config, state *ningen.State) *Model { + ml := &Model{ + title: "Messages", + selectionState: list.NewSelectionState(), + cfg: cfg, + state: state, + renderer: markdown.NewRenderer(cfg), } - ml.attachmentsPicker = attachmentspicker.NewModel(cfg) - ui.ConfigureBox(ml.Box, &cfg.Theme) - ml.SetTitle("Messages") - ml.SetBuilder(ml.buildItem) - ml.SetTrackEnd(true) - ml.SetSelectedStyle(cfg.Theme.MessagesList.SelectedMessageStyle.Style) - ml.SetKeybinds(list.Keybinds{ + ml.selectionState.SetTrackEnd(true) + ml.selectionState.ScrollToEnd() + ml.keybinds = list.Keybinds{ ScrollUp: cfg.Keybinds.MessagesList.ScrollUp.Keybind, ScrollDown: cfg.Keybinds.MessagesList.ScrollDown.Keybind, ScrollTop: cfg.Keybinds.MessagesList.ScrollTop.Keybind, ScrollBottom: cfg.Keybinds.MessagesList.ScrollBottom.Keybind, - }) - ml.SetScrollBarVisibility(cfg.Theme.ScrollBar.Visibility.ScrollBarVisibility) - ml.SetScrollBar(tview.NewScrollBar(). - SetTrackStyle(cfg.Theme.ScrollBar.TrackStyle.Style). - SetThumbStyle(cfg.Theme.ScrollBar.ThumbStyle.Style). - SetGlyphSet(cfg.Theme.ScrollBar.GlyphSet.GlyphSet)) + } + ml.scrollBarVisibility = cfg.Theme.ScrollBar.Visibility.ScrollBarVisibility + ml.scrollBar = scrollbar.New(). + TrackStyle(cfg.Theme.ScrollBar.TrackStyle.Style). + ThumbStyle(cfg.Theme.ScrollBar.ThumbStyle.Style). + GlyphSet(cfg.Theme.ScrollBar.GlyphSet.GlyphSet). + Arrows(scrollbar.ArrowsBoth) return ml } -func (ml *messagesList) reset() { - ml.items = nil - ml. - Clear(). - SetBuilder(ml.buildItem). - SetTitle("") +// listMsg moves or scrolls the messages list. +type listMsg list.Action + +// View shows the messages in a box titled with the channel and footed with who is typing. +func (ml *Model) View(focused bool) tview.Element { + return ui.Box(ml.listView(focused), &ml.cfg.Theme, focused).Title(ml.title).Footer(ml.footer) } -func (ml *messagesList) setTitle(channel discord.Channel) { - title := ui.ChannelToString(channel, ml.cfg.Icons, ml.chat.state) +func (ml *Model) listView(focused bool) list.Widget { + return list.New(&ml.selectionState, len(ml.items), ml.buildItem). + SelectedStyle(ml.cfg.Theme.MessagesList.SelectedMessageStyle.Style). + ScrollBar(ml.scrollBar, ml.scrollBarVisibility). + Keybinds(ml.keybinds). + Focused(focused). + OnAction(func(a list.Action) tview.Msg { return listMsg(a) }) +} + +func (ml *Model) cursor() int { + return ml.selectionState.Cursor() +} + +func (ml *Model) setCursor(index int) { + ml.selectionState.SetCursor(index) +} + +// SetChannel shows messages of channel, newest first as Discord sends them, scrolled to the newest, and requests the members who wrote them. +func (ml *Model) SetChannel(channel *discord.Channel, messages []discord.Message) tview.Cmd { + ml.channel = channel + ml.reset() + ml.setTitle(*channel) + ml.setMessages(messages) + if channel.GuildID.IsValid() { + return ml.requestGuildMembers(channel.GuildID, messages) + } + return nil +} + +// SetFooter sets the text below the messages, such as who is typing. +func (ml *Model) SetFooter(footer string) { + ml.footer = footer +} + +// ShowNewest clears the selection and scrolls to the newest message, which then stays in view as messages arrive until the user scrolls up. +func (ml *Model) ShowNewest() { + ml.clearSelection() + ml.selectionState.ScrollToEnd() +} + +// UpdateMessage replaces the shown message with the same ID. +func (ml *Model) UpdateMessage(message discord.Message) { + ml.setMessage(ml.indexOf(message.ChannelID, message.ID), message) +} + +// RefreshMessage shows the cached state of a message, such as after a reaction. +func (ml *Model) RefreshMessage(channelID discord.ChannelID, id discord.MessageID) { + if message, err := ml.state.Cabinet.Message(channelID, id); err == nil { + ml.UpdateMessage(*message) + } +} + +// DeleteMessage removes the shown message with id. +func (ml *Model) DeleteMessage(channelID discord.ChannelID, id discord.MessageID) { + ml.deleteMessage(ml.indexOf(channelID, id)) +} + +// indexOf returns the index of the message with id in the channel, or -1 if it is not shown. +func (ml *Model) indexOf(channelID discord.ChannelID, id discord.MessageID) int { + if ml.channel == nil || ml.channel.ID != channelID { + return -1 + } + return slices.IndexFunc(ml.items, func(item messageItem) bool { return !item.separator && item.message.ID == id }) +} + +func (ml *Model) reset() { + ml.items = nil + ml.selectionState = list.NewSelectionState() + ml.selectionState.SetTrackEnd(true) + ml.selectionState.ScrollToEnd() + ml.title = "" +} + +func (ml *Model) setTitle(channel discord.Channel) { + title := ui.ChannelToString(channel, ml.cfg.Icons, ml.state) if topic := channel.Topic; topic != "" { title += " - " + topic } - ml.SetTitle(title) + ml.title = title } -func (ml *messagesList) setMessages(messages []discord.Message) { +func (ml *Model) setMessages(messages []discord.Message) { ml.items = make([]messageItem, 0, len(messages)) for _, message := range slices.Backward(messages) { ml.items = append(ml.items, messageItem{message: message}) @@ -117,12 +196,12 @@ func (ml *messagesList) setMessages(messages []discord.Message) { ml.rebuildItems() } -func (ml *messagesList) addMessage(message discord.Message) { +func (ml *Model) AddMessage(message discord.Message) { ml.items = append(ml.items, messageItem{message: message}) ml.rebuildItems() } -func (ml *messagesList) setMessage(index int, message discord.Message) { +func (ml *Model) setMessage(index int, message discord.Message) { if index < 0 || index >= len(ml.items) || ml.items[index].separator { return } @@ -131,12 +210,12 @@ func (ml *messagesList) setMessage(index int, message discord.Message) { ml.rebuildItems() } -func (ml *messagesList) deleteMessage(index int) { +func (ml *Model) deleteMessage(index int) { if index < 0 || index >= len(ml.items) || ml.items[index].separator { return } - cursor := ml.Cursor() + cursor := ml.cursor() if cursor == index { cursor = ml.messageIndex(index, -1) if cursor == -1 { @@ -147,75 +226,93 @@ func (ml *messagesList) deleteMessage(index int) { cursor-- } ml.items = slices.Delete(ml.items, index, index+1) - ml.Model.SetCursor(cursor) + ml.setCursor(cursor) ml.rebuildItems() } -func (ml *messagesList) clearSelection() { - ml.SetCursor(-1) +func (ml *Model) clearSelection() { + ml.setCursor(-1) } -func (ml *messagesList) buildItem(index int) list.Item { +func (ml *Model) buildItem(index int) list.Item { if index < 0 || index >= len(ml.items) { return nil } item := &ml.items[index] if item.separator { - return ml.buildSeparatorItem(item.timestamp) + date := item.timestamp.Time().In(time.Local).Format(ml.cfg.DateSeparator.Format) + return dateSeparator{date: date, fill: ml.cfg.DateSeparator.Character, style: ml.cfg.Theme.MessagesList.MessageStyle.Style.Dim(true)} } if item.view == nil { - item.view = tview.NewTextView(). - SetWrap(true). - SetWordWrap(true). - SetContent(ml.renderMessage(item.message, ml.cfg.Theme.MessagesList.MessageStyle.Style)) + message := item.message + item.view = &messageView{render: func(width int) richtext.Text { + ml.renderWidth = width + return ml.renderMessage(message, ml.cfg.Theme.MessagesList.MessageStyle.Style) + }} } return item.view } -func (ml *messagesList) renderMessage(message discord.Message, baseStyle tcell.Style) text.Text { - builder := new(text.Builder) +// messageView renders a message for the width it is laid out at, keeping the wrapped lines until the width changes. +type messageView struct { + render func(width int) richtext.Text + width int + rendered bool + lines richtext.Text +} + +func (v *messageView) at(width int) textview.Widget { + if !v.rendered || v.width != width { + v.lines, v.width, v.rendered = nil, width, true + for _, line := range v.render(width) { + v.lines = append(v.lines, richtext.WrapWords(line, width)...) + } + } + return textview.New(v.lines).Wrap(false) +} + +func (v *messageView) Rows(width int) int { + v.at(width) + return len(v.lines) +} + +func (v *messageView) Draw(screen tview.Screen, area tview.Rectangle) { + v.at(area.Width).Draw(screen, area) +} + +func (v *messageView) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg { return msg } + +// dateSeparator is a line with the date in the middle, filled to the width it is drawn at. +type dateSeparator struct { + date, fill string + style tcell.Style +} + +func (dateSeparator) Rows(int) int { return 1 } + +func (d dateSeparator) Draw(screen tview.Screen, area tview.Rectangle) { + line := d.date + label := " " + d.date + " " + if labelWidth := utf8.RuneCountInString(label); area.Width > labelWidth { + fill := area.Width - labelWidth + line = strings.Repeat(d.fill, fill/2) + label + strings.Repeat(d.fill, fill-fill/2) + } + tview.Print(screen, line, area.X, area.Y, area.Width, tview.AlignmentLeft, d.style) +} + +func (dateSeparator) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg { return msg } + +func (ml *Model) renderMessage(message discord.Message, baseStyle tcell.Style) richtext.Text { + builder := new(richtext.Builder) ml.writeMessage(builder, message, baseStyle) return builder.Finish() } -func (ml *messagesList) buildSeparatorItem(ts discord.Timestamp) *tview.TextView { - builder := new(text.Builder) - ml.drawDateSeparator(builder, ts, ml.cfg.Theme.MessagesList.MessageStyle.Style) - return tview.NewTextView(). - SetScrollable(false). - SetWrap(false). - SetWordWrap(false). - SetContent(builder.Finish()) -} - -func (ml *messagesList) drawDateSeparator(builder *text.Builder, ts discord.Timestamp, baseStyle tcell.Style) { - date := ts.Time().In(time.Local).Format(ml.cfg.DateSeparator.Format) - label := " " + date + " " - fillChar := ml.cfg.DateSeparator.Character - dimStyle := baseStyle.Dim(true) - _, _, width, _ := ml.InnerRect() - if width <= 0 { - builder.Write(strings.Repeat(fillChar, 8)+label+strings.Repeat(fillChar, 8), dimStyle) - return - } - - labelWidth := utf8.RuneCountInString(label) - if width <= labelWidth { - builder.Write(date, dimStyle) - return - } - - fillWidth := width - labelWidth - left := fillWidth / 2 - right := fillWidth - left - builder.Write(strings.Repeat(fillChar, left)+label+strings.Repeat(fillChar, right), dimStyle) -} - // rebuildItems replaces date separators while retaining message views and selection. -func (ml *messagesList) rebuildItems() { +func (ml *Model) rebuildItems() { items := make([]messageItem, 0, len(ml.items)) - cursor, selected := ml.Model.Cursor(), -1 + cursor, selected := ml.cursor(), -1 var previous discord.Timestamp for i, item := range ml.items { if item.separator { @@ -231,8 +328,7 @@ func (ml *messagesList) rebuildItems() { previous = item.message.Timestamp } ml.items = items - ml.SetBuilder(ml.buildItem) - ml.Model.SetCursor(selected) + ml.setCursor(selected) } func sameLocalDate(a discord.Timestamp, b discord.Timestamp) bool { @@ -242,7 +338,7 @@ func sameLocalDate(a discord.Timestamp, b discord.Timestamp) bool { } // messageIndex finds the next message in direction (-1 or 1), excluding start. -func (ml *messagesList) messageIndex(start, direction int) int { +func (ml *Model) messageIndex(start, direction int) int { for i := start + direction; i >= 0 && i < len(ml.items); i += direction { if !ml.items[i].separator { return i @@ -251,7 +347,7 @@ func (ml *messagesList) messageIndex(start, direction int) int { return -1 } -func (ml *messagesList) onRowCursorChanged(index int) { +func (ml *Model) onRowCursorChanged(index int) { if index < 0 || index >= len(ml.items) || !ml.items[index].separator { return } @@ -259,12 +355,12 @@ func (ml *messagesList) onRowCursorChanged(index int) { if target == -1 { target = ml.messageIndex(index, 1) } - ml.SetCursor(target) + ml.setCursor(target) } -func (ml *messagesList) writeMessage(builder *text.Builder, message discord.Message, baseStyle tcell.Style) { +func (ml *Model) writeMessage(builder *richtext.Builder, message discord.Message, baseStyle tcell.Style) { if ml.cfg.HideBlockedUsers { - isBlocked := ml.chat.state.UserIsBlocked(message.Author.ID) + isBlocked := ml.state.UserIsBlocked(message.Author.ID) if isBlocked { builder.Write("Blocked message", baseStyle.Foreground(color.Red).Bold(true)) return @@ -293,7 +389,7 @@ func (ml *messagesList) writeMessage(builder *text.Builder, message discord.Mess ml.drawReactions(builder, message.Reactions, baseStyle) } -func (ml *messagesList) drawReactions(builder *text.Builder, reactions []discord.Reaction, baseStyle tcell.Style) { +func (ml *Model) drawReactions(builder *richtext.Builder, reactions []discord.Reaction, baseStyle tcell.Style) { if len(reactions) == 0 { return } @@ -316,16 +412,16 @@ func (ml *messagesList) drawReactions(builder *text.Builder, reactions []discord } } -func (ml *messagesList) formatTimestamp(ts discord.Timestamp) string { +func (ml *Model) formatTimestamp(ts discord.Timestamp) string { return ts.Time().In(time.Local).Format(ml.cfg.Timestamps.Format) } -func (ml *messagesList) drawTimestamps(builder *text.Builder, ts discord.Timestamp, baseStyle tcell.Style) { +func (ml *Model) drawTimestamps(builder *richtext.Builder, ts discord.Timestamp, baseStyle tcell.Style) { dimStyle := baseStyle.Dim(true) builder.Write(ml.formatTimestamp(ts)+" ", dimStyle) } -func (ml *messagesList) drawAuthor(builder *text.Builder, message discord.Message, baseStyle tcell.Style) { +func (ml *Model) drawAuthor(builder *richtext.Builder, message discord.Message, baseStyle tcell.Style) { name := message.Author.DisplayOrUsername() foreground := tcell.ColorDefault @@ -335,7 +431,7 @@ func (ml *messagesList) drawAuthor(builder *text.Builder, message discord.Messag } color, ok := state.MemberColor(member, func(id discord.RoleID) *discord.Role { - r, _ := ml.chat.state.Cabinet.Role(message.GuildID, id) + r, _ := ml.state.Cabinet.Role(message.GuildID, id) return r }) if ok { @@ -347,13 +443,13 @@ func (ml *messagesList) drawAuthor(builder *text.Builder, message discord.Messag builder.Write(name+" ", style) } -func (ml *messagesList) memberForMessage(message discord.Message) *discord.Member { +func (ml *Model) memberForMessage(message discord.Message) *discord.Member { // Webhooks do not have nicknames or roles. if !message.GuildID.IsValid() || message.WebhookID.IsValid() { return nil } - member, err := ml.chat.state.Cabinet.Member(message.GuildID, message.Author.ID) + member, err := ml.state.Cabinet.Member(message.GuildID, message.Author.ID) if err != nil { slog.Error("failed to get member from state", "guild_id", message.GuildID, "member_id", message.Author.ID, "err", err) return nil @@ -361,12 +457,10 @@ func (ml *messagesList) memberForMessage(message discord.Message) *discord.Membe return member } -// drawContent renders the message body and returns the parsed markdown AST -// together with the source bytes it indexes into, so callers can reuse them -// instead of re-parsing the same content (see drawEmbeds). root is nil when -// markdown rendering is disabled. -func (ml *messagesList) drawContent(builder *text.Builder, message discord.Message, baseStyle tcell.Style) (ast.Node, []byte) { - content, root, source := ml.renderContent(message, baseStyle) +// drawContent renders the message body and returns the parsed markdown AST together with the source bytes it indexes into, so callers can reuse them instead of re-parsing the same content (see drawEmbeds). +// root is nil when markdown rendering is disabled. +func (ml *Model) drawContent(builder *richtext.Builder, message discord.Message, baseStyle tcell.Style) (ast.Node, []byte) { + content, root, source := ml.renderContent(message, baseStyle, false) if ml.cfg.Markdown.Enabled && !builder.LineEmpty() { startsWithCodeBlock := false if root != nil { @@ -391,24 +485,20 @@ func (ml *messagesList) drawContent(builder *text.Builder, message discord.Messa return root, source } -func (ml *messagesList) renderContent(message discord.Message, baseStyle tcell.Style) (text.Text, ast.Node, []byte) { - return ml.renderContentWithMarkdown(message, baseStyle, false) -} - -func (ml *messagesList) renderContentWithMarkdown(message discord.Message, baseStyle tcell.Style, forceMarkdown bool) (text.Text, ast.Node, []byte) { +func (ml *Model) renderContent(message discord.Message, baseStyle tcell.Style, forceMarkdown bool) (richtext.Text, ast.Node, []byte) { // Keep one rendering path for both normal messages and embed fragments so we preserve mention/link parsing behavior consistently across both. if forceMarkdown || ml.cfg.Markdown.Enabled { c := []byte(message.Content) - root := md.ParseWithMessage(c, *ml.chat.state.Cabinet, &message) + root := md.ParseWithMessage(c, *ml.state.Cabinet, &message) return ml.renderer.RenderText(c, root, baseStyle), root, c } - b := new(text.Builder) + b := new(richtext.Builder) b.Write(message.Content, baseStyle) return b.Finish(), nil, nil } -func (ml *messagesList) drawSnapshotContent(builder *text.Builder, parent discord.Message, snapshot discord.MessageSnapshotMessage, baseStyle tcell.Style) { +func (ml *Model) drawSnapshotContent(builder *richtext.Builder, parent discord.Message, snapshot discord.MessageSnapshotMessage, baseStyle tcell.Style) { // Convert discord.MessageSnapshotMessage to discord.Message with common fields. message := discord.Message{ Type: snapshot.Type, @@ -428,7 +518,7 @@ func (ml *messagesList) drawSnapshotContent(builder *text.Builder, parent discor ml.drawContent(builder, message, baseStyle) } -func (ml *messagesList) drawDefaultMessage(builder *text.Builder, message discord.Message, baseStyle tcell.Style) { +func (ml *Model) drawDefaultMessage(builder *richtext.Builder, message discord.Message, baseStyle tcell.Style) { if ml.cfg.Timestamps.Enabled { ml.drawTimestamps(builder, message.Timestamp, baseStyle) } @@ -456,14 +546,13 @@ func (ml *messagesList) drawDefaultMessage(builder *text.Builder, message discor } } -func (ml *messagesList) drawEmbeds(builder *text.Builder, message discord.Message, baseStyle tcell.Style, contentRoot ast.Node, contentSource []byte) { +func (ml *Model) drawEmbeds(builder *richtext.Builder, message discord.Message, baseStyle tcell.Style, contentRoot ast.Node, contentSource []byte) { if len(message.Embeds) == 0 { return } - // Embed URLs are deduplicated against links already shown in the message - // body. Reuse the body's parsed AST when markdown is enabled; only the - // markdown-disabled path (no AST) has to parse the content here. + // Embed URLs are deduplicated against links already shown in the message body. + // Reuse the body's parsed AST when markdown is enabled; only the markdown-disabled path (no AST) has to parse the content here. var contentListURLs []string if contentRoot != nil { contentListURLs = urlsFromAST(contentRoot, contentSource) @@ -479,8 +568,8 @@ func (ml *messagesList) drawEmbeds(builder *text.Builder, message discord.Messag defaultBarStyle := baseStyle.Dim(true) prefixText := " ▎ " prefixWidth := uniseg.StringWidth(prefixText) - _, _, innerWidth, _ := ml.InnerRect() - // Wrap against the current list viewport. This keeps embed wrapping stable even when sidebars/panes are resized. + innerWidth := ml.renderWidth + // Wrap against the list's width, so the message is rendered again when it changes. wrapWidth := max(innerWidth-prefixWidth, 1) for _, embed := range message.Embeds { @@ -489,12 +578,12 @@ func (ml *messagesList) drawEmbeds(builder *text.Builder, message discord.Messag continue } - embedText := make(text.Text, 0, len(lines)*2) + embedText := make(richtext.Text, 0, len(lines)*2) barStyle := defaultBarStyle if embed.Color != discord.NullColor && embed.Color != 0 { barStyle = barStyle.Foreground(tcell.NewHexColor(int32(embed.Color))) } - prefix := text.NewSegment(prefixText, barStyle) + prefix := richtext.NewSegment(prefixText, barStyle) builder.NewLine() for _, line := range lines { if strings.TrimSpace(line.Text) == "" { @@ -504,14 +593,14 @@ func (ml *messagesList) drawEmbeds(builder *text.Builder, message discord.Messag msg.Content = line.Text lineStyle := lineStyles[line.Kind] // Embed descriptions are always markdown-rendered to match Discord's rich embed semantics, even when message markdown is globally disabled. - rendered, _, _ := ml.renderContentWithMarkdown(msg, lineStyle, line.Kind == embedLineDescription) + rendered, _, _ := ml.renderContent(msg, lineStyle, line.Kind == embedLineDescription) for _, renderedLine := range rendered { if line.URL != "" { renderedLine = lineWithURL(renderedLine, line.URL) } // Prefix must be applied after wrapping so every visual line keeps the embed bar marker ("▎"), not only the first logical line. - for _, wrapped := range text.Wrap(renderedLine, wrapWidth) { - prefixed := make(text.Line, 0, len(wrapped)+1) + for _, wrapped := range richtext.Wrap(renderedLine, wrapWidth) { + prefixed := make(richtext.Line, 0, len(wrapped)+1) prefixed = append(prefixed, prefix) prefixed = append(prefixed, wrapped...) embedText = append(embedText, prefixed) @@ -525,8 +614,8 @@ func (ml *messagesList) drawEmbeds(builder *text.Builder, message discord.Messag } } -func lineWithURL(line text.Line, rawURL string) text.Line { - out := make(text.Line, len(line)) +func lineWithURL(line richtext.Line, rawURL string) richtext.Line { + out := make(richtext.Line, len(line)) for i, segment := range line { out[i] = segment out[i].Style = out[i].Style.Url(rawURL) @@ -690,7 +779,7 @@ func isMarkdownEscapable(c byte) bool { } } -func (ml *messagesList) drawForwardedMessage(builder *text.Builder, message discord.Message, baseStyle tcell.Style) { +func (ml *Model) drawForwardedMessage(builder *richtext.Builder, message discord.Message, baseStyle tcell.Style) { dimStyle := baseStyle.Dim(true) ml.drawTimestamps(builder, message.Timestamp, baseStyle) ml.drawAuthor(builder, message, baseStyle) @@ -699,7 +788,7 @@ func (ml *messagesList) drawForwardedMessage(builder *text.Builder, message disc builder.Write(" ("+ml.formatTimestamp(message.MessageSnapshots[0].Message.Timestamp)+") ", dimStyle) } -func (ml *messagesList) drawReplyMessage(builder *text.Builder, message discord.Message, baseStyle tcell.Style) { +func (ml *Model) drawReplyMessage(builder *richtext.Builder, message discord.Message, baseStyle tcell.Style) { dimStyle := baseStyle.Dim(true) // indicator builder.Write(ml.cfg.Theme.MessagesList.ReplyIndicator+" ", dimStyle) @@ -717,17 +806,17 @@ func (ml *messagesList) drawReplyMessage(builder *text.Builder, message discord. ml.drawDefaultMessage(builder, message, baseStyle) } -func (ml *messagesList) drawPinnedMessage(builder *text.Builder, message discord.Message, baseStyle tcell.Style) { +func (ml *Model) drawPinnedMessage(builder *richtext.Builder, message discord.Message, baseStyle tcell.Style) { builder.Write(message.Author.DisplayOrUsername(), baseStyle) builder.Write(" pinned a message.", baseStyle) } -func (ml *messagesList) selectedMessage() (*discord.Message, bool) { +func (ml *Model) selectedMessage() (*discord.Message, bool) { if len(ml.items) == 0 { return nil, false } - cursor := ml.Cursor() + cursor := ml.cursor() if cursor < 0 || cursor >= len(ml.items) || ml.items[cursor].separator { return nil, false } @@ -735,7 +824,7 @@ func (ml *messagesList) selectedMessage() (*discord.Message, bool) { return &ml.items[cursor].message, true } -func (ml *messagesList) Update(msg tview.Msg) tview.Cmd { +func (ml *Model) Update(msg tview.Msg) tview.Cmd { switch msg := msg.(type) { case tview.KeyMsg: switch { @@ -780,11 +869,11 @@ func (ml *messagesList) Update(msg tview.Msg) tview.Cmd { return ml.confirmDelete() } case olderMessagesLoadedMsg: - selectedChannel, ok := ml.chat.SelectedChannel() - if !ok || selectedChannel.ID != msg.ChannelID { + selectedChannel := ml.channel + if selectedChannel == nil || selectedChannel.ID != msg.ChannelID { return nil } - prevCursor := ml.Cursor() + prevCursor := ml.cursor() older := make([]messageItem, len(msg.Older)) for i, message := range msg.Older { @@ -794,56 +883,61 @@ func (ml *messagesList) Update(msg tview.Msg) tview.Cmd { ml.items = slices.Concat(older, ml.items) switch { case prevCursor == first && len(older) > 0: - ml.Model.SetCursor(len(older) - 1) + ml.setCursor(len(older) - 1) case prevCursor >= 0: - ml.Model.SetCursor(prevCursor + len(older)) + ml.setCursor(prevCursor + len(older)) } ml.rebuildItems() if selectedChannel.GuildID.IsValid() { return ml.requestGuildMembers(selectedChannel.GuildID, msg.Older) } return nil + case deleteMessageMsg: + return ml.requestDelete(discord.Message(msg)) + case attachmentActionMsg: + return msg.Action + case listMsg: + ml.selectionState.Perform(list.Action(msg)) + ml.onRowCursorChanged(ml.cursor()) } - cmd := ml.Model.Update(msg) - ml.onRowCursorChanged(ml.Model.Cursor()) - return cmd + return nil } -func (ml *messagesList) selectUp() tview.Cmd { - cursor := ml.Cursor() +func (ml *Model) selectUp() tview.Cmd { + cursor := ml.cursor() if cursor == -1 { ml.selectBottom() } else if previous := ml.messageIndex(cursor, -1); previous >= 0 { - ml.SetCursor(previous) + ml.setCursor(previous) } else { return ml.fetchOlderMessages() } return nil } -func (ml *messagesList) selectDown() { - if ml.Cursor() == -1 { +func (ml *Model) selectDown() { + if ml.cursor() == -1 { ml.selectBottom() - } else if next := ml.messageIndex(ml.Cursor(), 1); next >= 0 { - ml.SetCursor(next) + } else if next := ml.messageIndex(ml.cursor(), 1); next >= 0 { + ml.setCursor(next) } } -func (ml *messagesList) selectTop() { - ml.SetCursor(ml.messageIndex(-1, 1)) +func (ml *Model) selectTop() { + ml.setCursor(ml.messageIndex(-1, 1)) } -func (ml *messagesList) selectBottom() { - ml.SetCursor(ml.messageIndex(len(ml.items), -1)) +func (ml *Model) selectBottom() { + ml.setCursor(ml.messageIndex(len(ml.items), -1)) } -func (ml *messagesList) selectReply() { +func (ml *Model) selectReply() { messages := ml.items if len(messages) == 0 { return } - cursor := ml.Cursor() + cursor := ml.cursor() if cursor == -1 || cursor >= len(messages) { return } @@ -853,14 +947,14 @@ func (ml *messagesList) selectReply() { return !m.separator && m.message.ID == ref.ID }) if refIdx != -1 { - ml.SetCursor(refIdx) + ml.setCursor(refIdx) } } } -func (ml *messagesList) fetchOlderMessages() tview.Cmd { - selectedChannel, ok := ml.chat.SelectedChannel() - if !ok { +func (ml *Model) fetchOlderMessages() tview.Cmd { + selectedChannel := ml.channel + if selectedChannel == nil { return nil } @@ -872,7 +966,7 @@ func (ml *messagesList) fetchOlderMessages() tview.Cmd { before := ml.items[first].message.ID limit := uint(ml.cfg.MessagesLimit) return func() tview.Msg { - messages, err := ml.chat.state.MessagesBefore(channelID, before, limit) + messages, err := ml.state.MessagesBefore(channelID, before, limit) if err != nil { slog.Error("failed to fetch older messages", "err", err) return nil @@ -887,7 +981,7 @@ func (ml *messagesList) fetchOlderMessages() tview.Cmd { } } -func (ml *messagesList) yankMessageID() tview.Cmd { +func (ml *Model) yankMessageID() tview.Cmd { selectedMessage, ok := ml.selectedMessage() if !ok { return nil @@ -902,7 +996,7 @@ func (ml *messagesList) yankMessageID() tview.Cmd { } } -func (ml *messagesList) yankContent() tview.Cmd { +func (ml *Model) yankContent() tview.Cmd { selectedMessage, ok := ml.selectedMessage() if !ok { return nil @@ -917,7 +1011,7 @@ func (ml *messagesList) yankContent() tview.Cmd { } } -func (ml *messagesList) yankURL() tview.Cmd { +func (ml *Model) yankURL() tview.Cmd { selectedMessage, ok := ml.selectedMessage() if !ok { return nil @@ -932,15 +1026,15 @@ func (ml *messagesList) yankURL() tview.Cmd { } } -func (ml *messagesList) open() tview.Cmd { +func (ml *Model) open() tview.Cmd { return ml.openWith(ml.openAttachment) } -func (ml *messagesList) openInBrowser() tview.Cmd { +func (ml *Model) openInBrowser() tview.Cmd { return ml.openWith(func(attachment discord.Attachment) tview.Cmd { return openURL(attachment.URL) }) } -func (ml *messagesList) openWith(openAttachment func(discord.Attachment) tview.Cmd) tview.Cmd { +func (ml *Model) openWith(openAttachment func(discord.Attachment) tview.Cmd) tview.Cmd { selectedMessage, ok := ml.selectedMessage() if !ok { return nil @@ -959,7 +1053,7 @@ func (ml *messagesList) openWith(openAttachment func(discord.Attachment) tview.C return openAttachment(selectedMessage.Attachments[0]) } -func (ml *messagesList) download() tview.Cmd { +func (ml *Model) download() tview.Cmd { selectedMessage, ok := ml.selectedMessage() if !ok || len(selectedMessage.Attachments) == 0 { return nil @@ -983,8 +1077,7 @@ func extractURLs(content string) []string { } // urlsFromAST collects link destinations from an already-parsed markdown AST. -// src must be the byte slice the node was parsed from (AutoLink resolves its -// URL against it). +// src must be the byte slice the node was parsed from (AutoLink resolves its URL against it). func urlsFromAST(node ast.Node, src []byte) []string { var urls []string ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) { @@ -1037,7 +1130,7 @@ func messageURLs(msg discord.Message) []string { return urls } -func (ml *messagesList) showAttachmentsList(urls []string, attachments []discord.Attachment, openAttachment func(discord.Attachment) tview.Cmd) tview.Cmd { +func (ml *Model) showAttachmentsList(urls []string, attachments []discord.Attachment, openAttachment func(discord.Attachment) tview.Cmd) tview.Cmd { var items []attachmentspicker.Item for _, attachment := range attachments { items = append(items, attachmentspicker.Item{Label: attachment.Filename, Action: openAttachment(attachment)}) @@ -1048,26 +1141,15 @@ func (ml *messagesList) showAttachmentsList(urls []string, attachments []discord return ml.showAttachmentsPicker(items) } -func (ml *messagesList) showAttachmentsPicker(items []attachmentspicker.Item) tview.Cmd { - ml.attachmentsPicker.SetItems(items) - - ml.chat. - AddLayer( - ui.Centered(ml.attachmentsPicker, ml.cfg.Picker.Width, ml.cfg.Picker.Height), - layers.WithName(attachmentsPickerLayerName), - layers.WithResize(true), - layers.WithVisible(true), - layers.WithOverlay(), - ). - SendToFront(attachmentsPickerLayerName) - return nil +func (ml *Model) showAttachmentsPicker(items []attachmentspicker.Item) tview.Cmd { + return func() tview.Msg { return ShowAttachmentsMsg(items) } } -func (ml *messagesList) openAttachment(attachment discord.Attachment) tview.Cmd { +func (ml *Model) openAttachment(attachment discord.Attachment) tview.Cmd { return ml.confirmAttachment(attachment, openDownloadedAttachment(attachment)) } -func (ml *messagesList) confirmAttachment(attachment discord.Attachment, action tview.Cmd) tview.Cmd { +func (ml *Model) confirmAttachment(attachment discord.Attachment, action tview.Cmd) tview.Cmd { if !ml.cfg.AllowedMIMETypes.Has(attachment.ContentType) { return ui.ShowModal( "This attachment type is not allowed and may be unsafe. Continue anyway?", @@ -1161,7 +1243,7 @@ func openURL(url string) tview.Cmd { } } -func (ml *messagesList) reply(mention bool) tview.Cmd { +func (ml *Model) reply(mention bool) tview.Cmd { selectedMessage, ok := ml.selectedMessage() if !ok { return nil @@ -1172,41 +1254,45 @@ func (ml *messagesList) reply(mention bool) tview.Cmd { name = member.Nick } - data := ml.chat.composer.sendMessageData - data.Reference = &discord.MessageReference{MessageID: selectedMessage.ID} - data.AllowedMentions = &api.AllowedMentions{RepliedUser: option.Some(false)} - - title := "Replying to " - if mention { - data.AllowedMentions.RepliedUser = option.Some(true) - title = "[@] " + title - } - - ml.chat.composer.sendMessageData = data - ml.chat.composer.SetTitle(title + name) - ml.chat.focusComposer() - return nil + message := *selectedMessage + return func() tview.Msg { return ReplyMsg{Message: message, Name: name, Mention: mention} } } -func (ml *messagesList) editSelectedMessage() tview.Cmd { +func (ml *Model) editSelectedMessage() tview.Cmd { selectedMessage, ok := ml.selectedMessage() if !ok { return nil } - if !ml.chat.isMe(selectedMessage.Author.ID) { + if !ui.IsMe(ml.state, selectedMessage.Author.ID) { slog.Error("failed to edit message; not the author", "channel_id", selectedMessage.ChannelID, "message_id", selectedMessage.ID) return nil } - ml.chat.composer.SetTitle("Editing") - ml.chat.composer.edit = true - ml.chat.composer.SetText(selectedMessage.Content, true) - ml.chat.focusComposer() - return nil + message := *selectedMessage + return func() tview.Msg { return EditMsg(message) } } -func (ml *messagesList) confirmDelete() tview.Cmd { +// SelectLastOwn selects the last message in the selected channel that the user can edit, and returns it. +func (ml *Model) SelectLastOwn() (discord.Message, bool) { + channel := ml.channel + if channel == nil { + return discord.Message{}, false + } + for i, item := range slices.Backward(ml.items) { + message := item.message + if item.separator || message.ChannelID != channel.ID || !ui.IsMe(ml.state, message.Author.ID) || + !message.ID.IsValid() || message.Content == "" || len(message.MessageSnapshots) > 0 || + (message.Type != discord.DefaultMessage && message.Type != discord.InlinedReplyMessage) { + continue + } + ml.setCursor(i) + return message, true + } + return discord.Message{}, false +} + +func (ml *Model) confirmDelete() tview.Cmd { selectedMessage, ok := ml.selectedMessage() if !ok || !ml.canDeleteMessage(*selectedMessage) { return nil @@ -1219,27 +1305,27 @@ func (ml *messagesList) confirmDelete() tview.Cmd { ) } -func (ml *messagesList) deleteSelectedMessage() tview.Cmd { +func (ml *Model) deleteSelectedMessage() tview.Cmd { selectedMessage, ok := ml.selectedMessage() if !ok { return nil } - return ml.deleteMessageRequest(*selectedMessage) + return ml.requestDelete(*selectedMessage) } -func (ml *messagesList) deleteMessageRequest(message discord.Message) tview.Cmd { +func (ml *Model) requestDelete(message discord.Message) tview.Cmd { return func() tview.Msg { if !ml.canDeleteMessage(message) { slog.Error("failed to delete message; missing relevant permissions", "channel_id", message.ChannelID, "message_id", message.ID) return nil } - if err := ml.chat.state.DeleteMessage(message.ChannelID, message.ID, ""); err != nil { + if err := ml.state.DeleteMessage(message.ChannelID, message.ID, ""); err != nil { slog.Error("failed to delete message", "channel_id", message.ChannelID, "message_id", message.ID, "err", err) return nil } - if err := ml.chat.state.MessageRemove(message.ChannelID, message.ID); err != nil { + if err := ml.state.MessageRemove(message.ChannelID, message.ID); err != nil { slog.Error("failed to delete message", "channel_id", message.ChannelID, "message_id", message.ID, "err", err) return nil } @@ -1247,12 +1333,12 @@ func (ml *messagesList) deleteMessageRequest(message discord.Message) tview.Cmd } } -func (ml *messagesList) canDeleteMessage(message discord.Message) bool { - return ml.chat.isMe(message.Author.ID) || - (message.GuildID.IsValid() && ml.chat.state.HasPermissions(message.ChannelID, discord.PermissionManageMessages)) +func (ml *Model) canDeleteMessage(message discord.Message) bool { + return ui.IsMe(ml.state, message.Author.ID) || + (message.GuildID.IsValid() && ml.state.HasPermissions(message.ChannelID, discord.PermissionManageMessages)) } -func (ml *messagesList) requestGuildMembers(guildID discord.GuildID, messages []discord.Message) tview.Cmd { +func (ml *Model) requestGuildMembers(guildID discord.GuildID, messages []discord.Message) tview.Cmd { usersToFetch := make([]discord.UserID, 0, len(messages)) seen := make(map[discord.UserID]struct{}, len(messages)) @@ -1262,7 +1348,7 @@ func (ml *messagesList) requestGuildMembers(guildID discord.GuildID, messages [] continue } - if member, _ := ml.chat.state.Cabinet.Member(guildID, message.Author.ID); member == nil { + if member, _ := ml.state.Cabinet.Member(guildID, message.Author.ID); member == nil { userID := message.Author.ID if _, ok := seen[userID]; !ok { seen[userID] = struct{}{} @@ -1276,7 +1362,7 @@ func (ml *messagesList) requestGuildMembers(guildID discord.GuildID, messages [] } return func() tview.Msg { - if err := ml.chat.state.SendGateway(context.Background(), &gateway.RequestGuildMembersCommand{ + if err := ml.state.SendGateway(context.Background(), &gateway.RequestGuildMembersCommand{ GuildIDs: []discord.GuildID{guildID}, UserIDs: usersToFetch, }); err != nil { @@ -1286,14 +1372,13 @@ func (ml *messagesList) requestGuildMembers(guildID discord.GuildID, messages [] } } -func (ml *messagesList) invalidateRenderedMessages() { +func (ml *Model) InvalidateRendered() { for i := range ml.items { ml.items[i].view = nil } - ml.SetBuilder(ml.buildItem) } -func (ml *messagesList) ShortHelp() []keybind.Keybind { +func (ml *Model) ShortHelp() []keybind.Keybind { cfg := ml.cfg.Keybinds.MessagesList help := []keybind.Keybind{ cfg.SelectUp.Keybind, @@ -1302,7 +1387,7 @@ func (ml *messagesList) ShortHelp() []keybind.Keybind { } if selectedMessage, ok := ml.selectedMessage(); ok { - if !ml.chat.isMe(selectedMessage.Author.ID) { + if !ui.IsMe(ml.state, selectedMessage.Author.ID) { help = append(help, cfg.Reply.Keybind) } if len(selectedMessage.Attachments) != 0 || len(messageURLs(*selectedMessage)) != 0 { @@ -1313,52 +1398,34 @@ func (ml *messagesList) ShortHelp() []keybind.Keybind { return help } -func (ml *messagesList) FullHelp() [][]keybind.Keybind { +func (ml *Model) FullHelp() [][]keybind.Keybind { cfg := ml.cfg.Keybinds.MessagesList - - canSelectReply := false - canReply := false - canEdit := false - canDelete := false - canOpen := false - canDownload := false - if selectedMessage, ok := ml.selectedMessage(); ok { - canSelectReply = selectedMessage.ReferencedMessage != nil - canOpen = len(messageURLs(*selectedMessage)) != 0 || len(selectedMessage.Attachments) != 0 - canDownload = len(selectedMessage.Attachments) != 0 - - canEdit = ml.chat.isMe(selectedMessage.Author.ID) - canReply = !canEdit - canDelete = ml.canDeleteMessage(*selectedMessage) - } - - actions := make([]keybind.Keybind, 0, 4) - if canReply { - actions = append(actions, cfg.Reply.Keybind, cfg.ReplyMention.Keybind) - } - if canSelectReply { - actions = append(actions, cfg.SelectReply.Keybind) - } - actions = append(actions, cfg.Cancel.Keybind) - - manage := make([]keybind.Keybind, 0, 4) - if canEdit { - manage = append(manage, cfg.Edit.Keybind) - } - if canDelete { - manage = append(manage, cfg.DeleteConfirm.Keybind) - if len(cfg.Delete.Keys()) != 0 { - manage = append(manage, cfg.Delete.Keybind) + var actions, manage, attachments []keybind.Keybind + if message, ok := ml.selectedMessage(); ok { + mine := ui.IsMe(ml.state, message.Author.ID) + if !mine { + actions = append(actions, cfg.Reply.Keybind, cfg.ReplyMention.Keybind) + } + if message.ReferencedMessage != nil { + actions = append(actions, cfg.SelectReply.Keybind) + } + if mine { + manage = append(manage, cfg.Edit.Keybind) + } + if ml.canDeleteMessage(*message) { + manage = append(manage, cfg.DeleteConfirm.Keybind) + if len(cfg.Delete.Keys()) != 0 { + manage = append(manage, cfg.Delete.Keybind) + } + } + if len(message.Attachments) != 0 || len(messageURLs(*message)) != 0 { + attachments = append(attachments, cfg.Open.Keybind, cfg.OpenInBrowser.Keybind) + } + if len(message.Attachments) != 0 { + attachments = append(attachments, cfg.Download.Keybind) } } - - attachments := make([]keybind.Keybind, 0, 3) - if canOpen { - attachments = append(attachments, cfg.Open.Keybind, cfg.OpenInBrowser.Keybind) - } - if canDownload { - attachments = append(attachments, cfg.Download.Keybind) - } + actions = append(actions, cfg.Cancel.Keybind) return [][]keybind.Keybind{ {cfg.SelectUp.Keybind, cfg.SelectDown.Keybind, cfg.SelectTop.Keybind, cfg.SelectBottom.Keybind}, diff --git a/internal/ui/chat/messages_list_test.go b/internal/ui/chat/messageslist/model_test.go similarity index 63% rename from internal/ui/chat/messages_list_test.go rename to internal/ui/chat/messageslist/model_test.go index fb1ae61..bea3b54 100644 --- a/internal/ui/chat/messages_list_test.go +++ b/internal/ui/chat/messageslist/model_test.go @@ -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) + } + }) + } +} diff --git a/internal/ui/chat/messageslist/msg.go b/internal/ui/chat/messageslist/msg.go new file mode 100644 index 0000000..34bcd2d --- /dev/null +++ b/internal/ui/chat/messageslist/msg.go @@ -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 diff --git a/internal/ui/chat/model.go b/internal/ui/chat/model.go index d5afb40..0137142 100644 --- a/internal/ui/chat/model.go +++ b/internal/ui/chat/model.go @@ -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 diff --git a/internal/ui/chat/model_test.go b/internal/ui/chat/model_test.go new file mode 100644 index 0000000..659e458 --- /dev/null +++ b/internal/ui/chat/model_test.go @@ -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) + } + }) + } +} diff --git a/internal/ui/chat/msg.go b/internal/ui/chat/msg.go index 0701082..5e41246 100644 --- a/internal/ui/chat/msg.go +++ b/internal/ui/chat/msg.go @@ -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 { diff --git a/internal/ui/chat/state.go b/internal/ui/chat/state.go index 3b3f301..48c4be4 100644 --- a/internal/ui/chat/state.go +++ b/internal/ui/chat/state.go @@ -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 } diff --git a/internal/ui/login/form/model.go b/internal/ui/login/form/model.go new file mode 100644 index 0000000..276c8c0 --- /dev/null +++ b/internal/ui/login/form/model.go @@ -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) +} diff --git a/internal/ui/login/form/model_test.go b/internal/ui/login/form/model_test.go new file mode 100644 index 0000000..73b0e7b --- /dev/null +++ b/internal/ui/login/form/model_test.go @@ -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) + } + }) +} diff --git a/internal/ui/login/keybinds.go b/internal/ui/login/keybinds.go deleted file mode 100644 index 6b598bd..0000000 --- a/internal/ui/login/keybinds.go +++ /dev/null @@ -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() -} diff --git a/internal/ui/login/keymap.go b/internal/ui/login/keymap.go new file mode 100644 index 0000000..307bf58 --- /dev/null +++ b/internal/ui/login/keymap.go @@ -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 +} diff --git a/internal/ui/login/keymap_test.go b/internal/ui/login/keymap_test.go new file mode 100644 index 0000000..7b93b3f --- /dev/null +++ b/internal/ui/login/keymap_test.go @@ -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")) + } + }) + } +} diff --git a/internal/ui/login/model.go b/internal/ui/login/model.go index f4f66bb..19ff918 100644 --- a/internal/ui/login/model.go +++ b/internal/ui/login/model.go @@ -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 { diff --git a/internal/ui/login/password/model.go b/internal/ui/login/password/model.go index 904cdf7..0745207 100644 --- a/internal/ui/login/password/model.go +++ b/internal/ui/login/password/model.go @@ -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) } diff --git a/internal/ui/login/password/msg.go b/internal/ui/login/password/msg.go index a4631b9..3f412f4 100644 --- a/internal/ui/login/password/msg.go +++ b/internal/ui/login/password/msg.go @@ -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") diff --git a/internal/ui/login/qr/model.go b/internal/ui/login/qr/model.go index fa95548..1b6aafd 100644 --- a/internal/ui/login/qr/model.go +++ b/internal/ui/login/qr/model.go @@ -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() } diff --git a/internal/ui/login/qr/model_test.go b/internal/ui/login/qr/model_test.go index 2aad336..fe259a8 100644 --- a/internal/ui/login/qr/model_test.go +++ b/internal/ui/login/qr/model_test.go @@ -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()) } }) } diff --git a/internal/ui/login/qr/msg.go b/internal/ui/login/qr/msg.go index 483fd75..21192bc 100644 --- a/internal/ui/login/qr/msg.go +++ b/internal/ui/login/qr/msg.go @@ -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{} diff --git a/internal/ui/login/token/model.go b/internal/ui/login/token/model.go index 199ea04..d5926fb 100644 --- a/internal/ui/login/token/model.go +++ b/internal/ui/login/token/model.go @@ -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) } diff --git a/internal/ui/login/token/msg.go b/internal/ui/login/token/msg.go index c945d63..721ed5c 100644 --- a/internal/ui/login/token/msg.go +++ b/internal/ui/login/token/msg.go @@ -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) } diff --git a/internal/ui/root/inert.go b/internal/ui/root/inert.go new file mode 100644 index 0000000..8a3b8a5 --- /dev/null +++ b/internal/ui/root/inert.go @@ -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 +} diff --git a/internal/ui/root/keybinds.go b/internal/ui/root/keymap.go similarity index 100% rename from internal/ui/root/keybinds.go rename to internal/ui/root/keymap.go diff --git a/internal/ui/root/model.go b/internal/ui/root/model.go index ca8e9fc..3dda2c9 100644 --- a/internal/ui/root/model.go +++ b/internal/ui/root/model.go @@ -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 -} diff --git a/internal/ui/root/model_test.go b/internal/ui/root/model_test.go index 181ba46..c6de82b 100644 --- a/internal/ui/root/model_test.go +++ b/internal/ui/root/model_test.go @@ -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") } }) } diff --git a/internal/ui/root/msg.go b/internal/ui/root/msg.go index b8943e7..2ae9abb 100644 --- a/internal/ui/root/msg.go +++ b/internal/ui/root/msg.go @@ -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) } diff --git a/internal/ui/util.go b/internal/ui/util.go index 6bcbe11..024f584 100644 --- a/internal/ui/util.go +++ b/internal/ui/util.go @@ -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 +}