refactor(ui): migrate to declarative tview
This commit is contained in:
parent
08b41176c0
commit
30810afde2
49 changed files with 2031 additions and 1529 deletions
|
|
@ -62,8 +62,10 @@ func Run() error {
|
|||
screen.EnableMouse()
|
||||
}
|
||||
screen.EnablePaste()
|
||||
if cfg.Notifications.WhenUnfocused {
|
||||
screen.EnableFocus()
|
||||
}
|
||||
|
||||
tview.Styles = tview.Theme{}
|
||||
model := root.NewModel(cfg)
|
||||
return tview.NewApplication(model, tview.WithScreen(screen)).Run()
|
||||
}
|
||||
|
|
|
|||
2
go.mod
2
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
|
||||
|
|
|
|||
4
go.sum
4
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=
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ type (
|
|||
|
||||
Notifications struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
WhenUnfocused bool `toml:"when_unfocused"`
|
||||
}
|
||||
|
||||
TypingIndicator struct {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
if msg, ok := msg.(actionMsg); ok {
|
||||
m.searchState.Perform(picker.Action(msg))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return func() tview.Msg { return SelectedMsg{Action: action} }
|
||||
case picker.CancelMsg:
|
||||
return func() tview.Msg { return CancelMsg{} }
|
||||
}
|
||||
|
||||
return m.Model.Update(msg)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,35 +13,47 @@ import (
|
|||
)
|
||||
|
||||
type Model struct {
|
||||
*picker.Model
|
||||
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)
|
||||
// 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 func() tview.Msg { return SelectedMsg{ChannelID: channelID} }
|
||||
case picker.CancelMsg:
|
||||
return func() tview.Msg { return CancelMsg{} }
|
||||
}
|
||||
return m.Model.Update(msg)
|
||||
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 {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
144
internal/ui/chat/composer/model_test.go
Normal file
144
internal/ui/chat/composer/model_test.go
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
package composer
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/ayn2op/arikawa/v3/discord"
|
||||
"github.com/ayn2op/arikawa/v3/gateway"
|
||||
"github.com/ayn2op/arikawa/v3/state"
|
||||
"github.com/ayn2op/arikawa/v3/utils/sendpart"
|
||||
"github.com/ayn2op/discordo/internal/config"
|
||||
"github.com/ayn2op/ningen/v3"
|
||||
"github.com/ayn2op/tview"
|
||||
"github.com/ayn2op/tview/keybind"
|
||||
"github.com/gdamore/tcell/v3"
|
||||
)
|
||||
|
||||
// newTestModel returns an enabled composer with the default config.
|
||||
func newTestModel(t *testing.T) (*Model, *config.Config) {
|
||||
t.Helper()
|
||||
cfg, err := config.Load(filepath.Join(t.TempDir(), "config.toml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := NewModel(cfg, ningen.FromState(state.New("")))
|
||||
c.disabled = false
|
||||
return c, cfg
|
||||
}
|
||||
|
||||
func TestModelUpdate(t *testing.T) {
|
||||
up := tcell.NewEventKey(tcell.KeyUp, "", tcell.ModNone)
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
setup func(*Model, *config.Config) tview.KeyMsg
|
||||
want bool
|
||||
}{
|
||||
{"empty", func(*Model, *config.Config) tview.KeyMsg { return up }, true},
|
||||
{"draft", func(c *Model, _ *config.Config) tview.KeyMsg { c.setText("draft"); return up }, false},
|
||||
{"reply", func(c *Model, _ *config.Config) tview.KeyMsg {
|
||||
c.StartReply(discord.Message{ID: 3}, "name", false)
|
||||
return up
|
||||
}, false},
|
||||
{"attachment", func(c *Model, _ *config.Config) tview.KeyMsg {
|
||||
c.sendMessageData.Files = []sendpart.File{{Name: "file"}}
|
||||
return up
|
||||
}, false},
|
||||
{"editing", func(c *Model, _ *config.Config) tview.KeyMsg { c.StartEdit(discord.Message{}); return up }, false},
|
||||
{"disabled", func(c *Model, _ *config.Config) tview.KeyMsg { c.disabled = true; return up }, false},
|
||||
{"remapped", func(_ *Model, cfg *config.Config) tview.KeyMsg {
|
||||
cfg.Keybinds.Composer.EditLast.Keybind = keybind.New("ctrl+p")
|
||||
return tcell.NewEventKey(tcell.KeyCtrlP, "", tcell.ModNone)
|
||||
}, true},
|
||||
{"unbound", func(_ *Model, cfg *config.Config) tview.KeyMsg {
|
||||
cfg.Keybinds.Composer.EditLast.Keybind = keybind.New()
|
||||
return up
|
||||
}, false},
|
||||
} {
|
||||
t.Run("edit last message: "+tt.name, func(t *testing.T) {
|
||||
c, cfg := newTestModel(t)
|
||||
cmd := c.Update(tt.setup(c, cfg))
|
||||
if got := cmd != nil && cmd() == (EditLastMsg{}); got != tt.want {
|
||||
t.Fatalf("asked to edit the last message = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelHandle(t *testing.T) {
|
||||
c, _ := newTestModel(t)
|
||||
area := tview.Rectangle{Width: 20, Height: 3}
|
||||
|
||||
t.Run("typing edits the text", func(t *testing.T) {
|
||||
msg := c.View(true).Handle(tcell.NewEventKey(tcell.KeyRune, "a", tcell.ModNone), area)
|
||||
if _, ok := msg.(editMsg); !ok {
|
||||
t.Fatalf("got %v, want an editMsg", msg)
|
||||
}
|
||||
c.Update(msg)
|
||||
if c.editState.Value() != "a" {
|
||||
t.Fatalf("text = %q", c.editState.Value())
|
||||
}
|
||||
})
|
||||
t.Run("the newline keybind inserts a newline", func(t *testing.T) {
|
||||
msg := c.View(true).Handle(tcell.NewEventKey(tcell.KeyEnter, "", tcell.ModShift), area)
|
||||
if _, ok := msg.(editMsg); !ok {
|
||||
t.Fatalf("got %v, want an editMsg", msg)
|
||||
}
|
||||
c.Update(msg)
|
||||
if c.editState.Value() != "a\n" {
|
||||
t.Fatalf("text = %q", c.editState.Value())
|
||||
}
|
||||
})
|
||||
t.Run("bound keys are left to Update", func(t *testing.T) {
|
||||
enter := tcell.NewEventKey(tcell.KeyEnter, "", tcell.ModNone)
|
||||
if got := c.View(true).Handle(enter, area); got != enter {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestModelSearchMember(t *testing.T) {
|
||||
c, _ := newTestModel(t)
|
||||
c.state.MemberState.SearchLimit = 2
|
||||
c.CacheMemberSearch(&gateway.GuildMembersChunkEvent{
|
||||
Nonce: memberSearchNonce + "1 ab",
|
||||
Members: []discord.Member{{}},
|
||||
})
|
||||
if cmd := c.searchMember(1, "ab"); cmd != nil {
|
||||
t.Fatal("cached query returned a network command")
|
||||
}
|
||||
if cmd := c.searchMember(1, "abc"); cmd != nil || c.memberSearchCache["1 abc"] != 1 {
|
||||
t.Fatal("incomplete prefix results were not reused")
|
||||
}
|
||||
c.memberSearchCache["1 a"] = 2
|
||||
c.memberSearchCache["2 a"] = 2
|
||||
c.OnGuildMemberRemove(&gateway.GuildMemberRemoveEvent{
|
||||
GuildID: 1,
|
||||
User: discord.User{Username: "abc"},
|
||||
})
|
||||
want := map[string]uint{"1 ab": 1, "1 abc": 1, "2 a": 2}
|
||||
if !maps.Equal(c.memberSearchCache, want) {
|
||||
t.Fatalf("cache after invalidation = %v, want %v", c.memberSearchCache, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelTabSuggest(t *testing.T) {
|
||||
c, _ := newTestModel(t)
|
||||
if err := c.state.Cabinet.MyselfSet(discord.User{ID: 1, Username: "1"}, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
channel := discord.Channel{ID: 1, Type: discord.DirectMessage}
|
||||
for id, author := range []discord.UserID{2, 3, 2, 1} {
|
||||
message := discord.Message{ID: discord.MessageID(id + 1), ChannelID: 1, Author: discord.User{ID: author, Username: author.String()}}
|
||||
if err := c.state.Cabinet.MessageSet(&message, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
c.SetChannel(&channel)
|
||||
c.setText("@")
|
||||
c.tabSuggest()
|
||||
if got := c.mentionsList.ItemCount(); got != 2 {
|
||||
t.Fatalf("suggested %d authors, want 2", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -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
|
||||
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
|
||||
m.entries = make([]list.Item, len(m.items))
|
||||
for i, item := range m.items {
|
||||
m.entries[i] = row{text: item.DisplayText, style: item.Style}
|
||||
}
|
||||
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.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 }
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
36
internal/ui/chat/messageslist/msg.go
Normal file
36
internal/ui/chat/messageslist/msg.go
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
package messageslist
|
||||
|
||||
import (
|
||||
"github.com/ayn2op/arikawa/v3/discord"
|
||||
"github.com/ayn2op/discordo/internal/ui/chat/attachmentspicker"
|
||||
"github.com/ayn2op/tview"
|
||||
)
|
||||
|
||||
// Msg is implemented by the messages the list sends itself, which must reach it whatever has the focus.
|
||||
type Msg interface{ messagesList() }
|
||||
|
||||
type olderMessagesLoadedMsg struct {
|
||||
ChannelID discord.ChannelID
|
||||
Older []discord.Message
|
||||
}
|
||||
|
||||
type deleteMessageMsg discord.Message
|
||||
|
||||
type attachmentActionMsg struct{ Action tview.Cmd }
|
||||
|
||||
func (olderMessagesLoadedMsg) messagesList() {}
|
||||
func (deleteMessageMsg) messagesList() {}
|
||||
func (attachmentActionMsg) messagesList() {}
|
||||
|
||||
// ReplyMsg asks to reply to Message by Name, mentioning its author if Mention is set.
|
||||
type ReplyMsg struct {
|
||||
Message discord.Message
|
||||
Name string
|
||||
Mention bool
|
||||
}
|
||||
|
||||
// EditMsg asks to edit a message.
|
||||
type EditMsg discord.Message
|
||||
|
||||
// ShowAttachmentsMsg asks to pick one of the attachments and links of the selected message.
|
||||
type ShowAttachmentsMsg []attachmentspicker.Item
|
||||
|
|
@ -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
|
||||
// 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)
|
||||
}
|
||||
return m.Layers.Update(msg)
|
||||
if msg.msg == nil {
|
||||
return nil
|
||||
}
|
||||
return msg.pane.Update(msg.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)
|
||||
}
|
||||
|
||||
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)),
|
||||
)
|
||||
}
|
||||
m.Layers.View(screen)
|
||||
|
||||
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()
|
||||
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
|
||||
|
|
|
|||
79
internal/ui/chat/model_test.go
Normal file
79
internal/ui/chat/model_test.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
package chat
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/ayn2op/arikawa/v3/discord"
|
||||
"github.com/ayn2op/arikawa/v3/gateway"
|
||||
"github.com/ayn2op/discordo/internal/config"
|
||||
"github.com/ayn2op/tview"
|
||||
"github.com/gdamore/tcell/v3"
|
||||
)
|
||||
|
||||
func newTestModel(t *testing.T) *Model {
|
||||
t.Helper()
|
||||
cfg, err := config.Load(filepath.Join(t.TempDir(), "config.toml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return NewModel(cfg, "")
|
||||
}
|
||||
|
||||
func TestModelHandle(t *testing.T) {
|
||||
t.Run("typing in the channels picker", func(t *testing.T) {
|
||||
m := newTestModel(t)
|
||||
m.openPicker()
|
||||
area := tview.Rectangle{Width: 120, Height: 40}
|
||||
// The picker's action passes through the panes below it, which must leave it alone.
|
||||
msg := m.View().Handle(tcell.NewEventKey(tcell.KeyRune, "a", tcell.ModNone), area)
|
||||
if msg == nil {
|
||||
t.Fatal("typing produced no message")
|
||||
}
|
||||
if _, ok := msg.(paneMsg); ok {
|
||||
t.Fatalf("the picker's action was marked for a pane: %v", msg)
|
||||
}
|
||||
m.Update(msg)
|
||||
})
|
||||
}
|
||||
|
||||
func TestModelUpdate(t *testing.T) {
|
||||
t.Run("typing expires unless extended", func(t *testing.T) {
|
||||
m := newTestModel(t)
|
||||
m.addTyper(1)
|
||||
first := m.typers[1]
|
||||
m.addTyper(1)
|
||||
m.Update(typingExpiredMsg{userID: 1, until: first})
|
||||
if _, ok := m.typers[1]; !ok {
|
||||
t.Fatal("an extended typing indicator expired early")
|
||||
}
|
||||
m.Update(typingExpiredMsg{userID: 1, until: m.typers[1]})
|
||||
if _, ok := m.typers[1]; ok {
|
||||
t.Fatal("the typing indicator did not expire")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestModelAddMessageOrNotify(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
focused bool
|
||||
whenUnfocused bool
|
||||
want bool
|
||||
}{
|
||||
{"focused", true, true, false},
|
||||
{"unfocused", false, true, true},
|
||||
{"unfocused but disabled", false, false, false},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
m := newTestModel(t)
|
||||
m.cfg.Notifications.WhenUnfocused = tt.whenUnfocused
|
||||
m.setSelectedChannel(&discord.Channel{ID: 1})
|
||||
m.Update(tcell.NewEventFocus(tt.focused))
|
||||
cmd := m.addMessageOrNotify(&gateway.MessageCreateEvent{Message: discord.Message{ID: 1, ChannelID: 1}})
|
||||
if got := cmd != nil; got != tt.want {
|
||||
t.Fatalf("notified = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
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)
|
||||
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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)
|
||||
|
||||
case *gateway.TypingStartEvent:
|
||||
if m.cfg.TypingIndicator.Receive {
|
||||
return m.showTypingIndicator(event)
|
||||
}
|
||||
|
||||
case *read.UpdateEvent:
|
||||
m.guildsTree.Update(event)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
89
internal/ui/login/form/model.go
Normal file
89
internal/ui/login/form/model.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
// Package form lays out labeled text inputs above a submit button.
|
||||
package form
|
||||
|
||||
import (
|
||||
"github.com/ayn2op/tview"
|
||||
"github.com/ayn2op/tview/button"
|
||||
"github.com/ayn2op/tview/column"
|
||||
"github.com/ayn2op/tview/row"
|
||||
"github.com/ayn2op/tview/text"
|
||||
"github.com/ayn2op/tview/textinput"
|
||||
"github.com/gdamore/tcell/v3"
|
||||
"github.com/rivo/uniseg"
|
||||
)
|
||||
|
||||
// Field is a labeled text input. A non-empty Mask hides what is typed, such as for passwords.
|
||||
type Field struct {
|
||||
Label, Mask string
|
||||
EditState textinput.EditState
|
||||
}
|
||||
|
||||
// Model is a column of fields above a button. Tab and the arrow keys move the focus, Enter in a field moves to the next one, and Enter on the button or clicking it submits.
|
||||
type Model struct {
|
||||
fields []Field
|
||||
button string
|
||||
focus int
|
||||
}
|
||||
|
||||
// SubmitMsg is returned when the button is pressed.
|
||||
type SubmitMsg struct{}
|
||||
|
||||
type editMsg struct {
|
||||
index int
|
||||
action textinput.Action
|
||||
}
|
||||
|
||||
type nextMsg struct{}
|
||||
|
||||
func New(button string, fields ...Field) *Model {
|
||||
return &Model{fields: fields, button: button}
|
||||
}
|
||||
|
||||
// Value returns the text of field index.
|
||||
func (m *Model) Value(index int) string {
|
||||
return m.fields[index].EditState.Value()
|
||||
}
|
||||
|
||||
func (m *Model) Init() tview.Cmd { return nil }
|
||||
|
||||
func (m *Model) Update(msg tview.Msg) tview.Cmd {
|
||||
count := len(m.fields) + 1
|
||||
switch msg := msg.(type) {
|
||||
case editMsg:
|
||||
m.fields[msg.index].EditState.Perform(msg.action)
|
||||
case nextMsg:
|
||||
m.focus = (m.focus + 1) % count
|
||||
case tview.KeyMsg:
|
||||
switch msg.Key() {
|
||||
case tcell.KeyTab, tcell.KeyDown:
|
||||
m.focus = (m.focus + 1) % count
|
||||
case tcell.KeyBacktab, tcell.KeyUp:
|
||||
m.focus = (m.focus + count - 1) % count
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) View() tview.Element {
|
||||
labelWidth := 0
|
||||
for _, f := range m.fields {
|
||||
labelWidth = max(labelWidth, uniseg.StringWidth(f.Label)+1)
|
||||
}
|
||||
layout := column.New().Spacing(1)
|
||||
for i := range m.fields {
|
||||
f := &m.fields[i]
|
||||
input := textinput.New(&f.EditState).
|
||||
Mask(f.Mask).
|
||||
Focused(m.focus == i).
|
||||
OnAction(func(a textinput.Action) tview.Msg { return editMsg{index: i, action: a} }).
|
||||
OnSubmit(nextMsg{})
|
||||
layout = layout.Push(row.New(column.New(text.New(f.Label)).Width(tview.Fixed(labelWidth)), input).Height(tview.Fixed(1)))
|
||||
}
|
||||
submit := button.New().
|
||||
Label(m.button).
|
||||
Width(tview.Fixed(uniseg.StringWidth(m.button) + 4)).
|
||||
Height(tview.Fixed(1)).
|
||||
Focused(m.focus == len(m.fields)).
|
||||
OnClick(SubmitMsg{})
|
||||
return layout.Push(submit)
|
||||
}
|
||||
36
internal/ui/login/form/model_test.go
Normal file
36
internal/ui/login/form/model_test.go
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
package form
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ayn2op/tview"
|
||||
"github.com/gdamore/tcell/v3"
|
||||
)
|
||||
|
||||
func TestModelUpdate(t *testing.T) {
|
||||
area := tview.Rectangle{Width: 30, Height: 5}
|
||||
// send delivers msg the way the application does: through the view, then Update.
|
||||
send := func(m *Model, msg tview.Msg) tview.Msg {
|
||||
if msg = m.View().Handle(msg, area); msg != nil {
|
||||
m.Update(msg)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
key := func(k tcell.Key, str string) tview.KeyMsg { return tcell.NewEventKey(k, str, tcell.ModNone) }
|
||||
|
||||
m := New("Login", Field{Label: "Login"}, Field{Label: "Password", Mask: "*"})
|
||||
send(m, key(tcell.KeyRune, "a"))
|
||||
send(m, key(tcell.KeyEnter, ""))
|
||||
send(m, key(tcell.KeyRune, "b"))
|
||||
t.Run("typing fills the focused field and Enter moves on", func(t *testing.T) {
|
||||
if m.Value(0) != "a" || m.Value(1) != "b" {
|
||||
t.Fatalf("values = %q, %q", m.Value(0), m.Value(1))
|
||||
}
|
||||
})
|
||||
t.Run("enter on the button submits", func(t *testing.T) {
|
||||
send(m, key(tcell.KeyTab, ""))
|
||||
if got := send(m, key(tcell.KeyEnter, "")); got != (SubmitMsg{}) {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
39
internal/ui/login/keymap.go
Normal file
39
internal/ui/login/keymap.go
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
package login
|
||||
|
||||
import (
|
||||
"github.com/ayn2op/tview/help"
|
||||
"github.com/ayn2op/tview/keybind"
|
||||
)
|
||||
|
||||
var _ help.KeyMap = (*Model)(nil)
|
||||
|
||||
func (m *Model) ShortHelp() []keybind.Keybind {
|
||||
short := m.navHelp()
|
||||
if keyMap, ok := m.tabs[m.active].(help.KeyMap); ok {
|
||||
short = append(short, keyMap.ShortHelp()...)
|
||||
}
|
||||
return short
|
||||
}
|
||||
|
||||
func (m *Model) FullHelp() [][]keybind.Keybind {
|
||||
var full [][]keybind.Keybind
|
||||
if nav := m.navHelp(); len(nav) > 0 {
|
||||
full = append(full, nav)
|
||||
}
|
||||
if keyMap, ok := m.tabs[m.active].(help.KeyMap); ok {
|
||||
full = append(full, keyMap.FullHelp()...)
|
||||
}
|
||||
return full
|
||||
}
|
||||
|
||||
// navHelp returns the tab keybinds that switch to another tab from the active one.
|
||||
func (m *Model) navHelp() []keybind.Keybind {
|
||||
var nav []keybind.Keybind
|
||||
if m.active > 0 {
|
||||
nav = append(nav, m.keybinds.Previous)
|
||||
}
|
||||
if m.active < len(m.tabs)-1 {
|
||||
nav = append(nav, m.keybinds.Next)
|
||||
}
|
||||
return nav
|
||||
}
|
||||
38
internal/ui/login/keymap_test.go
Normal file
38
internal/ui/login/keymap_test.go
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
package login
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/ayn2op/discordo/internal/config"
|
||||
"github.com/ayn2op/tview/keybind"
|
||||
)
|
||||
|
||||
func TestModelShortHelp(t *testing.T) {
|
||||
cfg, err := config.Load(filepath.Join(t.TempDir(), "config.toml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := NewModel(cfg)
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
active int
|
||||
wantPrev, wantNext bool
|
||||
}{
|
||||
{"first tab", 0, false, true},
|
||||
{"middle tab", 1, true, true},
|
||||
{"last tab", len(m.tabs) - 1, true, false},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
m.active = tt.active
|
||||
short := m.ShortHelp()
|
||||
has := func(desc string) bool {
|
||||
return slices.ContainsFunc(short, func(k keybind.Keybind) bool { return k.Help().Desc == desc })
|
||||
}
|
||||
if has("prev tab") != tt.wantPrev || has("next tab") != tt.wantNext {
|
||||
t.Fatalf("prev = %v, next = %v", has("prev tab"), has("next tab"))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import (
|
|||
|
||||
type TokenMsg string
|
||||
|
||||
func tokenCmd(token string) tview.Cmd {
|
||||
func submitToken(token string) tview.Cmd {
|
||||
return func() tview.Msg {
|
||||
return TokenMsg(token)
|
||||
}
|
||||
|
|
|
|||
18
internal/ui/root/inert.go
Normal file
18
internal/ui/root/inert.go
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
package root
|
||||
|
||||
import "github.com/ayn2op/tview"
|
||||
|
||||
// inert draws child but gives it no input, like HTML's inert attribute, for what is behind the dialog.
|
||||
type inert struct {
|
||||
child tview.Element
|
||||
}
|
||||
|
||||
var _ tview.Element = inert{}
|
||||
|
||||
func (i inert) Draw(screen tview.Screen, area tview.Rectangle) {
|
||||
i.child.Draw(screen, area)
|
||||
}
|
||||
|
||||
func (inert) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg {
|
||||
return msg
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
rootFlex *flex.Model // inner + help
|
||||
inner tview.Model
|
||||
help *help.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
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue