refactor(ui): remove redraw commands and inline command events

This commit is contained in:
ayn2op 2026-03-14 05:06:21 -04:00
parent abdb0748a2
commit 2d79e45f6f
15 changed files with 215 additions and 329 deletions

14
go.mod
View file

@ -11,9 +11,9 @@ require (
github.com/alecthomas/chroma/v2 v2.23.1
github.com/andybalholm/brotli v1.2.0
github.com/ayn2op/clipboard v0.0.0-20260308203959-c5ad7df3fc97
github.com/ayn2op/tview v0.0.0-20260311012550-9b46cad522bf
github.com/ayn2op/tview v0.0.0-20260314084311-e6fda4e5838b
github.com/deckarep/gosx-notifier v0.0.0-20180201035817-e127226297fb
github.com/diamondburned/arikawa/v3 v3.6.1-0.20260309010533-e61165a61b64
github.com/diamondburned/arikawa/v3 v3.6.1-0.20260311205148-176ad9b9440f
github.com/diamondburned/ningen/v3 v3.0.1-0.20260306213430-5a08d3a709b4
github.com/gdamore/tcell/v3 v3.1.2
github.com/gen2brain/beeep v0.11.2
@ -53,11 +53,11 @@ require (
github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect
github.com/twmb/murmur3 v1.1.8 // indirect
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
golang.org/x/exp/shiny v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/image v0.36.0 // indirect
golang.org/x/mobile v0.0.0-20260217195705-b56b3793a9c4 // indirect
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90 // indirect
golang.org/x/image v0.37.0 // indirect
golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/term v0.41.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/time v0.15.0 // indirect
)

28
go.sum
View file

@ -16,8 +16,8 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/ayn2op/clipboard v0.0.0-20260308203959-c5ad7df3fc97 h1:WujETUV+v0DEJyZgjeLzQvihWyL80c0Tg4qf0dDo+Io=
github.com/ayn2op/clipboard v0.0.0-20260308203959-c5ad7df3fc97/go.mod h1:3kFnpNCa3dF6WryzOMCDao7PfZ7DTCh+pievlfuwV80=
github.com/ayn2op/tview v0.0.0-20260311012550-9b46cad522bf h1:ay4OtKYsFGvK4Ng1pmZ/n8MA1CWzZO80lDlrDiCpdag=
github.com/ayn2op/tview v0.0.0-20260311012550-9b46cad522bf/go.mod h1:lZ8RdOegQWBQafTOasGE7Ps1/Ymy4jmXoPt5vz2QsS0=
github.com/ayn2op/tview v0.0.0-20260314084311-e6fda4e5838b h1:ZGDcIf5Vrk+/Tl3GMaxuXNQek8oT/FW27nn5xdkXx5A=
github.com/ayn2op/tview v0.0.0-20260314084311-e6fda4e5838b/go.mod h1:lZ8RdOegQWBQafTOasGE7Ps1/Ymy4jmXoPt5vz2QsS0=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -27,8 +27,8 @@ github.com/dchest/jsmin v1.0.0 h1:Y2hWXmGZiRxtl+VcTksyucgTlYxnhPzTozCwx9gy9zI=
github.com/dchest/jsmin v1.0.0/go.mod h1:AVBIund7Mr7lKXT70hKT2YgL3XEXUaUk5iw9DZ8b0Uc=
github.com/deckarep/gosx-notifier v0.0.0-20180201035817-e127226297fb h1:6S+TKObz6+Io2c8IOkcbK4Sz7nj6RpEVU7TkvmsZZcw=
github.com/deckarep/gosx-notifier v0.0.0-20180201035817-e127226297fb/go.mod h1:wf3nKtOnQqCp7kp9xB7hHnNlZ6m3NoiOxjrB9hFRq4Y=
github.com/diamondburned/arikawa/v3 v3.6.1-0.20260309010533-e61165a61b64 h1:ZdzbwItbGTvOdNfoCH7QIbRiBPDufsq8AwfJUaM4mHM=
github.com/diamondburned/arikawa/v3 v3.6.1-0.20260309010533-e61165a61b64/go.mod h1:TpV2GvCJIYSwAXUEAx4sutPcftyAbVidiFlG6l7K0go=
github.com/diamondburned/arikawa/v3 v3.6.1-0.20260311205148-176ad9b9440f h1:IQWwTKdWCWl0MiD2ln0LSTiO9FMOrp4FlbOfKgaF8/A=
github.com/diamondburned/arikawa/v3 v3.6.1-0.20260311205148-176ad9b9440f/go.mod h1:TpV2GvCJIYSwAXUEAx4sutPcftyAbVidiFlG6l7K0go=
github.com/diamondburned/ningen/v3 v3.0.1-0.20260306213430-5a08d3a709b4 h1:m1WyrOUuFE4BuIcQzPeRmmLQmJMBNElT/tfx/s2+AoE=
github.com/diamondburned/ningen/v3 v3.0.1-0.20260306213430-5a08d3a709b4/go.mod h1:JSqSCBN5MAI9yjAO6tZlSayXM9KWx+8q3EhhlkCuscM=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
@ -117,12 +117,12 @@ go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/exp/shiny v0.0.0-20260218203240-3dfff04db8fa h1:+7e7RPzOw2fG8DBbddatlOmHGNCg+VlA2Ar0yVMw7sM=
golang.org/x/exp/shiny v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:zxsA7NyDTOUjcveVwAMFI/YIErWwayTW/4RGqB/RzKk=
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
golang.org/x/mobile v0.0.0-20260217195705-b56b3793a9c4 h1:uT3oYo9M38vJa7JpT4kCie2lJwOpoUrx7FvV0H7kXSc=
golang.org/x/mobile v0.0.0-20260217195705-b56b3793a9c4/go.mod h1:4OGHIUSBiIqyFAQDaX1tpY0BVnO20DvNDeATBu8aeFQ=
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90 h1:kyPrwnEYXdME284bE7xgS9BPxhG7MCa5hw1/TpaTJVs=
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:jqkJFnLVkS8zgKKY4+MOPCZtuZGw3hONUjhapUSwZ8c=
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60 h1:MOzyaj0wu2xneBkzkg9LHNYjDBB4W5vP043A2SYQRPA=
golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60/go.mod h1:th6VJvzjMbrYF8SduQY5rpD0HG0GleGxjadkqSxFs3k=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@ -144,15 +144,15 @@ golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View file

@ -9,14 +9,12 @@ import (
type LogoutEvent struct{ tcell.EventTime }
func newLogoutEvent() *LogoutEvent {
event := &LogoutEvent{}
event.SetEventNow()
return event
}
func (v *Model) logout() tview.Command {
return tview.EventCommand(func() tcell.Event { return newLogoutEvent() })
return func() tcell.Event {
event := &LogoutEvent{}
event.SetEventNow()
return event
}
}
type QuitEvent struct{ tcell.EventTime }
@ -28,11 +26,24 @@ func NewQuitEvent() *QuitEvent {
}
func (v *Model) closeState() tview.Command {
return tview.EventCommand(func() tcell.Event {
return func() tcell.Event {
if err := v.CloseState(); err != nil {
slog.Error("failed to close the session", "err", err)
return tcell.NewEventError(err)
}
return nil
})
}
}
type closeLayerEvent struct {
tcell.EventTime
name string
}
func closeLayer(name string) tview.Command {
return func() tcell.Event {
event := &closeLayerEvent{name: name}
event.SetEventNow()
return event
}
}

View file

@ -397,13 +397,12 @@ func (gt *guildsTree) collapseParentNode(node *tview.TreeNode) {
func (gt *guildsTree) HandleEvent(event tcell.Event) tview.Command {
switch event := event.(type) {
case *tview.KeyEvent:
redraw := tview.RedrawCommand{}
handler := gt.TreeView.HandleEvent
switch {
case keybind.Matches(event, gt.cfg.Keybinds.GuildsTree.CollapseParentNode.Keybind):
gt.collapseParentNode(gt.GetCurrentNode())
return redraw
return nil
case keybind.Matches(event, gt.cfg.Keybinds.GuildsTree.MoveToParentNode.Keybind):
return handler(tcell.NewEventKey(tcell.KeyRune, "K", tcell.ModNone))
case keybind.Matches(event, gt.cfg.Keybinds.GuildsTree.Up.Keybind):
@ -412,10 +411,10 @@ func (gt *guildsTree) HandleEvent(event tcell.Event) tview.Command {
return handler(tcell.NewEventKey(tcell.KeyDown, "", tcell.ModNone))
case keybind.Matches(event, gt.cfg.Keybinds.GuildsTree.Top.Keybind):
gt.Move(gt.GetRowCount() * -1)
return redraw
return nil
case keybind.Matches(event, gt.cfg.Keybinds.GuildsTree.Bottom.Keybind):
gt.Move(gt.GetRowCount())
return redraw
return nil
case keybind.Matches(event, gt.cfg.Keybinds.GuildsTree.SelectCurrent.Keybind):
return handler(tcell.NewEventKey(tcell.KeyEnter, "", tcell.ModNone))
case keybind.Matches(event, gt.cfg.Keybinds.GuildsTree.YankID.Keybind):

View file

@ -21,7 +21,6 @@ import (
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/help"
"github.com/ayn2op/tview/keybind"
"github.com/ayn2op/tview/layers"
"github.com/diamondburned/arikawa/v3/api"
"github.com/diamondburned/arikawa/v3/discord"
"github.com/diamondburned/arikawa/v3/state"
@ -107,7 +106,6 @@ func (mi *messageInput) stopTypingTimer() {
func (mi *messageInput) HandleEvent(event tcell.Event) tview.Command {
switch event := event.(type) {
case *tview.KeyEvent:
redraw := tview.RedrawCommand{}
handler := mi.TextArea.HandleEvent
switch {
case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.Paste.Keybind):
@ -119,29 +117,27 @@ func (mi *messageInput) HandleEvent(event tcell.Event) tview.Command {
} else {
mi.send()
}
return redraw
return nil
case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.OpenEditor.Keybind):
var cmds tview.BatchCommand
var cmds []tview.Command
mi.stopTabCompletion(func(next tview.Command) {
if next != nil {
cmds = append(cmds, next)
}
})
mi.editor()
cmds = append(cmds, redraw)
return cmds
return tview.Batch(cmds...)
case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.OpenFilePicker.Keybind):
var cmds tview.BatchCommand
var cmds []tview.Command
mi.stopTabCompletion(func(next tview.Command) {
if next != nil {
cmds = append(cmds, next)
}
})
mi.openFilePicker()
cmds = append(cmds, redraw)
return cmds
return tview.Batch(cmds...)
case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.Cancel.Keybind):
var cmds tview.BatchCommand
var cmds []tview.Command
if mi.chat.GetVisible(mentionsListLayerName) {
mi.stopTabCompletion(func(next tview.Command) {
if next != nil {
@ -151,11 +147,10 @@ func (mi *messageInput) HandleEvent(event tcell.Event) tview.Command {
} else {
mi.reset()
}
cmds = append(cmds, redraw)
return cmds
return tview.Batch(cmds...)
case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.TabComplete.Keybind):
go mi.chat.app.QueueUpdateDraw(func() { mi.tabComplete() })
return redraw
return nil
case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.Undo.Keybind):
return handler(tcell.NewEventKey(tcell.KeyCtrlZ, "", tcell.ModNone))
}
@ -177,16 +172,16 @@ func (mi *messageInput) HandleEvent(event tcell.Event) tview.Command {
switch {
case keybind.Matches(event, mi.cfg.Keybinds.MentionsList.Up.Keybind):
mi.mentionsList.HandleEvent(tcell.NewEventKey(tcell.KeyUp, "", tcell.ModNone))
return redraw
return nil
case keybind.Matches(event, mi.cfg.Keybinds.MentionsList.Down.Keybind):
mi.mentionsList.HandleEvent(tcell.NewEventKey(tcell.KeyDown, "", tcell.ModNone))
return redraw
return nil
case keybind.Matches(event, mi.cfg.Keybinds.MentionsList.Top.Keybind):
mi.mentionsList.HandleEvent(tcell.NewEventKey(tcell.KeyHome, "", tcell.ModNone))
return redraw
return nil
case keybind.Matches(event, mi.cfg.Keybinds.MentionsList.Bottom.Keybind):
mi.mentionsList.HandleEvent(tcell.NewEventKey(tcell.KeyEnd, "", tcell.ModNone))
return redraw
return nil
}
}
@ -631,8 +626,8 @@ func (mi *messageInput) stopTabCompletion(emit func(tview.Command)) {
if mi.cfg.AutocompleteLimit > 0 {
mi.mentionsList.clear()
if emit != nil {
emit(layers.CloseLayerCommand{Name: mentionsListLayerName})
emit(tview.SetFocusCommand{Target: mi})
emit(closeLayer(mentionsListLayerName))
emit(tview.SetFocus(mi))
} else {
mi.removeMentionsList()
mi.chat.app.SetFocus(mi)

View file

@ -840,38 +840,37 @@ func (ml *messagesList) selectedMessage() (*discord.Message, error) {
func (ml *messagesList) HandleEvent(event tcell.Event) tview.Command {
switch event := event.(type) {
case *tview.KeyEvent:
redraw := tview.RedrawCommand{}
switch {
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ScrollUp.Keybind):
ml.ScrollUp()
return redraw
return nil
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ScrollDown.Keybind):
ml.ScrollDown()
return redraw
return nil
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ScrollTop.Keybind):
ml.ScrollToStart()
return redraw
return nil
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ScrollBottom.Keybind):
ml.ScrollToEnd()
return redraw
return nil
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.Cancel.Keybind):
ml.clearSelection()
return redraw
return nil
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectUp.Keybind):
ml.selectUp()
return redraw
return nil
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectDown.Keybind):
ml.selectDown()
return redraw
return nil
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectTop.Keybind):
ml.selectTop()
return redraw
return nil
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectBottom.Keybind):
ml.selectBottom()
return redraw
return nil
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectReply.Keybind):
ml.selectReply()
return redraw
return nil
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.YankID.Keybind):
ml.yankID()
return nil
@ -883,22 +882,22 @@ func (ml *messagesList) HandleEvent(event tcell.Event) tview.Command {
return nil
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.Open.Keybind):
ml.open()
return redraw
return nil
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.Reply.Keybind):
ml.reply(false)
return redraw
return nil
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ReplyMention.Keybind):
ml.reply(true)
return redraw
return nil
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.Edit.Keybind):
ml.edit()
return redraw
return nil
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.Delete.Keybind):
ml.delete()
return redraw
return nil
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.DeleteConfirm.Keybind):
ml.confirmDelete()
return redraw
return nil
}
// Do not fall through to List defaults for unmatched keys.
return nil

View file

@ -203,18 +203,18 @@ func (v *Model) focusNext() {
func (v *Model) HandleEvent(event tcell.Event) tview.Command {
switch event := event.(type) {
case *tview.InitEvent:
return tview.EventCommand(func() tcell.Event {
return func() tcell.Event {
if err := v.OpenState(v.token); err != nil {
slog.Error("failed to open chat state", "err", err)
return tcell.NewEventError(err)
}
return nil
})
}
case *QuitEvent:
return tview.BatchCommand{
return tview.Batch(
v.closeState(),
tview.Quit(),
}
)
case *tview.ModalDoneEvent:
if v.HasLayer(confirmModalLayerName) {
v.RemoveLayer(confirmModalLayerName)
@ -227,88 +227,43 @@ func (v *Model) HandleEvent(event tcell.Event) tview.Command {
if onDone != nil {
onDone(event.ButtonLabel)
}
return tview.RedrawCommand{}
return nil
}
case *tview.KeyEvent:
redraw := tview.RedrawCommand{}
switch {
case keybind.Matches(event, v.cfg.Keybinds.FocusGuildsTree.Keybind):
v.messageInput.removeMentionsList()
v.focusGuildsTree()
return redraw
return nil
case keybind.Matches(event, v.cfg.Keybinds.FocusMessagesList.Keybind):
v.messageInput.removeMentionsList()
v.app.SetFocus(v.messagesList)
return redraw
return nil
case keybind.Matches(event, v.cfg.Keybinds.FocusMessageInput.Keybind):
v.focusMessageInput()
return redraw
return nil
case keybind.Matches(event, v.cfg.Keybinds.FocusPrevious.Keybind):
v.focusPrevious()
return redraw
return nil
case keybind.Matches(event, v.cfg.Keybinds.FocusNext.Keybind):
v.focusNext()
return redraw
return nil
case keybind.Matches(event, v.cfg.Keybinds.Logout.Keybind):
return tview.BatchCommand{v.closeState(), v.logout()}
return tview.Batch(v.closeState(), v.logout())
case keybind.Matches(event, v.cfg.Keybinds.ToggleGuildsTree.Keybind):
v.toggleGuildsTree()
return redraw
return nil
case keybind.Matches(event, v.cfg.Keybinds.ToggleChannelsPicker.Keybind):
v.togglePicker()
return redraw
return nil
}
case *closeLayerEvent:
if v.HasLayer(event.name) {
v.HideLayer(event.name)
}
}
cmd := v.Layers.HandleEvent(event)
return v.consumeLayerCommands(cmd)
}
func (v *Model) consumeLayerCommands(command tview.Command) tview.Command {
if command == nil {
return nil
}
var commands []tview.Command
switch c := command.(type) {
case tview.BatchCommand:
commands = c
default:
commands = []tview.Command{c}
}
remaining := make([]tview.Command, 0, len(commands))
for _, cmd := range commands {
switch c := cmd.(type) {
case layers.OpenLayerCommand:
if v.HasLayer(c.Name) {
v.ShowLayer(c.Name).SendToFront(c.Name)
}
continue
case layers.CloseLayerCommand:
if v.HasLayer(c.Name) {
v.HideLayer(c.Name)
}
continue
case layers.ToggleLayerCommand:
if v.HasLayer(c.Name) {
if v.GetVisible(c.Name) {
v.HideLayer(c.Name)
} else {
v.ShowLayer(c.Name).SendToFront(c.Name)
}
}
continue
}
remaining = append(remaining, cmd)
}
if len(remaining) == 0 {
return nil
}
if len(remaining) == 1 {
return remaining[0]
}
return tview.BatchCommand(remaining)
return v.Layers.HandleEvent(event)
}
func (v *Model) showConfirmModal(prompt string, buttons []string, onDone func(label string)) {

View file

@ -9,11 +9,11 @@ import (
)
func setClipboard(content string) tview.Command {
return tview.EventCommand(func() tcell.Event {
return func() tcell.Event {
if err := clipboard.Write(clipboard.FmtText, []byte(content)); err != nil {
slog.Error("failed to copy error message", "err", err)
return tcell.NewEventError(err)
}
return nil
})
}
}

View file

@ -22,14 +22,13 @@ const (
type Model struct {
*layers.Layers
tabs *tabs.Model
app *tview.Application
cfg *config.Config
errorModalText string
}
func NewModel(app *tview.Application, cfg *config.Config) *Model {
tabs := tabs.NewModel([]tabs.Tab{token.NewModel(), qr.NewModel(app)})
func NewModel(cfg *config.Config) *Model {
tabs := tabs.NewModel([]tabs.Tab{token.NewModel(), qr.NewModel()})
l := layers.New()
ui.ConfigureBox(l.Box, &cfg.Theme)
@ -38,9 +37,7 @@ func NewModel(app *tview.Application, cfg *config.Config) *Model {
return &Model{
Layers: l,
tabs: tabs,
app: app,
cfg: cfg,
cfg: cfg,
}
}
@ -48,10 +45,9 @@ func (m *Model) HandleEvent(event tcell.Event) tview.Command {
switch event := event.(type) {
case *tcell.EventError:
if m.HasLayer(errorLayerName) {
return tview.RedrawCommand{}
return nil
}
m.onError(event)
return tview.RedrawCommand{}
return m.showErrorDialog(event)
case *tview.ModalDoneEvent:
if !m.HasLayer(errorLayerName) {
return nil
@ -61,12 +57,12 @@ func (m *Model) HandleEvent(event tcell.Event) tview.Command {
}
m.RemoveLayer(errorLayerName)
m.errorModalText = ""
return tview.RedrawCommand{}
return nil
}
return m.Layers.HandleEvent(event)
}
func (m *Model) onError(err error) {
func (m *Model) showErrorDialog(err error) tview.Command {
slog.Error("failed to login", "err", err)
message := err.Error()
@ -99,6 +95,5 @@ func (m *Model) onError(err error) {
layers.WithOverlay(),
).
SendToFront(errorLayerName)
modal.SetFocus(0)
m.app.SetFocus(modal)
return tview.SetFocus(modal)
}

View file

@ -24,12 +24,6 @@ type TokenEvent struct {
Token string
}
func newTokenEvent(token string) *TokenEvent {
event := &TokenEvent{Token: token}
event.SetEventNow()
return event
}
const remoteAuthGatewayURL = "wss://remote-auth-gateway.discord.gg/?v=2"
type connCreateEvent struct {
@ -37,41 +31,33 @@ type connCreateEvent struct {
conn *websocket.Conn
}
func newConnCreateEvent(conn *websocket.Conn) *connCreateEvent {
event := &connCreateEvent{conn: conn}
event.SetEventNow()
return event
}
type connCloseEvent struct{ tcell.EventTime }
func newConnCloseEvent() *connCloseEvent {
event := &connCloseEvent{}
event.SetEventNow()
return event
}
func (m *Model) connect() tview.Command {
return tview.EventCommand(func() tcell.Event {
return func() tcell.Event {
headers := http.Headers()
headers.Set("User-Agent", http.BrowserUserAgent)
conn, _, err := websocket.DefaultDialer.Dial(remoteAuthGatewayURL, headers)
if err != nil {
return tcell.NewEventError(err)
}
return newConnCreateEvent(conn)
})
event := &connCreateEvent{conn: conn}
event.SetEventNow()
return event
}
}
func (m *Model) close() tview.Command {
return tview.EventCommand(func() tcell.Event {
return func() tcell.Event {
if m.conn != nil {
if err := m.conn.Close(); err != nil {
return tcell.NewEventError(err)
}
}
return newConnCloseEvent()
})
event := &connCloseEvent{}
event.SetEventNow()
return event
}
}
type helloEvent struct {
@ -80,66 +66,30 @@ type helloEvent struct {
timeoutMS int
}
func newHelloEvent(heartbeatInterval, timeoutMS int) *helloEvent {
event := &helloEvent{heartbeatInterval: heartbeatInterval, timeoutMS: timeoutMS}
event.SetEventNow()
return event
}
type nonceProofEvent struct {
tcell.EventTime
encryptedNonce string
}
func newNonceProofEvent(encryptedNonce string) *nonceProofEvent {
event := &nonceProofEvent{encryptedNonce: encryptedNonce}
event.SetEventNow()
return event
}
type pendingRemoteInitEvent struct {
tcell.EventTime
fingerprint string
}
func newPendingRemoteInitEvent(fingerprint string) *pendingRemoteInitEvent {
event := &pendingRemoteInitEvent{fingerprint: fingerprint}
event.SetEventNow()
return event
}
type pendingTicketEvent struct {
tcell.EventTime
encryptedUserPayload string
}
func newPendingTicketEvent(encryptedUserPayload string) *pendingTicketEvent {
event := &pendingTicketEvent{encryptedUserPayload: encryptedUserPayload}
event.SetEventNow()
return event
}
type pendingLoginEvent struct {
tcell.EventTime
ticket string
}
func newPendingLoginEvent(ticket string) *pendingLoginEvent {
event := &pendingLoginEvent{ticket: ticket}
event.SetEventNow()
return event
}
type cancelEvent struct{ tcell.EventTime }
func newCancelEvent() *cancelEvent {
event := &cancelEvent{}
event.SetEventNow()
return event
}
func (m *Model) listen() tview.Command {
return tview.EventCommand(func() tcell.Event {
return func() tcell.Event {
if m.conn == nil {
return nil
}
@ -165,7 +115,9 @@ func (m *Model) listen() tview.Command {
if err := json.Unmarshal(data, &payload); err != nil {
return tcell.NewEventError(err)
}
return newHelloEvent(payload.HeartbeatInterval, payload.TimeoutMS)
event := &helloEvent{heartbeatInterval: payload.HeartbeatInterval, timeoutMS: payload.TimeoutMS}
event.SetEventNow()
return event
case "nonce_proof":
var payload struct {
EncryptedNonce string `json:"encrypted_nonce"`
@ -173,7 +125,9 @@ func (m *Model) listen() tview.Command {
if err := json.Unmarshal(data, &payload); err != nil {
return tcell.NewEventError(err)
}
return newNonceProofEvent(payload.EncryptedNonce)
event := &nonceProofEvent{encryptedNonce: payload.EncryptedNonce}
event.SetEventNow()
return event
case "pending_remote_init":
var payload struct {
Fingerprint string `json:"fingerprint"`
@ -181,7 +135,9 @@ func (m *Model) listen() tview.Command {
if err := json.Unmarshal(data, &payload); err != nil {
return tcell.NewEventError(err)
}
return newPendingRemoteInitEvent(payload.Fingerprint)
event := &pendingRemoteInitEvent{fingerprint: payload.Fingerprint}
event.SetEventNow()
return event
case "pending_ticket":
var payload struct {
EncryptedUserPayload string `json:"encrypted_user_payload"`
@ -189,9 +145,13 @@ func (m *Model) listen() tview.Command {
if err := json.Unmarshal(data, &payload); err != nil {
return tcell.NewEventError(err)
}
return newPendingTicketEvent(payload.EncryptedUserPayload)
event := &pendingTicketEvent{encryptedUserPayload: payload.EncryptedUserPayload}
event.SetEventNow()
return event
case "cancel":
return newCancelEvent()
event := &cancelEvent{}
event.SetEventNow()
return event
case "pending_login":
var payload struct {
Ticket string `json:"ticket"`
@ -199,30 +159,28 @@ func (m *Model) listen() tview.Command {
if err := json.Unmarshal(data, &payload); err != nil {
return tcell.NewEventError(err)
}
return newPendingLoginEvent(payload.Ticket)
event := &pendingLoginEvent{ticket: payload.Ticket}
event.SetEventNow()
return event
default:
return nil
}
})
}
}
type heartbeatTickEvent struct{ tcell.EventTime }
func newHeartbeatTickEvent() *heartbeatTickEvent {
event := &heartbeatTickEvent{}
event.SetEventNow()
return event
}
func (m *Model) heartbeat() tview.Command {
return tview.EventCommand(func() tcell.Event {
return func() tcell.Event {
time.Sleep(m.heartbeatInterval)
return newHeartbeatTickEvent()
})
event := &heartbeatTickEvent{}
event.SetEventNow()
return event
}
}
func (m *Model) sendHeartbeat() tview.Command {
return tview.EventCommand(func() tcell.Event {
return func() tcell.Event {
if m.conn == nil {
return nil
}
@ -233,7 +191,7 @@ func (m *Model) sendHeartbeat() tview.Command {
return tcell.NewEventError(err)
}
return nil
})
}
}
type privateKeyEvent struct {
@ -241,24 +199,20 @@ type privateKeyEvent struct {
privateKey *rsa.PrivateKey
}
func newPrivateKeyEvent(privateKey *rsa.PrivateKey) *privateKeyEvent {
event := &privateKeyEvent{privateKey: privateKey}
event.SetEventNow()
return event
}
func (m *Model) generatePrivateKey() tview.Command {
return tview.EventCommand(func() tcell.Event {
return func() tcell.Event {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return tcell.NewEventError(err)
}
return newPrivateKeyEvent(privateKey)
})
event := &privateKeyEvent{privateKey: privateKey}
event.SetEventNow()
return event
}
}
func (m *Model) sendInit() tview.Command {
return tview.EventCommand(func() tcell.Event {
return func() tcell.Event {
if m.privateKey == nil {
return tcell.NewEventError(errors.New("missing private key"))
}
@ -275,11 +229,11 @@ func (m *Model) sendInit() tview.Command {
return tcell.NewEventError(err)
}
return nil
})
}
}
func (m *Model) sendNonceProof(encryptedNonce string) tview.Command {
return tview.EventCommand(func() tcell.Event {
return func() tcell.Event {
decodedNonce, err := base64.StdEncoding.DecodeString(encryptedNonce)
if err != nil {
return tcell.NewEventError(err)
@ -299,7 +253,7 @@ func (m *Model) sendNonceProof(encryptedNonce string) tview.Command {
return tcell.NewEventError(err)
}
return nil
})
}
}
type qrCodeEvent struct {
@ -307,22 +261,18 @@ type qrCodeEvent struct {
qrCode *qrcode.QRCode
}
func newQRCodeEvent(qrCode *qrcode.QRCode) *qrCodeEvent {
event := &qrCodeEvent{qrCode: qrCode}
event.SetEventNow()
return event
}
func (m *Model) generateQRCode(fingerprint string) tview.Command {
return tview.EventCommand(func() tcell.Event {
return func() tcell.Event {
content := "https://discord.com/ra/" + fingerprint
qrCode, err := qrcode.New(content, qrcode.Low)
if err != nil {
return tcell.NewEventError(err)
}
qrCode.DisableBorder = true
return newQRCodeEvent(qrCode)
})
event := &qrCodeEvent{qrCode: qrCode}
event.SetEventNow()
return event
}
}
type userEvent struct {
@ -331,14 +281,8 @@ type userEvent struct {
username string
}
func newUserEvent(discriminator, username string) *userEvent {
event := &userEvent{discriminator: discriminator, username: username}
event.SetEventNow()
return event
}
func (m *Model) decryptUserPayload(encryptedPayload string) tview.Command {
return tview.EventCommand(func() tcell.Event {
return func() tcell.Event {
decodedPayload, err := base64.StdEncoding.DecodeString(encryptedPayload)
if err != nil {
return tcell.NewEventError(err)
@ -354,12 +298,14 @@ func (m *Model) decryptUserPayload(encryptedPayload string) tview.Command {
return tcell.NewEventError(errors.New("invalid user payload"))
}
return newUserEvent(parts[1], parts[3])
})
event := &userEvent{discriminator: parts[1], username: parts[3]}
event.SetEventNow()
return event
}
}
func (m *Model) exchangeTicket(ticket string) tview.Command {
return tview.EventCommand(func() tcell.Event {
return func() tcell.Event {
headers := http.Headers()
headers.Set("Referer", "https://discord.com/login")
if m.fingerprint != "" {
@ -383,6 +329,8 @@ func (m *Model) exchangeTicket(ticket string) tview.Command {
if err != nil {
return tcell.NewEventError(err)
}
return newTokenEvent(string(decryptedToken))
})
event := &TokenEvent{Token: string(decryptedToken)}
event.SetEventNow()
return event
}
}

View file

@ -15,7 +15,6 @@ import (
type Model struct {
*tview.TextView
app *tview.Application
conn *websocket.Conn
heartbeatInterval time.Duration
@ -26,18 +25,14 @@ type Model struct {
msg string
}
func NewModel(app *tview.Application) *Model {
func NewModel() *Model {
m := &Model{
TextView: tview.NewTextView(),
app: app,
}
m.
SetScrollable(true).
SetWrap(false).
SetTextAlign(tview.AlignmentCenter).
SetChangedFunc(func() {
m.app.QueueUpdateDraw(func() {})
})
SetTextAlign(tview.AlignmentCenter)
m.msg = "Press Ctrl+N to open QR login"
return m
@ -57,7 +52,7 @@ func (m *Model) HandleEvent(event tcell.Event) tview.Command {
case *tview.KeyEvent:
if event.Key() == tcell.KeyEsc {
m.msg = "Canceled"
return tview.BatchCommand{m.close(), tview.RedrawCommand{}}
return tview.Batch(m.close(), nil)
}
return m.TextView.HandleEvent(event)
@ -71,21 +66,21 @@ func (m *Model) HandleEvent(event tcell.Event) tview.Command {
case *helloEvent:
m.heartbeatInterval = time.Duration(event.heartbeatInterval) * time.Millisecond
return tview.BatchCommand{m.listen(), m.heartbeat(), m.generatePrivateKey()}
return tview.Batch(m.listen(), m.heartbeat(), m.generatePrivateKey())
case *privateKeyEvent:
m.privateKey = event.privateKey
return tview.BatchCommand{m.listen(), m.sendInit()}
return tview.Batch(m.listen(), m.sendInit())
case *nonceProofEvent:
return tview.BatchCommand{m.listen(), m.sendNonceProof(event.encryptedNonce)}
return tview.Batch(m.listen(), m.sendNonceProof(event.encryptedNonce))
case *pendingRemoteInitEvent:
m.fingerprint = event.fingerprint
return tview.BatchCommand{m.listen(), m.generateQRCode(event.fingerprint)}
return tview.Batch(m.listen(), m.generateQRCode(event.fingerprint))
case *qrCodeEvent:
m.qrCode = event.qrCode
m.msg = "Scan this with the Discord mobile app to log in instantly."
return m.listen()
case *pendingTicketEvent:
return tview.BatchCommand{m.listen(), m.decryptUserPayload(event.encryptedUserPayload)}
return tview.Batch(m.listen(), m.decryptUserPayload(event.encryptedUserPayload))
case *userEvent:
name := event.username
if event.discriminator != "0" {
@ -95,7 +90,7 @@ func (m *Model) HandleEvent(event tcell.Event) tview.Command {
return m.listen()
case *pendingLoginEvent:
m.msg = "Authenticating..."
return tview.BatchCommand{m.close(), m.exchangeTicket(event.ticket)}
return tview.Batch(m.close(), m.exchangeTicket(event.ticket))
case *cancelEvent:
m.msg = "Login canceled on mobile"
return m.close()
@ -104,11 +99,11 @@ func (m *Model) HandleEvent(event tcell.Event) tview.Command {
if m.conn == nil {
return nil
}
return tview.BatchCommand{m.heartbeat(), m.sendHeartbeat()}
return tview.Batch(m.heartbeat(), m.sendHeartbeat())
case *tcell.EventError:
m.msg = event.Error()
return tview.BatchCommand{m.close(), event}
return tview.Batch(m.close(), tview.Command(func() tcell.Event { return event }))
}
return nil

View file

@ -10,14 +10,10 @@ type TokenEvent struct {
Token string
}
func newTokenEvent(token string) *TokenEvent {
event := &TokenEvent{Token: token}
event.SetEventNow()
return event
}
func tokenCommand(token string) tview.Command {
return tview.EventCommand(func() tcell.Event {
return newTokenEvent(token)
})
return func() tcell.Event {
event := &TokenEvent{Token: token}
event.SetEventNow()
return event
}
}

View file

@ -14,63 +14,57 @@ type tokenEvent struct {
token string
}
func newTokenEvent(token string) *tokenEvent {
event := &tokenEvent{token: token}
event.SetEventNow()
return event
}
func tokenCommand(token string) tview.Command {
return tview.EventCommand(func() tcell.Event {
return newTokenEvent(token)
})
return func() tcell.Event {
event := &tokenEvent{token: token}
event.SetEventNow()
return event
}
}
type loginEvent struct{ tcell.EventTime }
func newLoginEvent() *loginEvent {
event := &loginEvent{}
event.SetEventNow()
return event
}
func getToken() tview.Command {
return tview.EventCommand(func() tcell.Event {
return func() tcell.Event {
token, err := keyring.GetToken()
if err != nil {
slog.Info("failed to retrieve token from keyring", "err", err)
return newLoginEvent()
event := &loginEvent{}
event.SetEventNow()
return event
}
return newTokenEvent(token)
})
event := &tokenEvent{token: token}
event.SetEventNow()
return event
}
}
func setToken(token string) tview.Command {
return tview.EventCommand(func() tcell.Event {
return func() tcell.Event {
if err := keyring.SetToken(token); err != nil {
slog.Error("failed to set token to keyring", "err", err)
return tcell.NewEventError(err)
}
return nil
})
}
}
func deleteToken() tview.Command {
return tview.EventCommand(func() tcell.Event {
return func() tcell.Event {
if err := keyring.DeleteToken(); err != nil {
slog.Error("failed to delete token from keyring", "err", err)
return tcell.NewEventError(err)
}
return nil
})
}
}
func initClipboard() tview.Command {
return tview.EventCommand(func() tcell.Event {
return func() tcell.Event {
if err := clipboard.Init(); err != nil {
slog.Error("failed to init clipboard", "err", err)
return tcell.NewEventError(err)
}
return nil
})
}
}

View file

@ -53,15 +53,15 @@ func NewModel(cfg *config.Config, app *tview.Application) *Model {
}
func (m *Model) showLogin() tview.Command {
m.inner = login.NewModel(m.app, m.cfg)
m.inner = login.NewModel(m.cfg)
m.buildLayout()
return tview.BatchCommand{m.inner.HandleEvent(tview.NewInitEvent()), tview.SetFocusCommand{Target: m}}
return tview.Batch(m.inner.HandleEvent(tview.NewInitEvent()), tview.SetFocus(m))
}
func (m *Model) showChat(token string) tview.Command {
m.inner = chat.NewView(m.app, m.cfg, token)
m.buildLayout()
return tview.BatchCommand{m.inner.HandleEvent(tview.NewInitEvent()), tview.SetFocusCommand{Target: m}}
return tview.Batch(m.inner.HandleEvent(tview.NewInitEvent()), tview.SetFocus(m))
}
func (m *Model) buildLayout() {
@ -88,11 +88,11 @@ func (m *Model) HandleEvent(event tcell.Event) tview.Command {
} else {
cmd = getToken()
}
return tview.BatchCommand{
return tview.Batch(
tview.SetTitle(consts.Name),
initClipboard(),
cmd,
}
)
case *loginEvent:
return m.showLogin()
@ -100,22 +100,22 @@ func (m *Model) HandleEvent(event tcell.Event) tview.Command {
return m.showChat(event.token)
case *token.TokenEvent:
return tview.BatchCommand{m.showChat(event.Token), setToken(event.Token)}
return tview.Batch(m.showChat(event.Token), setToken(event.Token))
case *qr.TokenEvent:
return tview.BatchCommand{m.showChat(event.Token), setToken(event.Token)}
return tview.Batch(m.showChat(event.Token), setToken(event.Token))
case *chat.LogoutEvent:
return tview.BatchCommand{
return tview.Batch(
m.showLogin(),
deleteToken(),
}
)
case *tview.KeyEvent:
switch {
case keybind.Matches(event, m.cfg.Keybinds.ToggleHelp.Keybind):
m.help.SetShowAll(!m.help.ShowAll())
m.updateHelpHeight()
return tview.RedrawCommand{}
return nil
case keybind.Matches(event, m.cfg.Keybinds.Suspend.Keybind):
m.suspend()
return nil
@ -124,7 +124,7 @@ func (m *Model) HandleEvent(event tcell.Event) tview.Command {
if m.inner != nil {
innerCmd = m.inner.HandleEvent(chat.NewQuitEvent())
}
return tview.BatchCommand{innerCmd, tview.Quit()}
return tview.Batch(innerCmd, tview.Quit())
}
}

View file

@ -151,29 +151,28 @@ func (p *Picker) onInputChanged(text string) {
func (p *Picker) HandleEvent(event tcell.Event) tview.Command {
switch event := event.(type) {
case *tview.KeyEvent:
redraw := tview.RedrawCommand{}
if p.keyMap != nil {
switch {
case keybind.Matches(event, p.keyMap.Up):
p.list.HandleEvent(tcell.NewEventKey(tcell.KeyUp, "", tcell.ModNone))
return redraw
return nil
case keybind.Matches(event, p.keyMap.Down):
p.list.HandleEvent(tcell.NewEventKey(tcell.KeyDown, "", tcell.ModNone))
return redraw
return nil
case keybind.Matches(event, p.keyMap.Top):
p.list.HandleEvent(tcell.NewEventKey(tcell.KeyHome, "", tcell.ModNone))
return redraw
return nil
case keybind.Matches(event, p.keyMap.Bottom):
p.list.HandleEvent(tcell.NewEventKey(tcell.KeyEnd, "", tcell.ModNone))
return redraw
return nil
case keybind.Matches(event, p.keyMap.Select):
p.onListSelected(p.list.Cursor())
return redraw
return nil
case keybind.Matches(event, p.keyMap.Cancel):
if p.onCancel != nil {
p.onCancel()
}
return redraw
return nil
}
}