refactor(ui): hold widget state as plain values in the models
This commit is contained in:
parent
7e5c3dc62c
commit
95f5fb91a1
27 changed files with 224 additions and 172 deletions
6
go.mod
6
go.mod
|
|
@ -15,7 +15,7 @@ require (
|
||||||
github.com/andybalholm/brotli v1.2.6
|
github.com/andybalholm/brotli v1.2.6
|
||||||
github.com/ayn2op/arikawa/v3 v3.0.0-20261012034659-5b006907b2de
|
github.com/ayn2op/arikawa/v3 v3.0.0-20261012034659-5b006907b2de
|
||||||
github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e
|
github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e
|
||||||
github.com/ayn2op/tview v0.0.0-20261013221052-78efeee1ac7c
|
github.com/ayn2op/tview v0.0.0-20261013225942-7c4cbe85112a
|
||||||
github.com/bogdanfinn/fhttp v0.6.9
|
github.com/bogdanfinn/fhttp v0.6.9
|
||||||
github.com/bogdanfinn/tls-client v1.16.0
|
github.com/bogdanfinn/tls-client v1.16.0
|
||||||
github.com/gdamore/tcell/v3 v3.6.0
|
github.com/gdamore/tcell/v3 v3.6.0
|
||||||
|
|
@ -52,7 +52,7 @@ require (
|
||||||
github.com/dlclark/regexp2/v2 v2.8.4 // indirect
|
github.com/dlclark/regexp2/v2 v2.8.4 // indirect
|
||||||
github.com/ebitengine/purego v0.11.1 // indirect
|
github.com/ebitengine/purego v0.11.1 // indirect
|
||||||
github.com/gdamore/encoding v1.0.1 // indirect
|
github.com/gdamore/encoding v1.0.1 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.2.3 // indirect
|
github.com/godbus/dbus/v5 v5.2.4 // indirect
|
||||||
github.com/gorilla/schema v1.4.1 // indirect
|
github.com/gorilla/schema v1.4.1 // indirect
|
||||||
github.com/josephspurrier/goversioninfo v1.7.0 // indirect
|
github.com/josephspurrier/goversioninfo v1.7.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
|
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
|
||||||
|
|
@ -65,7 +65,7 @@ require (
|
||||||
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
|
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
|
||||||
golang.design/x/x11 v0.2.0 // indirect
|
golang.design/x/x11 v0.2.0 // indirect
|
||||||
golang.org/x/crypto v0.58.0 // indirect
|
golang.org/x/crypto v0.58.0 // indirect
|
||||||
golang.org/x/exp/shiny v0.0.0-20261011100332-faecfb24eb49 // indirect
|
golang.org/x/exp/shiny v0.0.0-20261013213113-2ab7f3cbc5fb // indirect
|
||||||
golang.org/x/mobile v0.0.0-20261011034528-197770444867 // indirect
|
golang.org/x/mobile v0.0.0-20261011034528-197770444867 // indirect
|
||||||
golang.org/x/net v0.61.0 // indirect
|
golang.org/x/net v0.61.0 // indirect
|
||||||
golang.org/x/sys v0.49.0 // indirect
|
golang.org/x/sys v0.49.0 // indirect
|
||||||
|
|
|
||||||
12
go.sum
12
go.sum
|
|
@ -14,8 +14,8 @@ github.com/ayn2op/arikawa/v3 v3.0.0-20261012034659-5b006907b2de h1:bO825TKutshlG
|
||||||
github.com/ayn2op/arikawa/v3 v3.0.0-20261012034659-5b006907b2de/go.mod h1:YARcqZmk7hDjXB+lexlXX4wDFHULafbGnF0gsD0tuNw=
|
github.com/ayn2op/arikawa/v3 v3.0.0-20261012034659-5b006907b2de/go.mod h1:YARcqZmk7hDjXB+lexlXX4wDFHULafbGnF0gsD0tuNw=
|
||||||
github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e h1:QAei/IVEyZV7GcpkpcFUh37+QRqwrt57z7OT3ciHrhI=
|
github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e h1:QAei/IVEyZV7GcpkpcFUh37+QRqwrt57z7OT3ciHrhI=
|
||||||
github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e/go.mod h1:RsvYiMzi4Si1F7vmml5NCL2DaKIZXkNUQC7QlGTMtCw=
|
github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e/go.mod h1:RsvYiMzi4Si1F7vmml5NCL2DaKIZXkNUQC7QlGTMtCw=
|
||||||
github.com/ayn2op/tview v0.0.0-20261013221052-78efeee1ac7c h1:WgEp8OeS15d15kjPVPOPpnSsaKYJZYjTIKxBbr8b5kI=
|
github.com/ayn2op/tview v0.0.0-20261013225942-7c4cbe85112a h1:b92yhUuO4jsY8eTqTEEJO+SjpkIIngSzhnJA//FEw6M=
|
||||||
github.com/ayn2op/tview v0.0.0-20261013221052-78efeee1ac7c/go.mod h1:3aWH6S5uOqOkZv7JFePEVHukTj9hPb6Lbynm59Sks2E=
|
github.com/ayn2op/tview v0.0.0-20261013225942-7c4cbe85112a/go.mod h1:3aWH6S5uOqOkZv7JFePEVHukTj9hPb6Lbynm59Sks2E=
|
||||||
github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q=
|
github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q=
|
||||||
github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM=
|
github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM=
|
||||||
github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic=
|
github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic=
|
||||||
|
|
@ -52,8 +52,8 @@ github.com/gdamore/tcell/v3 v3.6.0 h1:jW20qS6Tm38KVDqLHq9KWSvyjNl00FbhbD8rIAnHBw
|
||||||
github.com/gdamore/tcell/v3 v3.6.0/go.mod h1:CBZxkl2T+XxEyuBfOh4zaahqm27dngvhfBfCCZxJsgc=
|
github.com/gdamore/tcell/v3 v3.6.0/go.mod h1:CBZxkl2T+XxEyuBfOh4zaahqm27dngvhfBfCCZxJsgc=
|
||||||
github.com/gen2brain/malgo v0.11.26 h1:k5WcPIKw1bbJAbPqrvNPt7nehPLoaPNcOFde2+eruiM=
|
github.com/gen2brain/malgo v0.11.26 h1:k5WcPIKw1bbJAbPqrvNPt7nehPLoaPNcOFde2+eruiM=
|
||||||
github.com/gen2brain/malgo v0.11.26/go.mod h1:xLVG3ROA33Bzol1quF3e4ehqcFuqh8QK4B8T6LQUs/M=
|
github.com/gen2brain/malgo v0.11.26/go.mod h1:xLVG3ROA33Bzol1quF3e4ehqcFuqh8QK4B8T6LQUs/M=
|
||||||
github.com/godbus/dbus/v5 v5.2.3 h1:IQNSXULh6Nc4ZH8ETJ5Mdsj8loUFCWSBnsjmJkyjUB8=
|
github.com/godbus/dbus/v5 v5.2.4 h1:F2gUCy++NMIxGmo9J+z5le/+ZC8W1OgeR51P4i9ZB/U=
|
||||||
github.com/godbus/dbus/v5 v5.2.3/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
github.com/godbus/dbus/v5 v5.2.4/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||||
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
|
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
|
||||||
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
|
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
|
@ -123,8 +123,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||||
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
||||||
golang.org/x/exp/shiny v0.0.0-20261011100332-faecfb24eb49 h1:APVj9ArMe2rwsS6jJid0LkBkcs+JVYu5TK6DRwJ+dpk=
|
golang.org/x/exp/shiny v0.0.0-20261013213113-2ab7f3cbc5fb h1:TOeg6q45IKFyKG+5BOYsLtYmleQjJWDlm5CIM0zB/uA=
|
||||||
golang.org/x/exp/shiny v0.0.0-20261011100332-faecfb24eb49/go.mod h1:ZdXIpWF4Owq+1g/S4uCKu+fMhV3xxPseTF4wQNeKtgM=
|
golang.org/x/exp/shiny v0.0.0-20261013213113-2ab7f3cbc5fb/go.mod h1:ZdXIpWF4Owq+1g/S4uCKu+fMhV3xxPseTF4wQNeKtgM=
|
||||||
golang.org/x/image v0.47.0 h1:za/8gVz++PPZqmZJBNSISICN5Rrj5zixP/8K++fgmqo=
|
golang.org/x/image v0.47.0 h1:za/8gVz++PPZqmZJBNSISICN5Rrj5zixP/8K++fgmqo=
|
||||||
golang.org/x/image v0.47.0/go.mod h1:IkN5o4A7vq55vXN8FWjjnhg9Rs2GOtffqsSi9mWAKYE=
|
golang.org/x/image v0.47.0/go.mod h1:IkN5o4A7vq55vXN8FWjjnhg9Rs2GOtffqsSi9mWAKYE=
|
||||||
golang.org/x/mobile v0.0.0-20261011034528-197770444867 h1:rav73cdd7OQUPgt1aIFbZQTiPNnjfueKXvfOiLCY0Ss=
|
golang.org/x/mobile v0.0.0-20261011034528-197770444867 h1:rav73cdd7OQUPgt1aIFbZQTiPNnjfueKXvfOiLCY0Ss=
|
||||||
|
|
|
||||||
|
|
@ -51,8 +51,12 @@ const (
|
||||||
var mentionRegex = regexp.MustCompile("@[a-zA-Z0-9._]+")
|
var mentionRegex = regexp.MustCompile("@[a-zA-Z0-9._]+")
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
editState textarea.EditState
|
text string
|
||||||
disabled bool
|
// cursor is the byte offset of the cursor in text, and history what they were before each change, for undo.
|
||||||
|
cursor int
|
||||||
|
history []edit
|
||||||
|
|
||||||
|
disabled bool
|
||||||
|
|
||||||
state *ningen.State
|
state *ningen.State
|
||||||
// channel is the selected channel, or nil for none.
|
// channel is the selected channel, or nil for none.
|
||||||
|
|
@ -138,11 +142,11 @@ func (v view) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg {
|
||||||
|
|
||||||
// box returns the text area in a box with the title and footer.
|
// box returns the text area in a box with the title and footer.
|
||||||
func (m *Model) box(focused bool) box.Widget {
|
func (m *Model) box(focused bool) box.Widget {
|
||||||
text := textarea.New(&m.editState).
|
text := textarea.New(m.text, m.cursor).
|
||||||
Placeholder(m.placeholder()).
|
Placeholder(m.placeholder()).
|
||||||
Keybind(m.editAction).
|
Keybind(m.editAction).
|
||||||
Focused(focused && !m.disabled).
|
Focused(focused && !m.disabled).
|
||||||
OnChange(func(a textarea.Change) tview.Msg { return editMsg(a) }).
|
OnInput(func(text string, cursor int) tview.Msg { return editMsg{text, cursor} }).
|
||||||
Height(layout.Shrink.Max(m.cfg.Composer.MaxHeight))
|
Height(layout.Shrink.Max(m.cfg.Composer.MaxHeight))
|
||||||
return ui.Box(text, m.cfg, focused).Title(m.title()).Footer(m.footer()).Height(layout.Shrink)
|
return ui.Box(text, m.cfg, focused).Title(m.title()).Footer(m.footer()).Height(layout.Shrink)
|
||||||
}
|
}
|
||||||
|
|
@ -182,9 +186,34 @@ func (m Model) isBound(key tview.KeyMsg) bool {
|
||||||
return keybind.Matches(key, k.EditLast.Keybind) && m.canEditLastMessage()
|
return keybind.Matches(key, k.EditLast.Keybind) && m.canEditLastMessage()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setText replaces the text and moves the cursor, remembering what they were for undo if the text changed.
|
||||||
|
func (m *Model) setText(text string, cursor int) {
|
||||||
|
if text != m.text {
|
||||||
|
m.history = append(m.history, edit{m.text, m.cursor})
|
||||||
|
}
|
||||||
|
|
||||||
|
m.text, m.cursor = text, cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
// replace replaces the bytes from start to end of the text with text and moves the cursor after it.
|
||||||
|
func (m *Model) replace(start, end int, text string) {
|
||||||
|
m.setText(m.text[:start]+text+m.text[end:], start+len(text))
|
||||||
|
}
|
||||||
|
|
||||||
// insert inserts text at the cursor.
|
// insert inserts text at the cursor.
|
||||||
func (m *Model) insert(text string) {
|
func (m *Model) insert(text string) {
|
||||||
m.editState.Replace(m.editState.Cursor(), m.editState.Cursor(), text)
|
m.replace(m.cursor, m.cursor, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// undo restores the text and cursor from before the last change.
|
||||||
|
func (m *Model) undo() {
|
||||||
|
if len(m.history) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
last := m.history[len(m.history)-1]
|
||||||
|
m.history = m.history[:len(m.history)-1]
|
||||||
|
m.text, m.cursor = last.text, last.cursor
|
||||||
}
|
}
|
||||||
|
|
||||||
// pasteText inserts the text on the clipboard at the cursor.
|
// pasteText inserts the text on the clipboard at the cursor.
|
||||||
|
|
@ -200,7 +229,7 @@ func (m *Model) pasteText() {
|
||||||
func (m *Model) reset() {
|
func (m *Model) reset() {
|
||||||
m.editing = nil
|
m.editing = nil
|
||||||
m.sendMessageData = &api.SendMessageData{}
|
m.sendMessageData = &api.SendMessageData{}
|
||||||
m.editState.SetValue("")
|
m.setText("", 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update returns c changed in response to msg and a command to run, or nil.
|
// Update returns c changed in response to msg and a command to run, or nil.
|
||||||
|
|
@ -224,10 +253,10 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case editorMsg:
|
case editorMsg:
|
||||||
m.editState.SetValue(string(msg))
|
m.setText(string(msg), len(msg))
|
||||||
return m, nil
|
return m, nil
|
||||||
case editMsg:
|
case editMsg:
|
||||||
m.editState.Apply(textarea.Change(msg))
|
m.setText(msg.text, msg.cursor)
|
||||||
typingCmd := m.sendTyping()
|
typingCmd := m.sendTyping()
|
||||||
if m.cfg.AutocompleteLimit > 0 {
|
if m.cfg.AutocompleteLimit > 0 {
|
||||||
return m, tview.Batch(typingCmd, m.tabSuggest())
|
return m, tview.Batch(typingCmd, m.tabSuggest())
|
||||||
|
|
@ -269,7 +298,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
||||||
m.toggleReplyMention()
|
m.toggleReplyMention()
|
||||||
return m, nil
|
return m, nil
|
||||||
case keybind.Matches(msg, m.cfg.Keybinds.Composer.Undo.Keybind):
|
case keybind.Matches(msg, m.cfg.Keybinds.Composer.Undo.Keybind):
|
||||||
m.editState.Undo()
|
m.undo()
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
case mentionslist.Msg:
|
case mentionslist.Msg:
|
||||||
|
|
@ -281,7 +310,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) canEditLastMessage() bool {
|
func (m Model) canEditLastMessage() bool {
|
||||||
return !m.Disabled() && m.editing == nil && m.editState.Value() == "" &&
|
return !m.Disabled() && m.editing == nil && m.text == "" &&
|
||||||
m.sendMessageData.Reference == nil && len(m.sendMessageData.Files) == 0 &&
|
m.sendMessageData.Reference == nil && len(m.sendMessageData.Files) == 0 &&
|
||||||
!m.mentionsListVisible()
|
!m.mentionsListVisible()
|
||||||
}
|
}
|
||||||
|
|
@ -289,7 +318,7 @@ func (m Model) canEditLastMessage() bool {
|
||||||
// StartEdit puts message in the composer to be edited.
|
// StartEdit puts message in the composer to be edited.
|
||||||
func (m *Model) StartEdit(message discord.Message) {
|
func (m *Model) StartEdit(message discord.Message) {
|
||||||
m.editing = &message
|
m.editing = &message
|
||||||
m.editState.SetValue(message.Content)
|
m.setText(message.Content, len(message.Content))
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartReply makes the next message a reply to message by name, mentioning its author if mention is set.
|
// StartReply makes the next message a reply to message by name, mentioning its author if mention is set.
|
||||||
|
|
@ -508,7 +537,7 @@ func (p mentionsPopup) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg {
|
||||||
func (p mentionsPopup) area(area tview.Rectangle) tview.Rectangle {
|
func (p mentionsPopup) area(area tview.Rectangle) tview.Rectangle {
|
||||||
c := p.c
|
c := p.c
|
||||||
size := c.mentionsList.View().Layout(layout.Limits{Max: layout.Size{Width: area.Width, Height: area.Height}})
|
size := c.mentionsList.View().Layout(layout.Limits{Max: layout.Size{Width: area.Width, Height: area.Height}})
|
||||||
value, cursor := c.editState.Value(), c.editState.Cursor()
|
value, cursor := c.text, c.cursor
|
||||||
col := uniseg.StringWidth(value[strings.LastIndex(value[:cursor], "\n")+1 : cursor])
|
col := uniseg.StringWidth(value[strings.LastIndex(value[:cursor], "\n")+1 : cursor])
|
||||||
x := min(c.box(false).InnerArea(tview.Rectangle{}).X+col, area.Width-size.Width)
|
x := min(c.box(false).InnerArea(tview.Rectangle{}).X+col, area.Width-size.Width)
|
||||||
return tview.Rectangle{X: area.X + x, Y: area.Y + area.Height - size.Height, Width: size.Width, Height: size.Height}
|
return tview.Rectangle{X: area.X + x, Y: area.Y + area.Height - size.Height, Width: size.Width, Height: size.Height}
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ func TestModelUpdate(t *testing.T) {
|
||||||
want bool
|
want bool
|
||||||
}{
|
}{
|
||||||
{"empty", func(*Model, *config.Config) tview.KeyMsg { return up }, true},
|
{"empty", func(*Model, *config.Config) tview.KeyMsg { return up }, true},
|
||||||
{"draft", func(c *Model, _ *config.Config) tview.KeyMsg { c.editState.SetValue("draft"); return up }, false},
|
{"draft", func(c *Model, _ *config.Config) tview.KeyMsg { c.setText("draft", len("draft")); return up }, false},
|
||||||
{"reply", func(c *Model, _ *config.Config) tview.KeyMsg {
|
{"reply", func(c *Model, _ *config.Config) tview.KeyMsg {
|
||||||
c.StartReply(discord.Message{ID: 3}, "name", false)
|
c.StartReply(discord.Message{ID: 3}, "name", false)
|
||||||
return up
|
return up
|
||||||
|
|
@ -79,8 +79,8 @@ func TestModelHandle(t *testing.T) {
|
||||||
t.Fatalf("got %v, want an editMsg", msg)
|
t.Fatalf("got %v, want an editMsg", msg)
|
||||||
}
|
}
|
||||||
*c, _ = c.Update(msg)
|
*c, _ = c.Update(msg)
|
||||||
if c.editState.Value() != "a" {
|
if c.text != "a" {
|
||||||
t.Fatalf("text = %q", c.editState.Value())
|
t.Fatalf("text = %q", c.text)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
t.Run("the newline keybind inserts a newline", func(t *testing.T) {
|
t.Run("the newline keybind inserts a newline", func(t *testing.T) {
|
||||||
|
|
@ -89,8 +89,8 @@ func TestModelHandle(t *testing.T) {
|
||||||
t.Fatalf("got %v, want an editMsg", msg)
|
t.Fatalf("got %v, want an editMsg", msg)
|
||||||
}
|
}
|
||||||
*c, _ = c.Update(msg)
|
*c, _ = c.Update(msg)
|
||||||
if c.editState.Value() != "a\n" {
|
if c.text != "a\n" {
|
||||||
t.Fatalf("text = %q", c.editState.Value())
|
t.Fatalf("text = %q", c.text)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
t.Run("bound keys are left to Update", func(t *testing.T) {
|
t.Run("bound keys are left to Update", func(t *testing.T) {
|
||||||
|
|
@ -139,7 +139,7 @@ func TestModelTabSuggest(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.SetChannel(&channel)
|
c.SetChannel(&channel)
|
||||||
c.editState.SetValue("@")
|
c.setText("@", 1)
|
||||||
c.tabSuggest()
|
c.tabSuggest()
|
||||||
if got := c.mentionsList.ItemCount(); got != 2 {
|
if got := c.mentionsList.ItemCount(); got != 2 {
|
||||||
t.Fatalf("suggested %d authors, want 2", got)
|
t.Fatalf("suggested %d authors, want 2", got)
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import (
|
||||||
"github.com/ayn2op/arikawa/v3/utils/json/option"
|
"github.com/ayn2op/arikawa/v3/utils/json/option"
|
||||||
"github.com/ayn2op/arikawa/v3/utils/sendpart"
|
"github.com/ayn2op/arikawa/v3/utils/sendpart"
|
||||||
"github.com/ayn2op/tview"
|
"github.com/ayn2op/tview"
|
||||||
"github.com/ayn2op/tview/textarea"
|
|
||||||
"github.com/ncruces/zenity"
|
"github.com/ncruces/zenity"
|
||||||
"github.com/sahilm/fuzzy"
|
"github.com/sahilm/fuzzy"
|
||||||
"golang.design/x/clipboard"
|
"golang.design/x/clipboard"
|
||||||
|
|
@ -33,8 +32,14 @@ type SentMsg struct{}
|
||||||
|
|
||||||
type editorMsg string
|
type editorMsg string
|
||||||
|
|
||||||
// editMsg edits the composer's text.
|
// edit is a text of the composer and the cursor in it.
|
||||||
type editMsg textarea.Change
|
type edit struct {
|
||||||
|
text string
|
||||||
|
cursor int
|
||||||
|
}
|
||||||
|
|
||||||
|
// editMsg is the text and cursor that typing in the composer left.
|
||||||
|
type editMsg edit
|
||||||
|
|
||||||
func (m Model) editLastMessage() tview.Cmd {
|
func (m Model) editLastMessage() tview.Cmd {
|
||||||
return func() tview.Msg { return EditLastMsg{} }
|
return func() tview.Msg { return EditLastMsg{} }
|
||||||
|
|
@ -127,7 +132,7 @@ func (m *Model) send() tview.Cmd {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
text := strings.TrimSpace(m.editState.Value())
|
text := strings.TrimSpace(m.text)
|
||||||
if text == "" && len(m.sendMessageData.Files) == 0 {
|
if text == "" && len(m.sendMessageData.Files) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -159,7 +164,7 @@ func (m *Model) send() tview.Cmd {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) tabComplete() tview.Cmd {
|
func (m *Model) tabComplete() tview.Cmd {
|
||||||
posEnd, name, r := wordBeforeCursor(m.editState.Value(), m.editState.Cursor(), isMentionChar)
|
posEnd, name, r := wordBeforeCursor(m.text, m.cursor, isMentionChar)
|
||||||
if r != '@' {
|
if r != '@' {
|
||||||
m.stopTabCompletion()
|
m.stopTabCompletion()
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -177,7 +182,7 @@ func (m *Model) tabComplete() tview.Cmd {
|
||||||
users := selectedChannel.DMRecipients
|
users := selectedChannel.DMRecipients
|
||||||
res := fuzzy.FindFrom(name, userList(users))
|
res := fuzzy.FindFrom(name, userList(users))
|
||||||
if len(res) > 0 {
|
if len(res) > 0 {
|
||||||
m.editState.Replace(pos, posEnd, "@"+users[res[0].Index].Username+" ")
|
m.replace(pos, posEnd, "@"+users[res[0].Index].Username+" ")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
cmd := m.searchMember(gID, name)
|
cmd := m.searchMember(gID, name)
|
||||||
|
|
@ -190,7 +195,7 @@ func (m *Model) tabComplete() tview.Cmd {
|
||||||
res := fuzzy.FindFrom(name, memberList(members))
|
res := fuzzy.FindFrom(name, memberList(members))
|
||||||
for _, r := range res {
|
for _, r := range res {
|
||||||
if channelHasUser(m.state, selectedChannel.ID, members[r.Index].User.ID) {
|
if channelHasUser(m.state, selectedChannel.ID, members[r.Index].User.ID) {
|
||||||
m.editState.Replace(pos, posEnd, "@"+members[r.Index].User.Username+" ")
|
m.replace(pos, posEnd, "@"+members[r.Index].User.Username+" ")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -205,13 +210,13 @@ func (m *Model) tabComplete() tview.Cmd {
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
m.editState.Replace(pos, posEnd, "@"+name+" ")
|
m.replace(pos, posEnd, "@"+name+" ")
|
||||||
m.stopTabCompletion()
|
m.stopTabCompletion()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) tabSuggest() tview.Cmd {
|
func (m *Model) tabSuggest() tview.Cmd {
|
||||||
_, name, r := wordBeforeCursor(m.editState.Value(), m.editState.Cursor(), isMentionChar)
|
_, name, r := wordBeforeCursor(m.text, m.cursor, isMentionChar)
|
||||||
if r != '@' {
|
if r != '@' {
|
||||||
m.stopTabCompletion()
|
m.stopTabCompletion()
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -313,7 +318,7 @@ func (m Model) openEditor() tview.Cmd {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
text := m.editState.Value()
|
text := m.text
|
||||||
cfg := m.cfg
|
cfg := m.cfg
|
||||||
return tview.Suspend(func() tview.Msg {
|
return tview.Suspend(func() tview.Msg {
|
||||||
file, err := os.CreateTemp("", tmpFilePattern)
|
file, err := os.CreateTemp("", tmpFilePattern)
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ func (m Model) selectCurrentKeybind() keybind.Keybind {
|
||||||
selectCurrent := m.cfg.Keybinds.GuildsTree.SelectCurrent.Keybind
|
selectCurrent := m.cfg.Keybinds.GuildsTree.SelectCurrent.Keybind
|
||||||
selectHelp := selectCurrent.Help()
|
selectHelp := selectCurrent.Help()
|
||||||
selectDesc := selectHelp.Desc
|
selectDesc := selectHelp.Desc
|
||||||
if node := m.selectionState.CurrentNode(); node != nil {
|
if node := m.current; node != nil {
|
||||||
if m.expandable(node) {
|
if m.expandable(node) {
|
||||||
if node.Expanded() {
|
if node.Expanded() {
|
||||||
selectDesc = "collapse"
|
selectDesc = "collapse"
|
||||||
|
|
@ -35,7 +35,7 @@ func (m Model) selectCurrentKeybind() keybind.Keybind {
|
||||||
|
|
||||||
// toggleVoiceKeybinds returns the keybind that joins or leaves the highlighted voice channel, if it is one.
|
// toggleVoiceKeybinds returns the keybind that joins or leaves the highlighted voice channel, if it is one.
|
||||||
func (m Model) toggleVoiceKeybinds() []keybind.Keybind {
|
func (m Model) toggleVoiceKeybinds() []keybind.Keybind {
|
||||||
channel := m.voiceChannel(m.selectionState.CurrentNode())
|
channel := m.voiceChannel(m.current)
|
||||||
if channel == nil {
|
if channel == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -50,9 +50,9 @@ func (m Model) toggleVoiceKeybinds() []keybind.Keybind {
|
||||||
|
|
||||||
func (m Model) ShortHelp() []keybind.Keybind {
|
func (m Model) ShortHelp() []keybind.Keybind {
|
||||||
cfg := m.cfg.Keybinds.GuildsTree
|
cfg := m.cfg.Keybinds.GuildsTree
|
||||||
shortHelp := append(uitree.SelectHelp(m.root, m.selectionState.CurrentNode(), cfg.TreeKeybinds), m.selectCurrentKeybind())
|
shortHelp := append(uitree.SelectHelp(m.root, m.current, cfg.TreeKeybinds), m.selectCurrentKeybind())
|
||||||
shortHelp = append(shortHelp, m.toggleVoiceKeybinds()...)
|
shortHelp = append(shortHelp, m.toggleVoiceKeybinds()...)
|
||||||
if m.canCollapseParent(m.selectionState.CurrentNode()) {
|
if m.canCollapseParent(m.current) {
|
||||||
shortHelp = append(shortHelp, cfg.CollapseParentNode.Keybind)
|
shortHelp = append(shortHelp, cfg.CollapseParentNode.Keybind)
|
||||||
}
|
}
|
||||||
return shortHelp
|
return shortHelp
|
||||||
|
|
@ -76,7 +76,7 @@ func (m Model) collapseKeybinds() []keybind.Keybind {
|
||||||
cfg := m.cfg.Keybinds.GuildsTree
|
cfg := m.cfg.Keybinds.GuildsTree
|
||||||
|
|
||||||
var keybinds []keybind.Keybind
|
var keybinds []keybind.Keybind
|
||||||
if m.canCollapseParent(m.selectionState.CurrentNode()) {
|
if m.canCollapseParent(m.current) {
|
||||||
keybinds = append(keybinds, cfg.CollapseParentNode.Keybind)
|
keybinds = append(keybinds, cfg.CollapseParentNode.Keybind)
|
||||||
}
|
}
|
||||||
if m.canCollapseAll() {
|
if m.canCollapseAll() {
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,9 @@ import (
|
||||||
type dmNode struct{}
|
type dmNode struct{}
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
root *tree.Node
|
root *tree.Node
|
||||||
selectionState tree.SelectionState
|
current *tree.Node
|
||||||
scrollState viewport.ScrollState
|
scrollState viewport.ScrollState
|
||||||
|
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
state *ningen.State
|
state *ningen.State
|
||||||
|
|
@ -53,16 +53,16 @@ func NewModel(cfg *config.Config, state *ningen.State) Model {
|
||||||
|
|
||||||
// CurrentNode returns the selected node, or nil for none.
|
// CurrentNode returns the selected node, or nil for none.
|
||||||
func (m Model) CurrentNode() *tree.Node {
|
func (m Model) CurrentNode() *tree.Node {
|
||||||
return m.selectionState.CurrentNode()
|
return m.current
|
||||||
}
|
}
|
||||||
|
|
||||||
// View shows the tree in a box titled Guilds.
|
// View shows the tree in a box titled Guilds.
|
||||||
func (m Model) View(focused bool) tview.Widget {
|
func (m Model) View(focused bool) tview.Widget {
|
||||||
onChange := func(c tree.Change) tview.Msg { return Msg(c) }
|
onChange := func(current *tree.Node) tview.Msg { return Msg{current} }
|
||||||
onSelect := func(n *tree.Node) tview.Msg { return SelectedMsg{Node: n} }
|
onSelect := func(n *tree.Node) tview.Msg { return SelectedMsg{Node: n} }
|
||||||
return uitree.New(
|
return uitree.New(
|
||||||
m.root,
|
m.root,
|
||||||
m.selectionState,
|
m.current,
|
||||||
m.scrollState,
|
m.scrollState,
|
||||||
m.cfg,
|
m.cfg,
|
||||||
m.cfg.Theme.GuildsTree.TreeThemeConfig,
|
m.cfg.Theme.GuildsTree.TreeThemeConfig,
|
||||||
|
|
@ -261,8 +261,8 @@ func (m *Model) showVoiceMembers(guildID discord.GuildID) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// The members are replaced, so what is selected cannot stay one of them.
|
// The members are replaced, so what is selected cannot stay one of them.
|
||||||
if slices.Contains(node.Children(), m.selectionState.CurrentNode()) {
|
if slices.Contains(node.Children(), m.current) {
|
||||||
m.selectionState.SetCurrentNode(node)
|
m.current = node
|
||||||
}
|
}
|
||||||
|
|
||||||
node.ClearChildren()
|
node.ClearChildren()
|
||||||
|
|
@ -501,7 +501,7 @@ func (m *Model) collapseParentNode(node *tree.Node) {
|
||||||
|
|
||||||
// setCurrentNode selects node and scrolls to it.
|
// setCurrentNode selects node and scrolls to it.
|
||||||
func (m *Model) setCurrentNode(node *tree.Node) {
|
func (m *Model) setCurrentNode(node *tree.Node) {
|
||||||
m.selectionState.SetCurrentNode(node)
|
m.current = node
|
||||||
m.scrollState.ScrollToTarget()
|
m.scrollState.ScrollToTarget()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -532,11 +532,11 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
||||||
case NavigateMsg:
|
case NavigateMsg:
|
||||||
return m, m.navigate(msg.ChannelID)
|
return m, m.navigate(msg.ChannelID)
|
||||||
case Msg:
|
case Msg:
|
||||||
m.selectionState.Apply(tree.Change(msg))
|
m.current = msg.current
|
||||||
m.scrollState.ScrollToTarget()
|
m.scrollState.ScrollToTarget()
|
||||||
return m, nil
|
return m, nil
|
||||||
case uitree.ScrollMsg:
|
case uitree.ScrollMsg:
|
||||||
m.scrollState.Apply(msg.Change)
|
m.scrollState = msg.ScrollState
|
||||||
if msg.Msg != nil {
|
if msg.Msg != nil {
|
||||||
return m.Update(msg.Msg)
|
return m.Update(msg.Msg)
|
||||||
}
|
}
|
||||||
|
|
@ -552,11 +552,11 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case keybind.Matches(msg, m.cfg.Keybinds.GuildsTree.CollapseParentNode.Keybind):
|
case keybind.Matches(msg, m.cfg.Keybinds.GuildsTree.CollapseParentNode.Keybind):
|
||||||
m.collapseParentNode(m.selectionState.CurrentNode())
|
m.collapseParentNode(m.current)
|
||||||
return m, nil
|
return m, nil
|
||||||
case keybind.Matches(msg, m.cfg.Keybinds.GuildsTree.SelectPreviousUnread.Keybind, m.cfg.Keybinds.GuildsTree.SelectNextUnread.Keybind):
|
case keybind.Matches(msg, m.cfg.Keybinds.GuildsTree.SelectPreviousUnread.Keybind, m.cfg.Keybinds.GuildsTree.SelectNextUnread.Keybind):
|
||||||
previous := keybind.Matches(msg, m.cfg.Keybinds.GuildsTree.SelectPreviousUnread.Keybind)
|
previous := keybind.Matches(msg, m.cfg.Keybinds.GuildsTree.SelectPreviousUnread.Keybind)
|
||||||
if node := adjacentNode(m.root, m.selectionState.CurrentNode(), m.isUnread, previous); node != nil {
|
if node := adjacentNode(m.root, m.current, m.isUnread, previous); node != nil {
|
||||||
// A guild or the direct messages without channels yet: load them and move on to the unread one.
|
// A guild or the direct messages without channels yet: load them and move on to the unread one.
|
||||||
if _, ok := node.Reference().(discord.ChannelID); !ok {
|
if _, ok := node.Reference().(discord.ChannelID); !ok {
|
||||||
m.selectNode(node)
|
m.selectNode(node)
|
||||||
|
|
@ -569,9 +569,9 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case keybind.Matches(msg, m.cfg.Keybinds.GuildsTree.YankID.Keybind):
|
case keybind.Matches(msg, m.cfg.Keybinds.GuildsTree.YankID.Keybind):
|
||||||
return m, uitree.YankID(m.selectionState.CurrentNode())
|
return m, uitree.YankID(m.current)
|
||||||
case keybind.Matches(msg, m.cfg.Keybinds.GuildsTree.ToggleVoice.Keybind):
|
case keybind.Matches(msg, m.cfg.Keybinds.GuildsTree.ToggleVoice.Keybind):
|
||||||
return m, m.toggleVoice(m.selectionState.CurrentNode())
|
return m, m.toggleVoice(m.current)
|
||||||
case keybind.Matches(msg, m.cfg.Keybinds.ToggleMute.Keybind):
|
case keybind.Matches(msg, m.cfg.Keybinds.ToggleMute.Keybind):
|
||||||
return m, m.toggleMute()
|
return m, m.toggleMute()
|
||||||
case keybind.Matches(msg, m.cfg.Keybinds.ToggleDeafen.Keybind):
|
case keybind.Matches(msg, m.cfg.Keybinds.ToggleDeafen.Keybind):
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,10 @@ import (
|
||||||
"github.com/ayn2op/tview/tree"
|
"github.com/ayn2op/tview/tree"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Msg moves the tree.
|
// Msg makes a node of the tree the current one.
|
||||||
type Msg tree.Change
|
type Msg struct {
|
||||||
|
current *tree.Node
|
||||||
|
}
|
||||||
|
|
||||||
// SelectedMsg is sent when the user selects a node.
|
// SelectedMsg is sent when the user selects a node.
|
||||||
type SelectedMsg struct {
|
type SelectedMsg struct {
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ var _ help.KeyMap = Model{}
|
||||||
|
|
||||||
func (m Model) ShortHelp() []keybind.Keybind {
|
func (m Model) ShortHelp() []keybind.Keybind {
|
||||||
kbs := m.cfg.Keybinds.MembersTree
|
kbs := m.cfg.Keybinds.MembersTree
|
||||||
short := uitree.SelectHelp(m.root, m.selectionState.CurrentNode(), kbs)
|
short := uitree.SelectHelp(m.root, m.current, kbs)
|
||||||
if node := m.selectionState.CurrentNode(); node != nil {
|
if node := m.current; node != nil {
|
||||||
if _, ok := node.Reference().(groupRef); ok {
|
if _, ok := node.Reference().(groupRef); ok {
|
||||||
desc := "expand"
|
desc := "expand"
|
||||||
if node.Expanded() {
|
if node.Expanded() {
|
||||||
|
|
@ -20,7 +20,7 @@ func (m Model) ShortHelp() []keybind.Keybind {
|
||||||
short = append(short, kbs.SelectCurrent.WithHelp(kbs.SelectCurrent.Help().Key, desc))
|
short = append(short, kbs.SelectCurrent.WithHelp(kbs.SelectCurrent.Help().Key, desc))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if m.parent(m.selectionState.CurrentNode()) != nil {
|
if m.parent(m.current) != nil {
|
||||||
short = append(short, kbs.CollapseParentNode.Keybind)
|
short = append(short, kbs.CollapseParentNode.Keybind)
|
||||||
}
|
}
|
||||||
return short
|
return short
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,9 @@ func (r memberRef) String() string {
|
||||||
|
|
||||||
// Model is a tree of the members of the selected channel, grouped as Discord groups them.
|
// Model is a tree of the members of the selected channel, grouped as Discord groups them.
|
||||||
type Model struct {
|
type Model struct {
|
||||||
root *tree.Node
|
root *tree.Node
|
||||||
selectionState tree.SelectionState
|
current *tree.Node
|
||||||
scrollState viewport.ScrollState
|
scrollState viewport.ScrollState
|
||||||
|
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
state *ningen.State
|
state *ningen.State
|
||||||
|
|
@ -85,14 +85,14 @@ func (m *Model) SetChannel(channel *discord.Channel) tview.Cmd {
|
||||||
|
|
||||||
// View shows the tree in a box titled Members.
|
// View shows the tree in a box titled Members.
|
||||||
func (m Model) View(focused bool) tview.Widget {
|
func (m Model) View(focused bool) tview.Widget {
|
||||||
onChange := func(c tree.Change) tview.Msg { return Msg(c) }
|
onChange := func(current *tree.Node) tview.Msg { return Msg{current} }
|
||||||
onSelect := func(n *tree.Node) tview.Msg { return SelectedMsg{Node: n} }
|
onSelect := func(n *tree.Node) tview.Msg { return SelectedMsg{Node: n} }
|
||||||
return uitree.New(m.root, m.selectionState, m.scrollState, m.cfg, m.cfg.Theme.MembersTree, m.cfg.UI.MembersTree.Graphics, m.cfg.Keybinds.MembersTree, focused, onChange, onSelect).Title("Members")
|
return uitree.New(m.root, m.current, m.scrollState, m.cfg, m.cfg.Theme.MembersTree, m.cfg.UI.MembersTree.Graphics, m.cfg.Keybinds.MembersTree, focused, onChange, onSelect).Title("Members")
|
||||||
}
|
}
|
||||||
|
|
||||||
// setCurrentNode selects node and scrolls to it.
|
// setCurrentNode selects node and scrolls to it.
|
||||||
func (m *Model) setCurrentNode(node *tree.Node) {
|
func (m *Model) setCurrentNode(node *tree.Node) {
|
||||||
m.selectionState.SetCurrentNode(node)
|
m.current = node
|
||||||
m.scrollState.ScrollToTarget()
|
m.scrollState.ScrollToTarget()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -113,11 +113,11 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case Msg:
|
case Msg:
|
||||||
m.selectionState.Apply(tree.Change(msg))
|
m.current = msg.current
|
||||||
m.scrollState.ScrollToTarget()
|
m.scrollState.ScrollToTarget()
|
||||||
return m, m.requestSelectedMembers()
|
return m, m.requestSelectedMembers()
|
||||||
case uitree.ScrollMsg:
|
case uitree.ScrollMsg:
|
||||||
m.scrollState.Apply(msg.Change)
|
m.scrollState = msg.ScrollState
|
||||||
if msg.Msg != nil {
|
if msg.Msg != nil {
|
||||||
return m.Update(msg.Msg)
|
return m.Update(msg.Msg)
|
||||||
}
|
}
|
||||||
|
|
@ -137,13 +137,13 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case keybind.Matches(msg, kbs.CollapseParentNode.Keybind):
|
case keybind.Matches(msg, kbs.CollapseParentNode.Keybind):
|
||||||
if parent := m.parent(m.selectionState.CurrentNode()); parent != nil {
|
if parent := m.parent(m.current); parent != nil {
|
||||||
parent.Collapse()
|
parent.Collapse()
|
||||||
m.setCurrentNode(parent)
|
m.setCurrentNode(parent)
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case keybind.Matches(msg, kbs.YankID.Keybind):
|
case keybind.Matches(msg, kbs.YankID.Keybind):
|
||||||
return m, uitree.YankID(m.selectionState.CurrentNode())
|
return m, uitree.YankID(m.current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
|
||||||
|
|
@ -133,11 +133,11 @@ func TestModelRebuild(t *testing.T) {
|
||||||
m.rebuild()
|
m.rebuild()
|
||||||
online, offline := m.root.Children()[0], m.root.Children()[1]
|
online, offline := m.root.Children()[0], m.root.Children()[1]
|
||||||
selected := online.Children()[0]
|
selected := online.Children()[0]
|
||||||
m.selectionState.SetCurrentNode(selected)
|
m.current = selected
|
||||||
offline.Collapse()
|
offline.Collapse()
|
||||||
|
|
||||||
m.rebuild()
|
m.rebuild()
|
||||||
if got := m.selectionState.CurrentNode(); got != selected || m.root.PathTo(got) == nil {
|
if got := m.current; got != selected || m.root.PathTo(got) == nil {
|
||||||
t.Fatal("the selected member was not kept in the tree")
|
t.Fatal("the selected member was not kept in the tree")
|
||||||
}
|
}
|
||||||
if m.root.Children()[1].Expanded() {
|
if m.root.Children()[1].Expanded() {
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,10 @@ import (
|
||||||
"github.com/ayn2op/tview/tree"
|
"github.com/ayn2op/tview/tree"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Msg moves the tree.
|
// Msg makes a node of the tree the current one.
|
||||||
type Msg tree.Change
|
type Msg struct {
|
||||||
|
current *tree.Node
|
||||||
|
}
|
||||||
|
|
||||||
// SelectedMsg is sent when the user selects a node.
|
// SelectedMsg is sent when the user selects a node.
|
||||||
type SelectedMsg struct {
|
type SelectedMsg struct {
|
||||||
|
|
@ -16,7 +18,7 @@ type SelectedMsg struct {
|
||||||
|
|
||||||
// requestSelectedMembers requests the chunk of the member list after the selected member, so that the members below it load before they are reached.
|
// requestSelectedMembers requests the chunk of the member list after the selected member, so that the members below it load before they are reached.
|
||||||
func (m Model) requestSelectedMembers() tview.Cmd {
|
func (m Model) requestSelectedMembers() tview.Cmd {
|
||||||
node := m.selectionState.CurrentNode()
|
node := m.current
|
||||||
if node == nil {
|
if node == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,14 +13,16 @@ import (
|
||||||
|
|
||||||
// Model shows the mention suggestions as a list in a box.
|
// Model shows the mention suggestions as a list in a box.
|
||||||
type Model struct {
|
type Model struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
selectionState list.SelectionState
|
|
||||||
scrollState viewport.ScrollState
|
items []Item
|
||||||
items []Item
|
// cursor is the selected item, or -1 for none.
|
||||||
|
cursor int
|
||||||
|
scrollState viewport.ScrollState
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewModel(cfg *config.Config) Model {
|
func NewModel(cfg *config.Config) Model {
|
||||||
return Model{cfg: cfg, selectionState: list.NewSelectionState()}
|
return Model{cfg: cfg, cursor: -1}
|
||||||
}
|
}
|
||||||
|
|
||||||
// View shows the list in a box whose bottom corners join the composer's border below it.
|
// View shows the list in a box whose bottom corners join the composer's border below it.
|
||||||
|
|
@ -39,7 +41,7 @@ func (m Model) View() tview.Widget {
|
||||||
func (Model) Init() tview.Cmd { return nil }
|
func (Model) Init() tview.Cmd { return nil }
|
||||||
|
|
||||||
func (m Model) listView() viewport.Widget {
|
func (m Model) listView() viewport.Widget {
|
||||||
l := list.New(m.selectionState, len(m.items), func(i int) tview.Widget {
|
l := list.New(m.cursor, len(m.items), func(i int) tview.Widget {
|
||||||
item := m.items[i]
|
item := m.items[i]
|
||||||
return row{text: item.DisplayText, style: item.Style}
|
return row{text: item.DisplayText, style: item.Style}
|
||||||
}).
|
}).
|
||||||
|
|
@ -47,20 +49,20 @@ func (m Model) listView() viewport.Widget {
|
||||||
Keybind(ui.SelectionKeybind(m.cfg.Keybinds.MentionsList.SelectionKeybinds)).
|
Keybind(ui.SelectionKeybind(m.cfg.Keybinds.MentionsList.SelectionKeybinds)).
|
||||||
// The list is only shown while mentions are being completed, so it takes its keys first.
|
// The list is only shown while mentions are being completed, so it takes its keys first.
|
||||||
Focused(true).
|
Focused(true).
|
||||||
OnChange(func(a list.Change) tview.Msg { return listMsg(a) })
|
OnChange(func(cursor int) tview.Msg { return listMsg(cursor) })
|
||||||
return viewport.New(l, m.scrollState).
|
return viewport.New(l, m.scrollState).
|
||||||
Target(l.Target).
|
Target(l.Target(m.cursor)).
|
||||||
ScrollBar(scrollbar.New(), viewport.ScrollBarVisibilityAutomatic).
|
ScrollBar(scrollbar.New(), viewport.ScrollBarVisibilityAutomatic).
|
||||||
OnChange(func(a viewport.Change) tview.Msg { return scrollMsg(a) })
|
OnChange(func(s *viewport.ScrollState) tview.Msg { return scrollMsg(*s) })
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case listMsg:
|
case listMsg:
|
||||||
m.selectionState.Apply(list.Change(msg))
|
m.cursor = int(msg)
|
||||||
m.scrollState.ScrollToTarget()
|
m.scrollState.ScrollToTarget()
|
||||||
case scrollMsg:
|
case scrollMsg:
|
||||||
m.scrollState.Apply(viewport.Change(msg))
|
m.scrollState = viewport.ScrollState(msg)
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
@ -71,7 +73,7 @@ func (m *Model) Append(item Item) {
|
||||||
|
|
||||||
func (m *Model) Clear() {
|
func (m *Model) Clear() {
|
||||||
m.items = nil
|
m.items = nil
|
||||||
m.selectionState.SetCursor(-1)
|
m.cursor = -1
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) ItemCount() int {
|
func (m Model) ItemCount() int {
|
||||||
|
|
@ -79,7 +81,7 @@ func (m Model) ItemCount() int {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) SelectedInsertText() (string, bool) {
|
func (m Model) SelectedInsertText() (string, bool) {
|
||||||
index := m.selectionState.Cursor()
|
index := m.cursor
|
||||||
if index < 0 || index >= len(m.items) {
|
if index < 0 || index >= len(m.items) {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
@ -95,7 +97,7 @@ func (m Model) maxDisplayWidth() int {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) Rebuild() {
|
func (m *Model) Rebuild() {
|
||||||
m.selectionState.SetCursor(min(0, len(m.items)-1))
|
m.cursor = min(0, len(m.items)-1)
|
||||||
m.scrollState.ScrollToTarget()
|
m.scrollState.ScrollToTarget()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
package mentionslist
|
package mentionslist
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/ayn2op/tview/list"
|
|
||||||
"github.com/ayn2op/tview/viewport"
|
"github.com/ayn2op/tview/viewport"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Msg is implemented by the messages that move or scroll the list.
|
// Msg is implemented by the messages that move or scroll the list.
|
||||||
type Msg interface{ mentionsList() }
|
type Msg interface{ mentionsList() }
|
||||||
|
|
||||||
type listMsg list.Change
|
// listMsg is the item to move the cursor to.
|
||||||
|
type listMsg int
|
||||||
|
|
||||||
func (listMsg) mentionsList() {}
|
func (listMsg) mentionsList() {}
|
||||||
|
|
||||||
type scrollMsg viewport.Change
|
type scrollMsg viewport.ScrollState
|
||||||
|
|
||||||
func (scrollMsg) mentionsList() {}
|
func (scrollMsg) mentionsList() {}
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,8 @@ type messageItem struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
selectionState list.SelectionState
|
// cursor is the selected item, or -1 for none, and target the item selected last, which stays in view once deselected.
|
||||||
|
cursor, target int
|
||||||
scrollState viewport.ScrollState
|
scrollState viewport.ScrollState
|
||||||
|
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
|
|
@ -83,12 +84,13 @@ type Model struct {
|
||||||
|
|
||||||
func NewModel(cfg *config.Config, state *ningen.State) Model {
|
func NewModel(cfg *config.Config, state *ningen.State) Model {
|
||||||
ml := Model{
|
ml := Model{
|
||||||
selectionState: list.NewSelectionState(),
|
cursor: -1,
|
||||||
cfg: cfg,
|
target: -1,
|
||||||
state: state,
|
cfg: cfg,
|
||||||
renderer: markdown.NewRenderer(cfg),
|
state: state,
|
||||||
avatars: map[discord.URL]tviewimage.Widget{},
|
renderer: markdown.NewRenderer(cfg),
|
||||||
voicePlayer: voice.NewPlayer(),
|
avatars: map[discord.URL]tviewimage.Widget{},
|
||||||
|
voicePlayer: voice.NewPlayer(),
|
||||||
}
|
}
|
||||||
|
|
||||||
ml.scrollState.ScrollToEnd()
|
ml.scrollState.ScrollToEnd()
|
||||||
|
|
@ -119,29 +121,27 @@ func (m Model) listView(focused bool) viewport.Widget {
|
||||||
Style(theme.TrackStyle.Style).
|
Style(theme.TrackStyle.Style).
|
||||||
ThumbStyle(theme.ThumbStyle.Style)
|
ThumbStyle(theme.ThumbStyle.Style)
|
||||||
// The model moves the selection itself, so the list is not focused and only takes clicks.
|
// The model moves the selection itself, so the list is not focused and only takes clicks.
|
||||||
l := list.New(m.selectionState, len(m.items), m.buildItem).
|
l := list.New(m.cursor, len(m.items), m.buildItem).
|
||||||
Gap(int(m.cfg.UI.MessagesList.Gap)).
|
Gap(int(m.cfg.UI.MessagesList.Gap)).
|
||||||
SelectedStyle(m.cfg.Theme.MessagesList.SelectedMessageStyle.Style).
|
SelectedStyle(m.cfg.Theme.MessagesList.SelectedMessageStyle.Style).
|
||||||
OnChange(func(a list.Change) tview.Msg { return listMsg(a) })
|
OnChange(func(cursor int) tview.Msg { return listMsg(cursor) })
|
||||||
return viewport.New(l, m.scrollState).
|
return viewport.New(l, m.scrollState).
|
||||||
Target(l.Target).
|
Target(l.Target(m.target)).
|
||||||
ScrollBar(bar, cfg.Visibility.ScrollBarVisibility).
|
ScrollBar(bar, cfg.Visibility.ScrollBarVisibility).
|
||||||
TrackEnd(true).
|
TrackEnd(true).
|
||||||
Keybind(ui.ScrollKeybind(m.cfg.Keybinds.MessagesList.ScrollKeybinds)).
|
Keybind(ui.ScrollKeybind(m.cfg.Keybinds.MessagesList.ScrollKeybinds)).
|
||||||
Focused(focused).
|
Focused(focused).
|
||||||
OnChange(func(a viewport.Change) tview.Msg { return scrollMsg(a) })
|
OnChange(func(s *viewport.ScrollState) tview.Msg { return scrollMsg(*s) })
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) cursor() int {
|
|
||||||
return m.selectionState.Cursor()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// setCursor selects the item at index and scrolls to it if it changed, or selects none if it is negative.
|
// setCursor selects the item at index and scrolls to it if it changed, or selects none if it is negative.
|
||||||
func (m *Model) setCursor(index int) {
|
func (m *Model) setCursor(index int) {
|
||||||
if index >= 0 && index != m.cursor() {
|
if index >= 0 && index != m.cursor {
|
||||||
m.scrollState.ScrollToTarget()
|
m.scrollState.ScrollToTarget()
|
||||||
|
m.target = index
|
||||||
}
|
}
|
||||||
m.selectionState.SetCursor(index)
|
|
||||||
|
m.cursor = max(index, -1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetChannel shows messages of channel, newest first as Discord sends them, scrolled to the newest, and requests the members who wrote them.
|
// SetChannel shows messages of channel, newest first as Discord sends them, scrolled to the newest, and requests the members who wrote them.
|
||||||
|
|
@ -190,7 +190,7 @@ func (m Model) indexOf(channelID discord.ChannelID, id discord.MessageID) int {
|
||||||
func (m *Model) reset() {
|
func (m *Model) reset() {
|
||||||
m.items = nil
|
m.items = nil
|
||||||
clear(m.avatars)
|
clear(m.avatars)
|
||||||
m.selectionState = list.NewSelectionState()
|
m.cursor, m.target = -1, -1
|
||||||
m.scrollState = viewport.ScrollState{}
|
m.scrollState = viewport.ScrollState{}
|
||||||
m.scrollState.ScrollToEnd()
|
m.scrollState.ScrollToEnd()
|
||||||
}
|
}
|
||||||
|
|
@ -226,7 +226,7 @@ func (m *Model) deleteMessage(index int) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cursor := m.cursor()
|
cursor := m.cursor
|
||||||
if cursor == index {
|
if cursor == index {
|
||||||
cursor = m.messageIndex(index, -1)
|
cursor = m.messageIndex(index, -1)
|
||||||
if cursor == -1 {
|
if cursor == -1 {
|
||||||
|
|
@ -359,7 +359,7 @@ func (m Model) renderMessage(message discord.Message, baseStyle tview.Style, wid
|
||||||
// rebuildItems replaces date separators while retaining message views and selection.
|
// rebuildItems replaces date separators while retaining message views and selection.
|
||||||
func (m *Model) rebuildItems() {
|
func (m *Model) rebuildItems() {
|
||||||
items := make([]messageItem, 0, len(m.items))
|
items := make([]messageItem, 0, len(m.items))
|
||||||
cursor, selected := m.cursor(), -1
|
cursor, selected := m.cursor, -1
|
||||||
var previous discord.Timestamp
|
var previous discord.Timestamp
|
||||||
for i, item := range m.items {
|
for i, item := range m.items {
|
||||||
if item.separator {
|
if item.separator {
|
||||||
|
|
@ -953,7 +953,7 @@ func (m Model) drawPinnedMessage(builder *richtext.Builder, message discord.Mess
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) selectedMessage() (*discord.Message, bool) {
|
func (m Model) selectedMessage() (*discord.Message, bool) {
|
||||||
cursor := m.cursor()
|
cursor := m.cursor
|
||||||
if cursor < 0 || cursor >= len(m.items) || m.items[cursor].separator {
|
if cursor < 0 || cursor >= len(m.items) || m.items[cursor].separator {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
@ -1014,7 +1014,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
||||||
if selectedChannel == nil || selectedChannel.ID != msg.ChannelID {
|
if selectedChannel == nil || selectedChannel.ID != msg.ChannelID {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
prevCursor := m.cursor()
|
prevCursor := m.cursor
|
||||||
|
|
||||||
older := make([]messageItem, len(msg.Older))
|
older := make([]messageItem, len(msg.Older))
|
||||||
for i, message := range msg.Older {
|
for i, message := range msg.Older {
|
||||||
|
|
@ -1092,11 +1092,11 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
||||||
m.kitty = msg.Name == "kitty" || msg.Name == "ghostty"
|
m.kitty = msg.Name == "kitty" || msg.Name == "ghostty"
|
||||||
}
|
}
|
||||||
case scrollMsg:
|
case scrollMsg:
|
||||||
m.scrollState.Apply(viewport.Change(msg))
|
m.scrollState = viewport.ScrollState(msg)
|
||||||
case listMsg:
|
case listMsg:
|
||||||
top := m.atTop()
|
top := m.atTop()
|
||||||
m.selectionState.Apply(list.Change(msg))
|
m.cursor, m.target = int(msg), int(msg)
|
||||||
m.onRowCursorChanged(m.cursor())
|
m.onRowCursorChanged(m.cursor)
|
||||||
if !top && m.atTop() {
|
if !top && m.atTop() {
|
||||||
return m, m.fetchOlderMessages()
|
return m, m.fetchOlderMessages()
|
||||||
}
|
}
|
||||||
|
|
@ -1106,13 +1106,13 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
||||||
|
|
||||||
// atTop reports whether the oldest message shown is selected.
|
// atTop reports whether the oldest message shown is selected.
|
||||||
func (m Model) atTop() bool {
|
func (m Model) atTop() bool {
|
||||||
return m.cursor() >= 0 && m.cursor() == m.messageIndex(-1, 1)
|
return m.cursor >= 0 && m.cursor == m.messageIndex(-1, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) selectDown() {
|
func (m *Model) selectDown() {
|
||||||
if m.cursor() == -1 {
|
if m.cursor == -1 {
|
||||||
m.selectBottom()
|
m.selectBottom()
|
||||||
} else if next := m.messageIndex(m.cursor(), 1); next >= 0 {
|
} else if next := m.messageIndex(m.cursor, 1); next >= 0 {
|
||||||
m.setCursor(next)
|
m.setCursor(next)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1131,7 +1131,7 @@ func (m *Model) selectBottom() {
|
||||||
|
|
||||||
func (m *Model) selectReply() {
|
func (m *Model) selectReply() {
|
||||||
messages := m.items
|
messages := m.items
|
||||||
cursor := m.cursor()
|
cursor := m.cursor
|
||||||
if cursor < 0 || cursor >= len(messages) {
|
if cursor < 0 || cursor >= len(messages) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ func TestModelBuildItem(t *testing.T) {
|
||||||
t.Fatal("older messages were not fetched once on reaching the top")
|
t.Fatal("older messages were not fetched once on reaching the top")
|
||||||
}
|
}
|
||||||
ml.selectBottom()
|
ml.selectBottom()
|
||||||
if _, cmd := ml.Update(listMsg{}); cmd == nil {
|
if _, cmd := ml.Update(listMsg(0)); cmd == nil {
|
||||||
t.Fatal("clicking the top message did not fetch older messages")
|
t.Fatal("clicking the top message did not fetch older messages")
|
||||||
}
|
}
|
||||||
ml.selectBottom()
|
ml.selectBottom()
|
||||||
|
|
@ -144,15 +144,15 @@ func TestModelRebuildItems(t *testing.T) {
|
||||||
*ml, _ = ml.Update(olderMessagesLoadedMsg{ChannelID: 1, Older: messages[2:]})
|
*ml, _ = ml.Update(olderMessagesLoadedMsg{ChannelID: 1, Older: messages[2:]})
|
||||||
assertItems(1, 2, 3)
|
assertItems(1, 2, 3)
|
||||||
assertSelected(2)
|
assertSelected(2)
|
||||||
ml.deleteMessage(ml.cursor())
|
ml.deleteMessage(ml.cursor)
|
||||||
assertItems(1, 3)
|
assertItems(1, 3)
|
||||||
assertSelected(1)
|
assertSelected(1)
|
||||||
ml.deleteMessage(ml.cursor())
|
ml.deleteMessage(ml.cursor)
|
||||||
assertItems(3)
|
assertItems(3)
|
||||||
assertSelected(3)
|
assertSelected(3)
|
||||||
ml.deleteMessage(ml.cursor())
|
ml.deleteMessage(ml.cursor)
|
||||||
assertItems()
|
assertItems()
|
||||||
if ml.cursor() != -1 {
|
if ml.cursor != -1 {
|
||||||
t.Fatal("empty list retained selection")
|
t.Fatal("empty list retained selection")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import (
|
||||||
"github.com/ayn2op/discordo/internal/consts"
|
"github.com/ayn2op/discordo/internal/consts"
|
||||||
"github.com/ayn2op/discordo/internal/ui"
|
"github.com/ayn2op/discordo/internal/ui"
|
||||||
"github.com/ayn2op/tview"
|
"github.com/ayn2op/tview"
|
||||||
"github.com/ayn2op/tview/list"
|
|
||||||
"github.com/ayn2op/tview/picker"
|
"github.com/ayn2op/tview/picker"
|
||||||
"github.com/ayn2op/tview/viewport"
|
"github.com/ayn2op/tview/viewport"
|
||||||
"github.com/ncruces/zenity"
|
"github.com/ncruces/zenity"
|
||||||
|
|
@ -60,14 +59,14 @@ type EditMsg discord.Message
|
||||||
// ShowAttachmentsMsg asks to pick one of the attachments and links of the selected message.
|
// ShowAttachmentsMsg asks to pick one of the attachments and links of the selected message.
|
||||||
type ShowAttachmentsMsg picker.Items
|
type ShowAttachmentsMsg picker.Items
|
||||||
|
|
||||||
// listMsg moves the messages list.
|
// listMsg is the item of the messages list to move the cursor to.
|
||||||
type listMsg list.Change
|
type listMsg int
|
||||||
|
|
||||||
// scrollMsg scrolls the messages list.
|
// scrollMsg scrolls the messages list.
|
||||||
type scrollMsg viewport.Change
|
type scrollMsg viewport.ScrollState
|
||||||
|
|
||||||
func (m *Model) selectUp() tview.Cmd {
|
func (m *Model) selectUp() tview.Cmd {
|
||||||
cursor := m.cursor()
|
cursor := m.cursor
|
||||||
if cursor == -1 {
|
if cursor == -1 {
|
||||||
m.selectBottom()
|
m.selectBottom()
|
||||||
} else if previous := m.messageIndex(cursor, -1); previous >= 0 {
|
} else if previous := m.messageIndex(cursor, -1); previous >= 0 {
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,11 @@ const (
|
||||||
|
|
||||||
// Model is a login and a password input above a button. Tab and the arrow keys move the focus, Enter in an input moves to the next one, and Enter on the button or clicking it logs in.
|
// Model is a login and a password input above a button. Tab and the arrow keys move the focus, Enter in an input moves to the next one, and Enter on the button or clicking it logs in.
|
||||||
type Model struct {
|
type Model struct {
|
||||||
login, password textinput.EditState
|
login, password string
|
||||||
focus focus
|
// loginCursor and passwordCursor are the byte offsets of the cursor in login and password.
|
||||||
|
loginCursor, passwordCursor int
|
||||||
|
|
||||||
|
focus focus
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ tview.Model[Model] = Model{}
|
var _ tview.Model[Model] = Model{}
|
||||||
|
|
@ -38,9 +41,9 @@ func (Model) Init() tview.Cmd { return nil }
|
||||||
func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case loginMsg:
|
case loginMsg:
|
||||||
m.login.Apply(textinput.Change(msg))
|
m.login, m.loginCursor = msg.text, msg.cursor
|
||||||
case passwordMsg:
|
case passwordMsg:
|
||||||
m.password.Apply(textinput.Change(msg))
|
m.password, m.passwordCursor = msg.text, msg.cursor
|
||||||
case focusMsg:
|
case focusMsg:
|
||||||
m.focus = focus(msg)
|
m.focus = focus(msg)
|
||||||
case tview.KeyMsg:
|
case tview.KeyMsg:
|
||||||
|
|
@ -51,25 +54,24 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
||||||
m.focus = (m.focus + focusCount - 1) % focusCount
|
m.focus = (m.focus + focusCount - 1) % focusCount
|
||||||
}
|
}
|
||||||
case submitMsg:
|
case submitMsg:
|
||||||
login, password := m.login.Value(), m.password.Value()
|
if m.login == "" || m.password == "" {
|
||||||
if login == "" || password == "" {
|
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
return m, submitLogin(login, password)
|
return m, submitLogin(m.login, m.password)
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) View() tview.Widget {
|
func (m Model) View() tview.Widget {
|
||||||
labelWidth := layout.Fixed(len("Password") + 1)
|
labelWidth := layout.Fixed(len("Password") + 1)
|
||||||
login := textinput.New(&m.login).
|
login := textinput.New(m.login, m.loginCursor).
|
||||||
Focused(m.focus == focusLogin).
|
Focused(m.focus == focusLogin).
|
||||||
OnChange(func(c textinput.Change) tview.Msg { return loginMsg(c) }).
|
OnInput(func(text string, cursor int) tview.Msg { return loginMsg{text, cursor} }).
|
||||||
OnSubmit(focusMsg(focusPassword))
|
OnSubmit(focusMsg(focusPassword))
|
||||||
password := textinput.New(&m.password).
|
password := textinput.New(m.password, m.passwordCursor).
|
||||||
Mask("*").
|
Mask("*").
|
||||||
Focused(m.focus == focusPassword).
|
Focused(m.focus == focusPassword).
|
||||||
OnChange(func(c textinput.Change) tview.Msg { return passwordMsg(c) }).
|
OnInput(func(text string, cursor int) tview.Msg { return passwordMsg{text, cursor} }).
|
||||||
OnSubmit(focusMsg(focusButton))
|
OnSubmit(focusMsg(focusButton))
|
||||||
submit := button.New().
|
submit := button.New().
|
||||||
Label("Login").
|
Label("Login").
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,8 @@ func TestModelUpdate(t *testing.T) {
|
||||||
send(&m, key(tcell.KeyEnter, ""))
|
send(&m, key(tcell.KeyEnter, ""))
|
||||||
send(&m, key(tcell.KeyRune, "b"))
|
send(&m, key(tcell.KeyRune, "b"))
|
||||||
t.Run("typing fills the focused input and Enter moves on", func(t *testing.T) {
|
t.Run("typing fills the focused input and Enter moves on", func(t *testing.T) {
|
||||||
if m.login.Value() != "a" || m.password.Value() != "b" {
|
if m.login != "a" || m.password != "b" {
|
||||||
t.Fatalf("values = %q, %q", m.login.Value(), m.password.Value())
|
t.Fatalf("values = %q, %q", m.login, m.password)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
t.Run("enter on the button submits", func(t *testing.T) {
|
t.Run("enter on the button submits", func(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,19 @@ import (
|
||||||
"github.com/ayn2op/arikawa/v3/utils/httputil"
|
"github.com/ayn2op/arikawa/v3/utils/httputil"
|
||||||
"github.com/ayn2op/discordo/internal/http"
|
"github.com/ayn2op/discordo/internal/http"
|
||||||
"github.com/ayn2op/tview"
|
"github.com/ayn2op/tview"
|
||||||
"github.com/ayn2op/tview/textinput"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type TokenMsg string
|
type TokenMsg string
|
||||||
|
|
||||||
|
// inputMsg is the text and cursor that typing in an input left.
|
||||||
|
type inputMsg struct {
|
||||||
|
text string
|
||||||
|
cursor int
|
||||||
|
}
|
||||||
|
|
||||||
type (
|
type (
|
||||||
loginMsg textinput.Change
|
loginMsg inputMsg
|
||||||
passwordMsg textinput.Change
|
passwordMsg inputMsg
|
||||||
focusMsg focus
|
focusMsg focus
|
||||||
submitMsg struct{}
|
submitMsg struct{}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case scrollMsg:
|
case scrollMsg:
|
||||||
m.scrollState.Apply(viewport.Change(msg))
|
m.scrollState = viewport.ScrollState(msg)
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case connCreateMsg:
|
case connCreateMsg:
|
||||||
|
|
@ -135,7 +135,7 @@ func (m Model) View() tview.Widget {
|
||||||
viewport.New(textview.New(code).Wrap(false).Alignment(tview.AlignmentCenter), m.scrollState).
|
viewport.New(textview.New(code).Wrap(false).Alignment(tview.AlignmentCenter), m.scrollState).
|
||||||
Height(layout.Shrink).
|
Height(layout.Shrink).
|
||||||
Focused(true).
|
Focused(true).
|
||||||
OnChange(func(a viewport.Change) tview.Msg { return scrollMsg(a) }),
|
OnChange(func(s *viewport.ScrollState) tview.Msg { return scrollMsg(*s) }),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -302,7 +302,7 @@ func exchangeTicket(fingerprint string, privateKey *rsa.PrivateKey, ticket strin
|
||||||
}
|
}
|
||||||
|
|
||||||
// scrollMsg scrolls the code.
|
// scrollMsg scrolls the code.
|
||||||
type scrollMsg viewport.Change
|
type scrollMsg viewport.ScrollState
|
||||||
|
|
||||||
// startMsg starts connecting to the Remote Auth Gateway.
|
// startMsg starts connecting to the Remote Auth Gateway.
|
||||||
type startMsg struct{}
|
type startMsg struct{}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,10 @@ import (
|
||||||
|
|
||||||
// Model is a token input above a button. Tab and the arrow keys move the focus, Enter in the input moves to the button, and Enter on the button or clicking it logs in.
|
// Model is a token input above a button. Tab and the arrow keys move the focus, Enter in the input moves to the button, and Enter on the button or clicking it logs in.
|
||||||
type Model struct {
|
type Model struct {
|
||||||
token textinput.EditState
|
token string
|
||||||
|
// cursor is the byte offset of the cursor in token.
|
||||||
|
cursor int
|
||||||
|
|
||||||
buttonFocused bool
|
buttonFocused bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -28,7 +31,7 @@ func (Model) Init() tview.Cmd { return nil }
|
||||||
func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tokenMsg:
|
case tokenMsg:
|
||||||
m.token.Apply(textinput.Change(msg))
|
m.token, m.cursor = msg.text, msg.cursor
|
||||||
case focusButtonMsg:
|
case focusButtonMsg:
|
||||||
m.buttonFocused = true
|
m.buttonFocused = true
|
||||||
case tview.KeyMsg:
|
case tview.KeyMsg:
|
||||||
|
|
@ -37,20 +40,19 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
||||||
m.buttonFocused = !m.buttonFocused
|
m.buttonFocused = !m.buttonFocused
|
||||||
}
|
}
|
||||||
case submitMsg:
|
case submitMsg:
|
||||||
token := m.token.Value()
|
if m.token == "" {
|
||||||
if token == "" {
|
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
return m, submitToken(token)
|
return m, submitToken(m.token)
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) View() tview.Widget {
|
func (m Model) View() tview.Widget {
|
||||||
input := textinput.New(&m.token).
|
input := textinput.New(m.token, m.cursor).
|
||||||
Mask("*").
|
Mask("*").
|
||||||
Focused(!m.buttonFocused).
|
Focused(!m.buttonFocused).
|
||||||
OnChange(func(c textinput.Change) tview.Msg { return tokenMsg(c) }).
|
OnInput(func(text string, cursor int) tview.Msg { return tokenMsg{text, cursor} }).
|
||||||
OnSubmit(focusButtonMsg{})
|
OnSubmit(focusButtonMsg{})
|
||||||
submit := button.New().
|
submit := button.New().
|
||||||
Label("Login").
|
Label("Login").
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,17 @@ package token
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/ayn2op/tview"
|
"github.com/ayn2op/tview"
|
||||||
"github.com/ayn2op/tview/textinput"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type TokenMsg string
|
type TokenMsg string
|
||||||
|
|
||||||
|
// tokenMsg is the token and cursor that typing in the input left.
|
||||||
|
type tokenMsg struct {
|
||||||
|
text string
|
||||||
|
cursor int
|
||||||
|
}
|
||||||
|
|
||||||
type (
|
type (
|
||||||
tokenMsg textinput.Change
|
|
||||||
focusButtonMsg struct{}
|
focusButtonMsg struct{}
|
||||||
submitMsg struct{}
|
submitMsg struct{}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,5 @@ package picker
|
||||||
|
|
||||||
import "github.com/ayn2op/tview/picker"
|
import "github.com/ayn2op/tview/picker"
|
||||||
|
|
||||||
// changeMsg changes the picker.
|
// changeMsg is the search state that typing, moving the selection or scrolling left the picker in.
|
||||||
type changeMsg picker.Change
|
type changeMsg picker.SearchState
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ func (m Model) View(onSelect func(picker.Item) tview.Msg, onCancel tview.Msg) tv
|
||||||
ScrollBar(bar, cfg.UI.ScrollBar.Visibility.ScrollBarVisibility).
|
ScrollBar(bar, cfg.UI.ScrollBar.Visibility.ScrollBarVisibility).
|
||||||
Keybind(bindKeys(cfg.Keybinds.Picker)).
|
Keybind(bindKeys(cfg.Keybinds.Picker)).
|
||||||
ListKeybind(ui.SelectionKeybind(cfg.Keybinds.Picker.SelectionKeybinds)).
|
ListKeybind(ui.SelectionKeybind(cfg.Keybinds.Picker.SelectionKeybinds)).
|
||||||
OnChange(func(c picker.Change) tview.Msg { return changeMsg(c) }).
|
OnChange(func(s *picker.SearchState) tview.Msg { return changeMsg(*s) }).
|
||||||
OnSelect(onSelect).
|
OnSelect(onSelect).
|
||||||
OnCancel(onCancel)
|
OnCancel(onCancel)
|
||||||
return ui.Box(p, cfg, true).Title(m.title)
|
return ui.Box(p, cfg, true).Title(m.title)
|
||||||
|
|
@ -43,7 +43,7 @@ func (m Model) View(onSelect func(picker.Item) tview.Msg, onCancel tview.Msg) tv
|
||||||
|
|
||||||
func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
||||||
if msg, ok := msg.(changeMsg); ok {
|
if msg, ok := msg.(changeMsg); ok {
|
||||||
m.searchState.Apply(picker.Change(msg))
|
m.searchState = picker.SearchState(msg)
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,19 +13,19 @@ import (
|
||||||
"github.com/gdamore/tcell/v3"
|
"github.com/gdamore/tcell/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ScrollMsg scrolls the tree, before passing on Msg if the tree sent one.
|
// ScrollMsg scrolls the tree to ScrollState, before passing on Msg if the tree sent one.
|
||||||
type ScrollMsg struct {
|
type ScrollMsg struct {
|
||||||
Change viewport.Change
|
ScrollState viewport.ScrollState
|
||||||
Msg tview.Msg
|
Msg tview.Msg
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns a boxed tree of the nodes under root in a viewport.
|
// New returns a boxed tree of the nodes under root in a viewport.
|
||||||
func New(root *tree.Node, selectionState tree.SelectionState, scrollState viewport.ScrollState, cfg *config.Config, theme config.TreeThemeConfig, graphics bool, kbs config.TreeKeybinds, focused bool, onChange func(tree.Change) tview.Msg, onSelect func(*tree.Node) tview.Msg) box.Widget {
|
func New(root, current *tree.Node, scrollState viewport.ScrollState, cfg *config.Config, theme config.TreeThemeConfig, graphics bool, kbs config.TreeKeybinds, focused bool, onChange, onSelect func(*tree.Node) tview.Msg) box.Widget {
|
||||||
set := cfg.UI.Border.NormalSet.BorderSet
|
set := cfg.UI.Border.NormalSet.BorderSet
|
||||||
if focused {
|
if focused {
|
||||||
set = cfg.UI.Border.ActiveSet.BorderSet
|
set = cfg.UI.Border.ActiveSet.BorderSet
|
||||||
}
|
}
|
||||||
t := tree.New(root, selectionState).
|
t := tree.New(root, current).
|
||||||
TopLevel(1).
|
TopLevel(1).
|
||||||
Markers(tree.Markers{
|
Markers(tree.Markers{
|
||||||
Expanded: cfg.Sidebar.Markers.Expanded,
|
Expanded: cfg.Sidebar.Markers.Expanded,
|
||||||
|
|
@ -42,11 +42,11 @@ func New(root *tree.Node, selectionState tree.SelectionState, scrollState viewpo
|
||||||
v := viewport.New(t, scrollState).
|
v := viewport.New(t, scrollState).
|
||||||
Target(t.Target).
|
Target(t.Target).
|
||||||
ContentWidth(t.RowsWidth).
|
ContentWidth(t.RowsWidth).
|
||||||
OnChildMsg(func(c viewport.Change, msg tview.Msg) tview.Msg { return ScrollMsg{Change: c, Msg: msg} }).
|
OnChildMsg(func(s *viewport.ScrollState, msg tview.Msg) tview.Msg { return ScrollMsg{ScrollState: *s, Msg: msg} }).
|
||||||
Axes(layout.Axes{Width: true, Height: true}).
|
Axes(layout.Axes{Width: true, Height: true}).
|
||||||
Keybind(bindScrollKeys(kbs)).
|
Keybind(bindScrollKeys(kbs)).
|
||||||
Focused(focused).
|
Focused(focused).
|
||||||
OnChange(func(c viewport.Change) tview.Msg { return ScrollMsg{Change: c} })
|
OnChange(func(s *viewport.ScrollState) tview.Msg { return ScrollMsg{ScrollState: *s} })
|
||||||
return ui.Box(v, cfg, focused)
|
return ui.Box(v, cfg, focused)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue