package cmd import ( "fmt" "log/slog" "strings" "github.com/ayn2op/discordo/internal/config" "github.com/ayn2op/discordo/internal/keyring" "github.com/ayn2op/discordo/internal/login" "github.com/ayn2op/discordo/internal/ui" "github.com/ayn2op/tview" "github.com/gdamore/tcell/v2" "golang.design/x/clipboard" ) const ( flexPageName = "flex" mentionsListPageName = "mentionsList" attachmentsListPageName = "attachmentsList" modalPageName = "modalPageName" ) type application struct { cfg *config.Config *tview.Application pages *tview.Pages flex *tview.Flex guildsTree *guildsTree messagesList *messagesList messageInput *messageInput } func newApplication(cfg *config.Config) *application { app := &application{ cfg: cfg, Application: tview.NewApplication(), pages: tview.NewPages(), flex: tview.NewFlex(), guildsTree: newGuildsTree(cfg), messagesList: newMessagesList(cfg), messageInput: newMessageInput(cfg), } if err := clipboard.Init(); err != nil { app.onError("Failed to init clipboard", err) } app.pages.SetInputCapture(app.onPagesInputCapture) app. EnableMouse(cfg.Mouse). SetInputCapture(app.onInputCapture). EnablePaste(true) return app } func (a *application) run(token string) error { if token == "" { loginForm := login.NewForm(a.Application, a.cfg, func(token string) { if err := a.run(token); err != nil { slog.Error("failed to run application", "err", err) } }) return a.SetRoot(loginForm, true).Run() } if err := openState(token); err != nil { return err } a.init() return a.SetRoot(a.pages, true).Run() } func (a *application) quit() { if discordState != nil { if err := discordState.Close(); err != nil { slog.Error("failed to close the session", "err", err) } } a.Stop() } func (a *application) init() { a.pages.Clear() a.flex.Clear() right := tview.NewFlex(). SetDirection(tview.FlexRow). AddItem(a.messagesList, 0, 1, false). AddItem(a.messageInput, 3, 1, false) // The guilds tree is always focused first at start-up. a.flex. AddItem(a.guildsTree, 0, 1, true). AddItem(right, 0, 4, false) a.pages.AddAndSwitchToPage(flexPageName, a.flex, true) } func (a *application) onInputCapture(event *tcell.EventKey) *tcell.EventKey { switch event.Name() { case a.cfg.Keys.Quit: a.quit() return nil case "Ctrl+C": // https://github.com/ayn2op/tview/blob/a64fc48d7654432f71922c8b908280cdb525805c/application.go#L153 return tcell.NewEventKey(tcell.KeyCtrlC, 0, tcell.ModNone) } return event } func (a *application) onPagesInputCapture(event *tcell.EventKey) *tcell.EventKey { switch event.Name() { case a.cfg.Keys.FocusGuildsTree: a.messageInput.removeMentionsList() a.focusGuildsTree() return nil case a.cfg.Keys.FocusMessagesList: a.messageInput.removeMentionsList() a.SetFocus(a.messagesList) return nil case a.cfg.Keys.FocusMessageInput: a.focusMessageInput() return nil case a.cfg.Keys.FocusPrevious: a.focusPrevious() return nil case a.cfg.Keys.FocusNext: a.focusNext() return nil case a.cfg.Keys.Logout: a.quit() if err := keyring.DeleteToken(); err != nil { a.onError("Failed to delete token from keyring", err) return nil } return nil case a.cfg.Keys.ToggleGuildsTree: a.toggleGuildsTree() return nil } return event } func (a *application) toggleGuildsTree() { // The guilds tree is visible if the number of items is two. if a.flex.GetItemCount() == 2 { a.flex.RemoveItem(a.guildsTree) if a.guildsTree.HasFocus() { a.SetFocus(a.flex) } } else { a.init() a.SetFocus(a.guildsTree) } } func (a *application) focusGuildsTree() bool { // The guilds tree is not hidden if the number of items is two. if a.flex.GetItemCount() == 2 { a.SetFocus(a.guildsTree) return true } return false } func (a *application) focusMessageInput() bool { if !a.messageInput.GetDisabled() { a.SetFocus(a.messageInput) return true } return false } func (a *application) focusPrevious() { switch a.GetFocus() { case a.guildsTree: a.SetFocus(a.messageInput) case a.messagesList: // Handle both a.messagesList and a.flex as well as other edge cases (if there is). if ok := a.focusGuildsTree(); !ok { a.SetFocus(a.messageInput) } case a.messageInput: a.SetFocus(a.messagesList) } } func (a *application) focusNext() { switch a.GetFocus() { case a.guildsTree: a.SetFocus(a.messagesList) case a.messagesList: a.SetFocus(a.messageInput) case a.messageInput: // Handle both a.messageInput and a.flex as well as other edge cases (if there is). if ok := a.focusGuildsTree(); !ok { a.SetFocus(a.messagesList) } } } func (a *application) onError(msg string, err error, info ...any) { slog.Error(msg, append(info, "err", err)...) a.showErrorModal(msg, err.Error(), info...) } func (a *application) showModal(title, prompt string, buttons []string, onDone func(label string)) { previousFocus := a.GetFocus() modal := tview.NewModal() modal.Box = ui.ConfigureBox(modal.Box, &a.cfg.Theme) modal. SetText(prompt). AddButtons(buttons). SetDoneFunc(func(_ int, buttonLabel string) { a.pages. RemovePage(modalPageName). SwitchToPage(flexPageName) a.SetFocus(previousFocus) if onDone != nil { onDone(buttonLabel) } }). SetTitle(title). SetTitleAlignment(tview.AlignmentCenter) a.pages. AddAndSwitchToPage(modalPageName, ui.Centered(modal, 0, 0), true). ShowPage(flexPageName) a.SetFocus(modal) } func (a *application) showConfirmModal(prompt string, buttons []string, onDone func(label string)) { a.showModal("", prompt, buttons, onDone) } func (a *application) showErrorModal(msg, err string, info ...any) { a.showModal("[ ERROR ]", msg+"\nReason: "+err, []string{"Copy", "OK"}, func(label string) { if label != "Copy" { return } res := &strings.Builder{} fmt.Fprintf(res, "%s\nReason: %s", msg, err) for i := 0; i < len(info)-1; i += 2 { fmt.Fprintf(res, "\n%v: %#v", info[i], info[i+1]) } // Do as log/slog does. "!BADKEY" for last odd argument. if (len(info) % 2) == 1 { fmt.Fprintf(res, "\n!BADKEY: %#v", info[len(info)-1]) } go clipboard.Write(clipboard.FmtText, []byte(res.String())) }) } func (a *application) showInfoModal(text string) { a.showModal("[ INFO ]", text, []string{"OK"}, nil) }