diff --git a/go.mod b/go.mod index 55d34d7..322c97f 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/andybalholm/brotli v1.2.6 github.com/ayn2op/arikawa/v3 v3.0.0-20261012034659-5b006907b2de github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e - github.com/ayn2op/tview v0.0.0-20261013221052-78efeee1ac7c + github.com/ayn2op/tview v0.0.0-20261013225942-7c4cbe85112a github.com/bogdanfinn/fhttp v0.6.9 github.com/bogdanfinn/tls-client v1.16.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/ebitengine/purego v0.11.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/josephspurrier/goversioninfo v1.7.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.1 // indirect @@ -65,7 +65,7 @@ require ( go4.org v0.0.0-20260112195520-a5071408f32f // indirect golang.design/x/x11 v0.2.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/net v0.61.0 // indirect golang.org/x/sys v0.49.0 // indirect diff --git a/go.sum b/go.sum index 8c6032b..6972d69 100644 --- a/go.sum +++ b/go.sum @@ -14,8 +14,8 @@ github.com/ayn2op/arikawa/v3 v3.0.0-20261012034659-5b006907b2de h1:bO825TKutshlG github.com/ayn2op/arikawa/v3 v3.0.0-20261012034659-5b006907b2de/go.mod h1:YARcqZmk7hDjXB+lexlXX4wDFHULafbGnF0gsD0tuNw= github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e h1:QAei/IVEyZV7GcpkpcFUh37+QRqwrt57z7OT3ciHrhI= github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e/go.mod h1:RsvYiMzi4Si1F7vmml5NCL2DaKIZXkNUQC7QlGTMtCw= -github.com/ayn2op/tview v0.0.0-20261013221052-78efeee1ac7c h1:WgEp8OeS15d15kjPVPOPpnSsaKYJZYjTIKxBbr8b5kI= -github.com/ayn2op/tview v0.0.0-20261013221052-78efeee1ac7c/go.mod h1:3aWH6S5uOqOkZv7JFePEVHukTj9hPb6Lbynm59Sks2E= +github.com/ayn2op/tview v0.0.0-20261013225942-7c4cbe85112a h1:b92yhUuO4jsY8eTqTEEJO+SjpkIIngSzhnJA//FEw6M= +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/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM= 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/gen2brain/malgo v0.11.26 h1:k5WcPIKw1bbJAbPqrvNPt7nehPLoaPNcOFde2+eruiM= 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.3/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/godbus/dbus/v5 v5.2.4 h1:F2gUCy++NMIxGmo9J+z5le/+ZC8W1OgeR51P4i9ZB/U= +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/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= 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.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/exp/shiny v0.0.0-20261011100332-faecfb24eb49 h1:APVj9ArMe2rwsS6jJid0LkBkcs+JVYu5TK6DRwJ+dpk= -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 h1:TOeg6q45IKFyKG+5BOYsLtYmleQjJWDlm5CIM0zB/uA= +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/go.mod h1:IkN5o4A7vq55vXN8FWjjnhg9Rs2GOtffqsSi9mWAKYE= golang.org/x/mobile v0.0.0-20261011034528-197770444867 h1:rav73cdd7OQUPgt1aIFbZQTiPNnjfueKXvfOiLCY0Ss= diff --git a/internal/ui/chat/composer/model.go b/internal/ui/chat/composer/model.go index 65714a4..1870073 100644 --- a/internal/ui/chat/composer/model.go +++ b/internal/ui/chat/composer/model.go @@ -51,8 +51,12 @@ const ( var mentionRegex = regexp.MustCompile("@[a-zA-Z0-9._]+") type Model struct { - editState textarea.EditState - disabled bool + text string + // 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 // 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. func (m *Model) box(focused bool) box.Widget { - text := textarea.New(&m.editState). + text := textarea.New(m.text, m.cursor). Placeholder(m.placeholder()). Keybind(m.editAction). 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)) 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() } +// 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. 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. @@ -200,7 +229,7 @@ func (m *Model) pasteText() { func (m *Model) reset() { m.editing = nil 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. @@ -224,10 +253,10 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { } return m, nil case editorMsg: - m.editState.SetValue(string(msg)) + m.setText(string(msg), len(msg)) return m, nil case editMsg: - m.editState.Apply(textarea.Change(msg)) + m.setText(msg.text, msg.cursor) typingCmd := m.sendTyping() if m.cfg.AutocompleteLimit > 0 { return m, tview.Batch(typingCmd, m.tabSuggest()) @@ -269,7 +298,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { m.toggleReplyMention() return m, nil case keybind.Matches(msg, m.cfg.Keybinds.Composer.Undo.Keybind): - m.editState.Undo() + m.undo() return m, nil } case mentionslist.Msg: @@ -281,7 +310,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { } 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.mentionsListVisible() } @@ -289,7 +318,7 @@ func (m Model) canEditLastMessage() bool { // StartEdit puts message in the composer to be edited. func (m *Model) StartEdit(message discord.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. @@ -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 { c := p.c 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]) 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} diff --git a/internal/ui/chat/composer/model_test.go b/internal/ui/chat/composer/model_test.go index d130181..f9ed06d 100644 --- a/internal/ui/chat/composer/model_test.go +++ b/internal/ui/chat/composer/model_test.go @@ -36,7 +36,7 @@ func TestModelUpdate(t *testing.T) { want bool }{ {"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 { c.StartReply(discord.Message{ID: 3}, "name", false) return up @@ -79,8 +79,8 @@ func TestModelHandle(t *testing.T) { t.Fatalf("got %v, want an editMsg", msg) } *c, _ = c.Update(msg) - if c.editState.Value() != "a" { - t.Fatalf("text = %q", c.editState.Value()) + if c.text != "a" { + t.Fatalf("text = %q", c.text) } }) 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) } *c, _ = c.Update(msg) - if c.editState.Value() != "a\n" { - t.Fatalf("text = %q", c.editState.Value()) + if c.text != "a\n" { + t.Fatalf("text = %q", c.text) } }) 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.editState.SetValue("@") + c.setText("@", 1) c.tabSuggest() if got := c.mentionsList.ItemCount(); got != 2 { t.Fatalf("suggested %d authors, want 2", got) diff --git a/internal/ui/chat/composer/msg.go b/internal/ui/chat/composer/msg.go index 8c395f4..265442f 100644 --- a/internal/ui/chat/composer/msg.go +++ b/internal/ui/chat/composer/msg.go @@ -16,7 +16,6 @@ import ( "github.com/ayn2op/arikawa/v3/utils/json/option" "github.com/ayn2op/arikawa/v3/utils/sendpart" "github.com/ayn2op/tview" - "github.com/ayn2op/tview/textarea" "github.com/ncruces/zenity" "github.com/sahilm/fuzzy" "golang.design/x/clipboard" @@ -33,8 +32,14 @@ type SentMsg struct{} type editorMsg string -// editMsg edits the composer's text. -type editMsg textarea.Change +// edit is a text of the composer and the cursor in it. +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 { return func() tview.Msg { return EditLastMsg{} } @@ -127,7 +132,7 @@ func (m *Model) send() tview.Cmd { return nil } - text := strings.TrimSpace(m.editState.Value()) + text := strings.TrimSpace(m.text) if text == "" && len(m.sendMessageData.Files) == 0 { return nil } @@ -159,7 +164,7 @@ func (m *Model) send() 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 != '@' { m.stopTabCompletion() return nil @@ -177,7 +182,7 @@ func (m *Model) tabComplete() tview.Cmd { users := selectedChannel.DMRecipients res := fuzzy.FindFrom(name, userList(users)) if len(res) > 0 { - m.editState.Replace(pos, posEnd, "@"+users[res[0].Index].Username+" ") + m.replace(pos, posEnd, "@"+users[res[0].Index].Username+" ") } } else { cmd := m.searchMember(gID, name) @@ -190,7 +195,7 @@ func (m *Model) tabComplete() tview.Cmd { res := fuzzy.FindFrom(name, memberList(members)) for _, r := range res { 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 } } @@ -205,13 +210,13 @@ func (m *Model) tabComplete() tview.Cmd { if !ok { return nil } - m.editState.Replace(pos, posEnd, "@"+name+" ") + m.replace(pos, posEnd, "@"+name+" ") m.stopTabCompletion() return nil } 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 != '@' { m.stopTabCompletion() return nil @@ -313,7 +318,7 @@ func (m Model) openEditor() tview.Cmd { return nil } } - text := m.editState.Value() + text := m.text cfg := m.cfg return tview.Suspend(func() tview.Msg { file, err := os.CreateTemp("", tmpFilePattern) diff --git a/internal/ui/chat/guildstree/keymap.go b/internal/ui/chat/guildstree/keymap.go index fb02988..762f9cd 100644 --- a/internal/ui/chat/guildstree/keymap.go +++ b/internal/ui/chat/guildstree/keymap.go @@ -16,7 +16,7 @@ func (m Model) selectCurrentKeybind() keybind.Keybind { selectCurrent := m.cfg.Keybinds.GuildsTree.SelectCurrent.Keybind selectHelp := selectCurrent.Help() selectDesc := selectHelp.Desc - if node := m.selectionState.CurrentNode(); node != nil { + if node := m.current; node != nil { if m.expandable(node) { if node.Expanded() { 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. func (m Model) toggleVoiceKeybinds() []keybind.Keybind { - channel := m.voiceChannel(m.selectionState.CurrentNode()) + channel := m.voiceChannel(m.current) if channel == nil { return nil } @@ -50,9 +50,9 @@ func (m Model) toggleVoiceKeybinds() []keybind.Keybind { func (m Model) ShortHelp() []keybind.Keybind { 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()...) - if m.canCollapseParent(m.selectionState.CurrentNode()) { + if m.canCollapseParent(m.current) { shortHelp = append(shortHelp, cfg.CollapseParentNode.Keybind) } return shortHelp @@ -76,7 +76,7 @@ func (m Model) collapseKeybinds() []keybind.Keybind { cfg := m.cfg.Keybinds.GuildsTree var keybinds []keybind.Keybind - if m.canCollapseParent(m.selectionState.CurrentNode()) { + if m.canCollapseParent(m.current) { keybinds = append(keybinds, cfg.CollapseParentNode.Keybind) } if m.canCollapseAll() { diff --git a/internal/ui/chat/guildstree/model.go b/internal/ui/chat/guildstree/model.go index 7d02ae9..1d94f9f 100644 --- a/internal/ui/chat/guildstree/model.go +++ b/internal/ui/chat/guildstree/model.go @@ -27,9 +27,9 @@ import ( type dmNode struct{} type Model struct { - root *tree.Node - selectionState tree.SelectionState - scrollState viewport.ScrollState + root *tree.Node + current *tree.Node + scrollState viewport.ScrollState cfg *config.Config 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. func (m Model) CurrentNode() *tree.Node { - return m.selectionState.CurrentNode() + return m.current } // View shows the tree in a box titled Guilds. 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} } return uitree.New( m.root, - m.selectionState, + m.current, m.scrollState, m.cfg, 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. - if slices.Contains(node.Children(), m.selectionState.CurrentNode()) { - m.selectionState.SetCurrentNode(node) + if slices.Contains(node.Children(), m.current) { + m.current = node } node.ClearChildren() @@ -501,7 +501,7 @@ func (m *Model) collapseParentNode(node *tree.Node) { // setCurrentNode selects node and scrolls to it. func (m *Model) setCurrentNode(node *tree.Node) { - m.selectionState.SetCurrentNode(node) + m.current = node m.scrollState.ScrollToTarget() } @@ -532,11 +532,11 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { case NavigateMsg: return m, m.navigate(msg.ChannelID) case Msg: - m.selectionState.Apply(tree.Change(msg)) + m.current = msg.current m.scrollState.ScrollToTarget() return m, nil case uitree.ScrollMsg: - m.scrollState.Apply(msg.Change) + m.scrollState = msg.ScrollState if msg.Msg != nil { return m.Update(msg.Msg) } @@ -552,11 +552,11 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { } return m, nil case keybind.Matches(msg, m.cfg.Keybinds.GuildsTree.CollapseParentNode.Keybind): - m.collapseParentNode(m.selectionState.CurrentNode()) + m.collapseParentNode(m.current) return m, nil 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) - 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. if _, ok := node.Reference().(discord.ChannelID); !ok { m.selectNode(node) @@ -569,9 +569,9 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { } return m, nil 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): - return m, m.toggleVoice(m.selectionState.CurrentNode()) + return m, m.toggleVoice(m.current) case keybind.Matches(msg, m.cfg.Keybinds.ToggleMute.Keybind): return m, m.toggleMute() case keybind.Matches(msg, m.cfg.Keybinds.ToggleDeafen.Keybind): diff --git a/internal/ui/chat/guildstree/msg.go b/internal/ui/chat/guildstree/msg.go index 5ed1cd7..917c3fd 100644 --- a/internal/ui/chat/guildstree/msg.go +++ b/internal/ui/chat/guildstree/msg.go @@ -10,8 +10,10 @@ import ( "github.com/ayn2op/tview/tree" ) -// Msg moves the tree. -type Msg tree.Change +// Msg makes a node of the tree the current one. +type Msg struct { + current *tree.Node +} // SelectedMsg is sent when the user selects a node. type SelectedMsg struct { diff --git a/internal/ui/chat/memberstree/keymap.go b/internal/ui/chat/memberstree/keymap.go index 738cf45..05fab4b 100644 --- a/internal/ui/chat/memberstree/keymap.go +++ b/internal/ui/chat/memberstree/keymap.go @@ -10,8 +10,8 @@ var _ help.KeyMap = Model{} func (m Model) ShortHelp() []keybind.Keybind { kbs := m.cfg.Keybinds.MembersTree - short := uitree.SelectHelp(m.root, m.selectionState.CurrentNode(), kbs) - if node := m.selectionState.CurrentNode(); node != nil { + short := uitree.SelectHelp(m.root, m.current, kbs) + if node := m.current; node != nil { if _, ok := node.Reference().(groupRef); ok { desc := "expand" if node.Expanded() { @@ -20,7 +20,7 @@ func (m Model) ShortHelp() []keybind.Keybind { 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) } return short diff --git a/internal/ui/chat/memberstree/model.go b/internal/ui/chat/memberstree/model.go index f7b6934..018fa1f 100644 --- a/internal/ui/chat/memberstree/model.go +++ b/internal/ui/chat/memberstree/model.go @@ -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. type Model struct { - root *tree.Node - selectionState tree.SelectionState - scrollState viewport.ScrollState + root *tree.Node + current *tree.Node + scrollState viewport.ScrollState cfg *config.Config 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. 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} } - 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. func (m *Model) setCurrentNode(node *tree.Node) { - m.selectionState.SetCurrentNode(node) + m.current = node m.scrollState.ScrollToTarget() } @@ -113,11 +113,11 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { return m, nil case Msg: - m.selectionState.Apply(tree.Change(msg)) + m.current = msg.current m.scrollState.ScrollToTarget() return m, m.requestSelectedMembers() case uitree.ScrollMsg: - m.scrollState.Apply(msg.Change) + m.scrollState = msg.ScrollState if msg.Msg != nil { return m.Update(msg.Msg) } @@ -137,13 +137,13 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { } return m, nil 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() m.setCurrentNode(parent) } return m, nil case keybind.Matches(msg, kbs.YankID.Keybind): - return m, uitree.YankID(m.selectionState.CurrentNode()) + return m, uitree.YankID(m.current) } } return m, nil diff --git a/internal/ui/chat/memberstree/model_test.go b/internal/ui/chat/memberstree/model_test.go index 4b0b616..90a24ac 100644 --- a/internal/ui/chat/memberstree/model_test.go +++ b/internal/ui/chat/memberstree/model_test.go @@ -133,11 +133,11 @@ func TestModelRebuild(t *testing.T) { m.rebuild() online, offline := m.root.Children()[0], m.root.Children()[1] selected := online.Children()[0] - m.selectionState.SetCurrentNode(selected) + m.current = selected offline.Collapse() 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") } if m.root.Children()[1].Expanded() { diff --git a/internal/ui/chat/memberstree/msg.go b/internal/ui/chat/memberstree/msg.go index 948ad38..81a08c8 100644 --- a/internal/ui/chat/memberstree/msg.go +++ b/internal/ui/chat/memberstree/msg.go @@ -6,8 +6,10 @@ import ( "github.com/ayn2op/tview/tree" ) -// Msg moves the tree. -type Msg tree.Change +// Msg makes a node of the tree the current one. +type Msg struct { + current *tree.Node +} // SelectedMsg is sent when the user selects a node. 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. func (m Model) requestSelectedMembers() tview.Cmd { - node := m.selectionState.CurrentNode() + node := m.current if node == nil { return nil } diff --git a/internal/ui/chat/mentionslist/model.go b/internal/ui/chat/mentionslist/model.go index 0e4edba..59d09cd 100644 --- a/internal/ui/chat/mentionslist/model.go +++ b/internal/ui/chat/mentionslist/model.go @@ -13,14 +13,16 @@ import ( // Model shows the mention suggestions as a list in a box. type Model struct { - cfg *config.Config - selectionState list.SelectionState - scrollState viewport.ScrollState - items []Item + cfg *config.Config + + items []Item + // cursor is the selected item, or -1 for none. + cursor int + scrollState viewport.ScrollState } 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. @@ -39,7 +41,7 @@ func (m Model) View() tview.Widget { func (Model) Init() tview.Cmd { return nil } 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] 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)). // The list is only shown while mentions are being completed, so it takes its keys first. 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). - Target(l.Target). + Target(l.Target(m.cursor)). 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) { switch msg := msg.(type) { case listMsg: - m.selectionState.Apply(list.Change(msg)) + m.cursor = int(msg) m.scrollState.ScrollToTarget() case scrollMsg: - m.scrollState.Apply(viewport.Change(msg)) + m.scrollState = viewport.ScrollState(msg) } return m, nil } @@ -71,7 +73,7 @@ func (m *Model) Append(item Item) { func (m *Model) Clear() { m.items = nil - m.selectionState.SetCursor(-1) + m.cursor = -1 } func (m Model) ItemCount() int { @@ -79,7 +81,7 @@ func (m Model) ItemCount() int { } func (m Model) SelectedInsertText() (string, bool) { - index := m.selectionState.Cursor() + index := m.cursor if index < 0 || index >= len(m.items) { return "", false } @@ -95,7 +97,7 @@ func (m Model) maxDisplayWidth() int { } func (m *Model) Rebuild() { - m.selectionState.SetCursor(min(0, len(m.items)-1)) + m.cursor = min(0, len(m.items)-1) m.scrollState.ScrollToTarget() } diff --git a/internal/ui/chat/mentionslist/msg.go b/internal/ui/chat/mentionslist/msg.go index 489175b..c3a93a7 100644 --- a/internal/ui/chat/mentionslist/msg.go +++ b/internal/ui/chat/mentionslist/msg.go @@ -1,17 +1,17 @@ package mentionslist import ( - "github.com/ayn2op/tview/list" "github.com/ayn2op/tview/viewport" ) // Msg is implemented by the messages that move or scroll the list. type Msg interface{ mentionsList() } -type listMsg list.Change +// listMsg is the item to move the cursor to. +type listMsg int func (listMsg) mentionsList() {} -type scrollMsg viewport.Change +type scrollMsg viewport.ScrollState func (scrollMsg) mentionsList() {} diff --git a/internal/ui/chat/messageslist/model.go b/internal/ui/chat/messageslist/model.go index bb3e3e5..b8e09ad 100644 --- a/internal/ui/chat/messageslist/model.go +++ b/internal/ui/chat/messageslist/model.go @@ -56,7 +56,8 @@ type messageItem 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 cfg *config.Config @@ -83,12 +84,13 @@ type Model struct { func NewModel(cfg *config.Config, state *ningen.State) Model { ml := Model{ - selectionState: list.NewSelectionState(), - cfg: cfg, - state: state, - renderer: markdown.NewRenderer(cfg), - avatars: map[discord.URL]tviewimage.Widget{}, - voicePlayer: voice.NewPlayer(), + cursor: -1, + target: -1, + cfg: cfg, + state: state, + renderer: markdown.NewRenderer(cfg), + avatars: map[discord.URL]tviewimage.Widget{}, + voicePlayer: voice.NewPlayer(), } ml.scrollState.ScrollToEnd() @@ -119,29 +121,27 @@ func (m Model) listView(focused bool) viewport.Widget { Style(theme.TrackStyle.Style). ThumbStyle(theme.ThumbStyle.Style) // 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)). 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). - Target(l.Target). + Target(l.Target(m.target)). ScrollBar(bar, cfg.Visibility.ScrollBarVisibility). TrackEnd(true). Keybind(ui.ScrollKeybind(m.cfg.Keybinds.MessagesList.ScrollKeybinds)). Focused(focused). - OnChange(func(a viewport.Change) tview.Msg { return scrollMsg(a) }) -} - -func (m Model) cursor() int { - return m.selectionState.Cursor() + OnChange(func(s *viewport.ScrollState) tview.Msg { return scrollMsg(*s) }) } // 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) { - if index >= 0 && index != m.cursor() { + if index >= 0 && index != m.cursor { 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. @@ -190,7 +190,7 @@ func (m Model) indexOf(channelID discord.ChannelID, id discord.MessageID) int { func (m *Model) reset() { m.items = nil clear(m.avatars) - m.selectionState = list.NewSelectionState() + m.cursor, m.target = -1, -1 m.scrollState = viewport.ScrollState{} m.scrollState.ScrollToEnd() } @@ -226,7 +226,7 @@ func (m *Model) deleteMessage(index int) { return } - cursor := m.cursor() + cursor := m.cursor if cursor == index { cursor = m.messageIndex(index, -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. func (m *Model) rebuildItems() { items := make([]messageItem, 0, len(m.items)) - cursor, selected := m.cursor(), -1 + cursor, selected := m.cursor, -1 var previous discord.Timestamp for i, item := range m.items { if item.separator { @@ -953,7 +953,7 @@ func (m Model) drawPinnedMessage(builder *richtext.Builder, message discord.Mess } func (m Model) selectedMessage() (*discord.Message, bool) { - cursor := m.cursor() + cursor := m.cursor if cursor < 0 || cursor >= len(m.items) || m.items[cursor].separator { return nil, false } @@ -1014,7 +1014,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { if selectedChannel == nil || selectedChannel.ID != msg.ChannelID { return m, nil } - prevCursor := m.cursor() + prevCursor := m.cursor older := make([]messageItem, len(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" } case scrollMsg: - m.scrollState.Apply(viewport.Change(msg)) + m.scrollState = viewport.ScrollState(msg) case listMsg: top := m.atTop() - m.selectionState.Apply(list.Change(msg)) - m.onRowCursorChanged(m.cursor()) + m.cursor, m.target = int(msg), int(msg) + m.onRowCursorChanged(m.cursor) if !top && m.atTop() { 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. 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() { - if m.cursor() == -1 { + if m.cursor == -1 { 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) } } @@ -1131,7 +1131,7 @@ func (m *Model) selectBottom() { func (m *Model) selectReply() { messages := m.items - cursor := m.cursor() + cursor := m.cursor if cursor < 0 || cursor >= len(messages) { return } diff --git a/internal/ui/chat/messageslist/model_test.go b/internal/ui/chat/messageslist/model_test.go index 6f3c130..49e5fe4 100644 --- a/internal/ui/chat/messageslist/model_test.go +++ b/internal/ui/chat/messageslist/model_test.go @@ -55,7 +55,7 @@ func TestModelBuildItem(t *testing.T) { t.Fatal("older messages were not fetched once on reaching the top") } 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") } ml.selectBottom() @@ -144,15 +144,15 @@ func TestModelRebuildItems(t *testing.T) { *ml, _ = ml.Update(olderMessagesLoadedMsg{ChannelID: 1, Older: messages[2:]}) assertItems(1, 2, 3) assertSelected(2) - 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") } }) diff --git a/internal/ui/chat/messageslist/msg.go b/internal/ui/chat/messageslist/msg.go index c1b58c3..38ccd83 100644 --- a/internal/ui/chat/messageslist/msg.go +++ b/internal/ui/chat/messageslist/msg.go @@ -16,7 +16,6 @@ import ( "github.com/ayn2op/discordo/internal/consts" "github.com/ayn2op/discordo/internal/ui" "github.com/ayn2op/tview" - "github.com/ayn2op/tview/list" "github.com/ayn2op/tview/picker" "github.com/ayn2op/tview/viewport" "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. type ShowAttachmentsMsg picker.Items -// listMsg moves the messages list. -type listMsg list.Change +// listMsg is the item of the messages list to move the cursor to. +type listMsg int // scrollMsg scrolls the messages list. -type scrollMsg viewport.Change +type scrollMsg viewport.ScrollState func (m *Model) selectUp() tview.Cmd { - cursor := m.cursor() + cursor := m.cursor if cursor == -1 { m.selectBottom() } else if previous := m.messageIndex(cursor, -1); previous >= 0 { diff --git a/internal/ui/login/password/model.go b/internal/ui/login/password/model.go index 75d3ffe..d7edf1b 100644 --- a/internal/ui/login/password/model.go +++ b/internal/ui/login/password/model.go @@ -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. type Model struct { - login, password textinput.EditState - focus focus + login, password string + // loginCursor and passwordCursor are the byte offsets of the cursor in login and password. + loginCursor, passwordCursor int + + focus focus } 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) { switch msg := msg.(type) { case loginMsg: - m.login.Apply(textinput.Change(msg)) + m.login, m.loginCursor = msg.text, msg.cursor case passwordMsg: - m.password.Apply(textinput.Change(msg)) + m.password, m.passwordCursor = msg.text, msg.cursor case focusMsg: m.focus = focus(msg) case tview.KeyMsg: @@ -51,25 +54,24 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { m.focus = (m.focus + focusCount - 1) % focusCount } case submitMsg: - login, password := m.login.Value(), m.password.Value() - if login == "" || password == "" { + if m.login == "" || m.password == "" { return m, nil } - return m, submitLogin(login, password) + return m, submitLogin(m.login, m.password) } return m, nil } func (m Model) View() tview.Widget { labelWidth := layout.Fixed(len("Password") + 1) - login := textinput.New(&m.login). + login := textinput.New(m.login, m.loginCursor). 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)) - password := textinput.New(&m.password). + password := textinput.New(m.password, m.passwordCursor). Mask("*"). 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)) submit := button.New(). Label("Login"). diff --git a/internal/ui/login/password/model_test.go b/internal/ui/login/password/model_test.go index 8ef2d77..05f8798 100644 --- a/internal/ui/login/password/model_test.go +++ b/internal/ui/login/password/model_test.go @@ -23,8 +23,8 @@ func TestModelUpdate(t *testing.T) { send(&m, key(tcell.KeyEnter, "")) send(&m, key(tcell.KeyRune, "b")) t.Run("typing fills the focused input and Enter moves on", func(t *testing.T) { - if m.login.Value() != "a" || m.password.Value() != "b" { - t.Fatalf("values = %q, %q", m.login.Value(), m.password.Value()) + if m.login != "a" || m.password != "b" { + t.Fatalf("values = %q, %q", m.login, m.password) } }) t.Run("enter on the button submits", func(t *testing.T) { diff --git a/internal/ui/login/password/msg.go b/internal/ui/login/password/msg.go index 2385e66..ef45769 100644 --- a/internal/ui/login/password/msg.go +++ b/internal/ui/login/password/msg.go @@ -6,14 +6,19 @@ import ( "github.com/ayn2op/arikawa/v3/utils/httputil" "github.com/ayn2op/discordo/internal/http" "github.com/ayn2op/tview" - "github.com/ayn2op/tview/textinput" ) type TokenMsg string +// inputMsg is the text and cursor that typing in an input left. +type inputMsg struct { + text string + cursor int +} + type ( - loginMsg textinput.Change - passwordMsg textinput.Change + loginMsg inputMsg + passwordMsg inputMsg focusMsg focus submitMsg struct{} ) diff --git a/internal/ui/login/qr/model.go b/internal/ui/login/qr/model.go index 2cc9ca3..50dfd98 100644 --- a/internal/ui/login/qr/model.go +++ b/internal/ui/login/qr/model.go @@ -56,7 +56,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { } return m, nil case scrollMsg: - m.scrollState.Apply(viewport.Change(msg)) + m.scrollState = viewport.ScrollState(msg) return m, nil case connCreateMsg: @@ -135,7 +135,7 @@ func (m Model) View() tview.Widget { viewport.New(textview.New(code).Wrap(false).Alignment(tview.AlignmentCenter), m.scrollState). Height(layout.Shrink). Focused(true). - OnChange(func(a viewport.Change) tview.Msg { return scrollMsg(a) }), + OnChange(func(s *viewport.ScrollState) tview.Msg { return scrollMsg(*s) }), ) } diff --git a/internal/ui/login/qr/msg.go b/internal/ui/login/qr/msg.go index f2b0672..6f53d10 100644 --- a/internal/ui/login/qr/msg.go +++ b/internal/ui/login/qr/msg.go @@ -302,7 +302,7 @@ func exchangeTicket(fingerprint string, privateKey *rsa.PrivateKey, ticket strin } // scrollMsg scrolls the code. -type scrollMsg viewport.Change +type scrollMsg viewport.ScrollState // startMsg starts connecting to the Remote Auth Gateway. type startMsg struct{} diff --git a/internal/ui/login/token/model.go b/internal/ui/login/token/model.go index 245f3a1..405d1d8 100644 --- a/internal/ui/login/token/model.go +++ b/internal/ui/login/token/model.go @@ -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. type Model struct { - token textinput.EditState + token string + // cursor is the byte offset of the cursor in token. + cursor int + buttonFocused bool } @@ -28,7 +31,7 @@ func (Model) Init() tview.Cmd { return nil } func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { switch msg := msg.(type) { case tokenMsg: - m.token.Apply(textinput.Change(msg)) + m.token, m.cursor = msg.text, msg.cursor case focusButtonMsg: m.buttonFocused = true case tview.KeyMsg: @@ -37,20 +40,19 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { m.buttonFocused = !m.buttonFocused } case submitMsg: - token := m.token.Value() - if token == "" { + if m.token == "" { return m, nil } - return m, submitToken(token) + return m, submitToken(m.token) } return m, nil } func (m Model) View() tview.Widget { - input := textinput.New(&m.token). + input := textinput.New(m.token, m.cursor). Mask("*"). 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{}) submit := button.New(). Label("Login"). diff --git a/internal/ui/login/token/msg.go b/internal/ui/login/token/msg.go index 1eb2a03..6a39c77 100644 --- a/internal/ui/login/token/msg.go +++ b/internal/ui/login/token/msg.go @@ -2,13 +2,17 @@ package token import ( "github.com/ayn2op/tview" - "github.com/ayn2op/tview/textinput" ) type TokenMsg string +// tokenMsg is the token and cursor that typing in the input left. +type tokenMsg struct { + text string + cursor int +} + type ( - tokenMsg textinput.Change focusButtonMsg struct{} submitMsg struct{} ) diff --git a/internal/ui/picker/msg.go b/internal/ui/picker/msg.go index f0beacb..740f062 100644 --- a/internal/ui/picker/msg.go +++ b/internal/ui/picker/msg.go @@ -2,5 +2,5 @@ package picker import "github.com/ayn2op/tview/picker" -// changeMsg changes the picker. -type changeMsg picker.Change +// changeMsg is the search state that typing, moving the selection or scrolling left the picker in. +type changeMsg picker.SearchState diff --git a/internal/ui/picker/picker.go b/internal/ui/picker/picker.go index fdcf31d..fb1ba17 100644 --- a/internal/ui/picker/picker.go +++ b/internal/ui/picker/picker.go @@ -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). Keybind(bindKeys(cfg.Keybinds.Picker)). 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). OnCancel(onCancel) 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) { if msg, ok := msg.(changeMsg); ok { - m.searchState.Apply(picker.Change(msg)) + m.searchState = picker.SearchState(msg) } return m, nil } diff --git a/internal/ui/tree/tree.go b/internal/ui/tree/tree.go index 509e564..739ba68 100644 --- a/internal/ui/tree/tree.go +++ b/internal/ui/tree/tree.go @@ -13,19 +13,19 @@ import ( "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 { - Change viewport.Change - Msg tview.Msg + ScrollState viewport.ScrollState + Msg tview.Msg } // 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 if focused { set = cfg.UI.Border.ActiveSet.BorderSet } - t := tree.New(root, selectionState). + t := tree.New(root, current). TopLevel(1). Markers(tree.Markers{ Expanded: cfg.Sidebar.Markers.Expanded, @@ -42,11 +42,11 @@ func New(root *tree.Node, selectionState tree.SelectionState, scrollState viewpo v := viewport.New(t, scrollState). Target(t.Target). 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}). Keybind(bindScrollKeys(kbs)). 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) }