fix(ui): use dedicated modal model (#821)

This commit is contained in:
ayn2op 2026-07-30 14:32:05 -04:00 • committed by GitHub
parent 226936310d
commit d13ce0ea4a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 150 additions and 184 deletions

2
go.mod
View file

@ -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

4
go.sum
View file

@ -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=

View file

@ -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

View file

@ -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 {

View file

@ -46,6 +46,8 @@ type olderMessagesLoadedMsg struct {
Older []discord.Message
}
type deleteMessageMsg discord.Message
type LogoutMsg struct{}
func (m *Model) logout() tview.Cmd {

View file

@ -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"},
)
}

View file

@ -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))

18
internal/ui/modal.go Normal file
View file

@ -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} }
}

View file

@ -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()
}
}