diff --git a/go.mod b/go.mod index 1611918..74af756 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/andybalholm/brotli v1.2.2 github.com/ayn2op/arikawa/v3 v3.0.0-20260708075022-9cc760fe507e github.com/ayn2op/ningen/v3 v3.0.1-0.20260708075321-0ba1ffc70bd6 - github.com/ayn2op/tview v0.0.0-20260723005648-f29aace80bb2 + github.com/ayn2op/tview v0.0.0-20260730175844-eae642abc285 github.com/deckarep/gosx-notifier v0.0.0-20180201035817-e127226297fb github.com/gdamore/tcell/v3 v3.4.1 github.com/gen2brain/beeep v0.11.2 diff --git a/go.sum b/go.sum index b49aac1..22e932b 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,8 @@ github.com/ayn2op/arikawa/v3 v3.0.0-20260708075022-9cc760fe507e h1:rsMifRXwxkbha github.com/ayn2op/arikawa/v3 v3.0.0-20260708075022-9cc760fe507e/go.mod h1:gqklpHK7WHYTzpd1ZY6Ho/p9GURBghzBCzRiBYpaUP8= github.com/ayn2op/ningen/v3 v3.0.1-0.20260708075321-0ba1ffc70bd6 h1:/khHm6G+GWwXpmwA8bJoaXoUhubqMEUG6spjZsnklIw= github.com/ayn2op/ningen/v3 v3.0.1-0.20260708075321-0ba1ffc70bd6/go.mod h1:vqLIvETLkjc2vIgQzVEBqpGNCrgNe7YJqS9TUAJsXRI= -github.com/ayn2op/tview v0.0.0-20260723005648-f29aace80bb2 h1:tAZcwU2ZM4zSndksuFkhqAQKmmAJ+wXYSZWCatrUdQ0= -github.com/ayn2op/tview v0.0.0-20260723005648-f29aace80bb2/go.mod h1:bJZdl/FfspPLEW3nuX6ubt7YbmDoSj7f2IoWH37w1NE= +github.com/ayn2op/tview v0.0.0-20260730175844-eae642abc285 h1:EaV8lNpt2vWTjc/8nPFtb8S0qA6a6EIsALNy4VYiHww= +github.com/ayn2op/tview v0.0.0-20260730175844-eae642abc285/go.mod h1:bJZdl/FfspPLEW3nuX6ubt7YbmDoSj7f2IoWH37w1NE= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= diff --git a/internal/ui/chat/messages_list.go b/internal/ui/chat/messages_list.go index 8df438c..942ea8d 100644 --- a/internal/ui/chat/messages_list.go +++ b/internal/ui/chat/messages_list.go @@ -908,8 +908,7 @@ func (ml *messagesList) Update(msg tview.Msg) tview.Cmd { case keybind.Matches(msg, ml.cfg.Keybinds.MessagesList.Delete.Keybind): return ml.deleteSelectedMessage() case keybind.Matches(msg, ml.cfg.Keybinds.MessagesList.DeleteConfirm.Keybind): - ml.confirmDelete() - return nil + return ml.confirmDelete() } return ml.Model.Update(msg) @@ -1294,19 +1293,16 @@ func (ml *messagesList) editSelectedMessage() tview.Cmd { return tview.SetFocus(ml.chat.composer) } -func (ml *messagesList) confirmDelete() { - onChoice := func(choice string) { - if choice == "Yes" { - if cmd := ml.deleteSelectedMessage(); cmd != nil { - cmd() - } - } +func (ml *messagesList) confirmDelete() tview.Cmd { + selectedMessage, ok := ml.selectedMessage() + if !ok { + return nil } - - ml.chat.showConfirmDialog( + message := *selectedMessage + return ui.ShowModal( "Are you sure you want to delete this message?", - []string{"Yes", "No"}, - onChoice, + ui.ModalButton{Label: "Yes", Result: deleteMessageMsg(message)}, + ui.ModalButton{Label: "No"}, ) } @@ -1315,22 +1311,25 @@ func (ml *messagesList) deleteSelectedMessage() tview.Cmd { if !ok { return nil } + return ml.deleteMessageRequest(*selectedMessage) +} +func (ml *messagesList) deleteMessageRequest(message discord.Message) tview.Cmd { return func() tview.Msg { - if selectedMessage.GuildID.IsValid() { - if !ml.chat.isMe(selectedMessage.Author.ID) && !ml.chat.state.HasPermissions(selectedMessage.ChannelID, discord.PermissionManageMessages) { - slog.Error("failed to delete message; missing relevant permissions", "channel_id", selectedMessage.ChannelID, "message_id", selectedMessage.ID) + if message.GuildID.IsValid() { + if !ml.chat.isMe(message.Author.ID) && !ml.chat.state.HasPermissions(message.ChannelID, discord.PermissionManageMessages) { + slog.Error("failed to delete message; missing relevant permissions", "channel_id", message.ChannelID, "message_id", message.ID) return nil } } - if err := ml.chat.state.DeleteMessage(selectedMessage.ChannelID, selectedMessage.ID, ""); err != nil { - slog.Error("failed to delete message", "channel_id", selectedMessage.ChannelID, "message_id", selectedMessage.ID, "err", err) + if err := ml.chat.state.DeleteMessage(message.ChannelID, message.ID, ""); err != nil { + slog.Error("failed to delete message", "channel_id", message.ChannelID, "message_id", message.ID, "err", err) return nil } - if err := ml.chat.state.MessageRemove(selectedMessage.ChannelID, selectedMessage.ID); err != nil { - slog.Error("failed to delete message", "channel_id", selectedMessage.ChannelID, "message_id", selectedMessage.ID, "err", err) + if err := ml.chat.state.MessageRemove(message.ChannelID, message.ID); err != nil { + slog.Error("failed to delete message", "channel_id", message.ChannelID, "message_id", message.ID, "err", err) return nil } return nil diff --git a/internal/ui/chat/model.go b/internal/ui/chat/model.go index 8845eda..8299721 100644 --- a/internal/ui/chat/model.go +++ b/internal/ui/chat/model.go @@ -21,7 +21,6 @@ import ( "github.com/ayn2op/ningen/v3/states/read" "github.com/ayn2op/tview" "github.com/ayn2op/tview/flex" - "github.com/ayn2op/tview/frame" "github.com/ayn2op/tview/keybind" "github.com/ayn2op/tview/layers" "github.com/gdamore/tcell/v3" @@ -30,9 +29,8 @@ import ( const typingDuration = 10 * time.Second const ( - flexLayerName = "flex" - mentionsListLayerName = "mentionsList" - confirmDialogLayerName = "confirmDialog" + flexLayerName = "flex" + mentionsListLayerName = "mentionsList" channelsPickerLayerName = "channelsPicker" attachmentsPickerLayerName = "attachmentsPicker" @@ -54,9 +52,6 @@ type Model struct { selectedChannel *discord.Channel selectedChannelMu sync.RWMutex - confirmDialogDone func(label string) - confirmDialogPreviousFocus tview.Model - state *ningen.State events chan gateway.Event @@ -327,38 +322,10 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd { return tview.Batch(focusCmd, m.messagesList.requestGuildMembers(msg.Channel.GuildID, msg.Messages)) } return focusCmd + case deleteMessageMsg: + return m.messagesList.deleteMessageRequest(discord.Message(msg)) case QuitMsg: return m.closeState() - case tview.FormSubmitMsg: - if m.HasLayer(confirmDialogLayerName) { - m.RemoveLayer(confirmDialogLayerName) - var focusCmd tview.Cmd - if m.confirmDialogPreviousFocus != nil { - focusCmd = tview.SetFocus(m.confirmDialogPreviousFocus) - } - onDone := m.confirmDialogDone - m.confirmDialogDone = nil - m.confirmDialogPreviousFocus = nil - if onDone != nil { - onDone(msg.ButtonLabel) - } - return focusCmd - } - case tview.FormCancelMsg: - if m.HasLayer(confirmDialogLayerName) { - m.RemoveLayer(confirmDialogLayerName) - var focusCmd tview.Cmd - if m.confirmDialogPreviousFocus != nil { - focusCmd = tview.SetFocus(m.confirmDialogPreviousFocus) - } - onDone := m.confirmDialogDone - m.confirmDialogDone = nil - m.confirmDialogPreviousFocus = nil - if onDone != nil { - onDone("") - } - return focusCmd - } case tview.KeyMsg: switch { case keybind.Matches(msg, m.cfg.Keybinds.FocusGuildsTree.Keybind): @@ -392,29 +359,6 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd { return m.Layers.Update(msg) } -func (m *Model) showConfirmDialog(prompt string, buttons []string, onDone func(label string)) { - m.confirmDialogPreviousFocus = m.app.Focused() - m.confirmDialogDone = onDone - - form := tview.NewForm().SetButtonsAlignment(tview.AlignmentCenter) - for _, button := range buttons { - form.AddButton(button) - } - dialog := frame.NewModel(form).SetBorders(0, 0, 1, 0, 0, 0) - for _, line := range tview.WordWrap(prompt, 80) { - dialog.AddText(line, true, tview.AlignmentCenter, tview.Styles.PrimaryTextColor) - } - m. - AddLayer( - ui.Centered(dialog, 0, 0), - layers.WithName(confirmDialogLayerName), - layers.WithResize(true), - layers.WithVisible(true), - layers.WithOverlay(), - ). - SendToFront(confirmDialogLayerName) -} - func (m *Model) clearTypers() { m.typersMu.Lock() for _, timer := range m.typers { diff --git a/internal/ui/chat/msg.go b/internal/ui/chat/msg.go index df96f47..716bd1c 100644 --- a/internal/ui/chat/msg.go +++ b/internal/ui/chat/msg.go @@ -46,6 +46,8 @@ type olderMessagesLoadedMsg struct { Older []discord.Message } +type deleteMessageMsg discord.Message + type LogoutMsg struct{} func (m *Model) logout() tview.Cmd { diff --git a/internal/ui/login/model.go b/internal/ui/login/model.go index 932807c..15642ac 100644 --- a/internal/ui/login/model.go +++ b/internal/ui/login/model.go @@ -3,10 +3,8 @@ package login import ( "log/slog" - "github.com/ayn2op/tview/frame" "github.com/ayn2op/tview/layers" "github.com/ayn2op/tview/tabs" - "github.com/gdamore/tcell/v3" "github.com/ayn2op/discordo/internal/config" "github.com/ayn2op/discordo/internal/ui" @@ -16,16 +14,12 @@ import ( ) const ( - tabsLayerName = "tabs" - errorLayerName = "error" + tabsLayerName = "tabs" ) type Model struct { *layers.Layers tabs *tabs.Model - - cfg *config.Config - errorDialogText string } func NewModel(cfg *config.Config) *Model { @@ -38,79 +32,24 @@ func NewModel(cfg *config.Config) *Model { return &Model{ Layers: l, tabs: tabs, - cfg: cfg, } } func (m *Model) Update(msg tview.Msg) tview.Cmd { switch msg := msg.(type) { case errMsg: - if m.HasLayer(errorLayerName) { - return nil - } return m.showErrorDialog(msg.err) - case tview.FormSubmitMsg: - if !m.HasLayer(errorLayerName) { - break - } - if msg.ButtonIndex == 0 { - return setClipboard(m.errorDialogText) - } - m.RemoveLayer(errorLayerName) - m.errorDialogText = "" - return nil - case tview.FormCancelMsg: - if !m.HasLayer(errorLayerName) { - return nil - } - m.RemoveLayer(errorLayerName) - m.errorDialogText = "" - return nil + case copyErrorMsg: + return setClipboard(string(msg)) } return m.Layers.Update(msg) } func (m *Model) showErrorDialog(err error) tview.Cmd { slog.Error("failed to login", "err", err) - message := err.Error() - m.errorDialogText = message - form := tview.NewForm(). - SetButtonsAlignment(tview.AlignmentCenter). - AddButton("Copy"). - AddButton("Close") - dialog := frame.NewModel(form).SetBorders(0, 0, 1, 0, 0, 0) - for _, line := range tview.WordWrap(message, 80) { - dialog.AddText(line, true, tview.AlignmentCenter, tview.Styles.PrimaryTextColor) - } - { - bg := m.cfg.Theme.Dialog.Style.GetBackground() - buttonStyle := m.cfg.Theme.Dialog.Style.Style - if bg != tcell.ColorDefault { - form.SetBackgroundColor(bg) - dialog.SetBackgroundColor(bg) - buttonStyle = buttonStyle.Background(bg) - } - fg := m.cfg.Theme.Dialog.Style.GetForeground() - if fg != tcell.ColorDefault { - dialog.Clear() - for _, line := range tview.WordWrap(message, 80) { - dialog.AddText(line, true, tview.AlignmentCenter, fg) - } - buttonStyle = buttonStyle.Foreground(fg) - } - // Keep button styles aligned with dialog content and still show focus. - form.SetButtonStyle(buttonStyle) - form.SetButtonActivatedStyle(buttonStyle.Reverse(true)) - } - m. - AddLayer( - ui.Centered(dialog, 0, 0), - layers.WithName(errorLayerName), - layers.WithResize(true), - layers.WithVisible(true), - layers.WithOverlay(), - ). - SendToFront(errorLayerName) - return tview.SetFocus(dialog) + return ui.ShowModal(message, + ui.ModalButton{Label: "Copy", Result: copyErrorMsg(message), KeepOpen: true}, + ui.ModalButton{Label: "Close"}, + ) } diff --git a/internal/ui/login/msg.go b/internal/ui/login/msg.go index dac201e..364d157 100644 --- a/internal/ui/login/msg.go +++ b/internal/ui/login/msg.go @@ -9,6 +9,8 @@ type errMsg struct { err error } +type copyErrorMsg string + func setClipboard(content string) tview.Cmd { return func() tview.Msg { _ = clipboard.Write(clipboard.FmtText, []byte(content)) diff --git a/internal/ui/modal.go b/internal/ui/modal.go new file mode 100644 index 0000000..7f109dc --- /dev/null +++ b/internal/ui/modal.go @@ -0,0 +1,18 @@ +package ui + +import "github.com/ayn2op/tview" + +type ModalButton struct { + Label string + Result tview.Msg + KeepOpen bool +} + +type ModalMsg struct { + Text string + Buttons []ModalButton +} + +func ShowModal(text string, buttons ...ModalButton) tview.Cmd { + return func() tview.Msg { return ModalMsg{Text: text, Buttons: buttons} } +} diff --git a/internal/ui/root/model.go b/internal/ui/root/model.go index 807b115..cf2f651 100644 --- a/internal/ui/root/model.go +++ b/internal/ui/root/model.go @@ -5,6 +5,7 @@ import ( "github.com/ayn2op/discordo/internal/config" "github.com/ayn2op/discordo/internal/consts" + "github.com/ayn2op/discordo/internal/ui" "github.com/ayn2op/discordo/internal/ui/chat" "github.com/ayn2op/discordo/internal/ui/login" "github.com/ayn2op/discordo/internal/ui/login/qr" @@ -13,28 +14,41 @@ import ( "github.com/ayn2op/tview/flex" "github.com/ayn2op/tview/help" "github.com/ayn2op/tview/keybind" + "github.com/ayn2op/tview/layers" + "github.com/ayn2op/tview/modal" "github.com/gdamore/tcell/v3" ) -const tokenEnvVarKey = "DISCORDO_TOKEN" +const ( + tokenEnvVarKey = "DISCORDO_TOKEN" + contentLayerName = "content" + modalLayerName = "modal" +) type Model struct { + *layers.Layers + app *tview.Application rootFlex *flex.Model // inner + help inner tview.Model help *help.Model - cfg *config.Config + modalRequest *ui.ModalMsg + modalDialog *modal.Model + modalPreviousFocus tview.Model + cfg *config.Config } func NewModel(cfg *config.Config, app *tview.Application) *Model { m := &Model{ + Layers: layers.New(), app: app, rootFlex: flex.NewModel(), help: help.NewModel(), cfg: cfg, } + m.SetBackgroundLayerStyle(cfg.Theme.Dialog.BackgroundStyle.Style) m.rootFlex.SetDirection(flex.DirectionRow) @@ -66,20 +80,21 @@ func (m *Model) showChat(token string) tview.Cmd { } func (m *Model) buildLayout() { + m.Clear() + m.modalRequest = nil + m.modalDialog = nil + m.modalPreviousFocus = nil m.rootFlex.Clear() if m.inner != nil { m.rootFlex.AddItem(m.inner, 0, 1, true) } m.rootFlex.AddItem(m.help, 1, 0, false) + m.AddLayer(m.rootFlex, layers.WithName(contentLayerName), layers.WithResize(true), layers.WithVisible(true)) m.updateHelpHeight() } var _ tview.Model = (*Model)(nil) -func (m *Model) View(screen tcell.Screen) { - m.rootFlex.View(screen) -} - func (m *Model) Update(msg tview.Msg) tview.Cmd { switch msg := msg.(type) { case tview.InitMsg: @@ -110,8 +125,18 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd { m.showLogin(), deleteToken(), ) + case ui.ModalMsg: + return m.showModal(msg) + case modal.DoneMsg: + return m.finishModal(msg) case tview.KeyMsg: + if m.modalRequest != nil { + if !m.modalDialog.HasFocus() { + return tview.Sequence(tview.SetFocus(m.modalDialog), func() tview.Msg { return msg }) + } + return m.Layers.Update(msg) + } switch { case keybind.Matches(msg, m.cfg.Keybinds.ToggleHelp.Keybind): m.help.SetShowAll(!m.help.ShowAll()) @@ -127,6 +152,10 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd { } return tview.Sequence(innerCmd, tview.Quit()) } + case tview.MouseMsg, tview.PasteMsg, tview.FormSubmitMsg, tview.FormCancelMsg, tview.ButtonExitMsg: + if m.modalRequest != nil { + return m.Layers.Update(msg) + } } if m.inner != nil { @@ -135,6 +164,66 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd { return nil } +func (m *Model) showModal(request ui.ModalMsg) tview.Cmd { + if m.modalRequest != nil { + return nil + } + + labels := make([]string, len(request.Buttons)) + for i, button := range request.Buttons { + labels[i] = button.Label + } + dialog := modal.NewModel().SetText(request.Text).AddButtons(labels) + style := m.cfg.Theme.Dialog.Style.Style + if bg := style.GetBackground(); bg != tcell.ColorDefault { + dialog.SetBackgroundColor(bg) + } + if fg := style.GetForeground(); fg != tcell.ColorDefault { + dialog.SetTextColor(fg) + } + dialog.SetButtonStyle(style).SetButtonActivatedStyle(style.Reverse(true)) + + m.modalRequest = &request + m.modalDialog = dialog + m.modalPreviousFocus = m.app.Focused() + m.AddLayer(dialog, + layers.WithName(modalLayerName), + layers.WithResize(true), + layers.WithVisible(true), + layers.WithOverlay(), + ) + return nil +} + +func (m *Model) finishModal(done modal.DoneMsg) tview.Cmd { + request := m.modalRequest + if request == nil { + return nil + } + + var result tview.Msg + if done.ButtonIndex >= 0 && done.ButtonIndex < len(request.Buttons) { + button := request.Buttons[done.ButtonIndex] + if button.KeepOpen { + return func() tview.Msg { return button.Result } + } + result = button.Result + } + + m.RemoveLayer(modalLayerName) + focus := tview.Cmd(nil) + if m.modalPreviousFocus != nil { + focus = tview.SetFocus(m.modalPreviousFocus) + } + m.modalRequest = nil + m.modalDialog = nil + m.modalPreviousFocus = nil + if result == nil { + return focus + } + return tview.Sequence(focus, func() tview.Msg { return result }) +} + func (m *Model) updateHelpHeight() { height := 1 if m.help.ShowAll() { @@ -142,30 +231,3 @@ func (m *Model) updateHelpHeight() { } m.rootFlex.ResizeItem(m.help, height, 0) } - -func (m *Model) Rect() (int, int, int, int) { - return m.rootFlex.Rect() -} - -func (m *Model) SetRect(x int, y int, width int, height int) { - m.rootFlex.SetRect(x, y, width, height) -} - -func (m *Model) Focus(delegate func(tview.Model)) { - if m.inner != nil { - delegate(m.inner) - } -} - -func (m *Model) HasFocus() bool { - if m.inner != nil { - return m.inner.HasFocus() - } - return true -} - -func (m *Model) Blur() { - if m.inner != nil { - m.inner.Blur() - } -}