浏览代码

refactor(ui): modularize UI widgets

ayntgl 4 年之前
父节点
当前提交
ff5cbf7216
共有 8 个文件被更改,包括 564 次插入507 次删除
  1. 6 10
      main.go
  2. 62 18
      ui/app.go
  3. 65 0
      ui/channels.go
  4. 111 0
      ui/guilds.go
  5. 0 392
      ui/handlers.go
  6. 28 0
      ui/login.go
  7. 292 0
      ui/messages.go
  8. 0 87
      ui/widgets.go

+ 6 - 10
main.go

@@ -13,7 +13,6 @@ const keyringServiceName = "discordo"
 
 func main() {
 	app := ui.NewApp()
-	app.EnableMouse(app.Config.General.Mouse)
 
 	token := os.Getenv("DISCORDO_TOKEN")
 	if token == "" {
@@ -26,9 +25,8 @@ func main() {
 			panic(err)
 		}
 
-		app.
-			SetRoot(ui.NewMainFlex(app), true).
-			SetFocus(app.GuildsList)
+		app.DrawMainFlex()
+		app.SetFocus(app.GuildsList)
 	} else {
 		loginForm := ui.NewLoginForm(false)
 		loginForm.AddButton("Login", func() {
@@ -50,9 +48,8 @@ func main() {
 					panic(err)
 				}
 
-				app.
-					SetRoot(ui.NewMainFlex(app), true).
-					SetFocus(app.GuildsList)
+				app.DrawMainFlex()
+				app.SetFocus(app.GuildsList)
 
 				go keyring.Set("discordo", "token", lr.Token)
 			} else {
@@ -74,9 +71,8 @@ func main() {
 						panic(err)
 					}
 
-					app.
-						SetRoot(ui.NewMainFlex(app), true).
-						SetFocus(app.GuildsList)
+					app.DrawMainFlex()
+					app.SetFocus(app.GuildsList)
 
 					go keyring.Set(keyringServiceName, "token", lr.Token)
 				})

+ 62 - 18
ui/app.go

@@ -6,16 +6,17 @@ import (
 
 	"github.com/ayntgl/discordgo"
 	"github.com/ayntgl/discordo/config"
+	"github.com/gdamore/tcell/v2"
 	"github.com/rivo/tview"
 )
 
 type App struct {
 	*tview.Application
 	MainFlex          *tview.Flex
-	GuildsList        *tview.List
-	ChannelsTreeView  *tview.TreeView
-	MessagesTextView  *tview.TextView
-	MessageInputField *tview.InputField
+	GuildsList        *GuildsList
+	ChannelsTreeView  *ChannelsTreeView
+	MessagesTextView  *MessagesTextView
+	MessageInputField *MessageInputField
 	Session           *discordgo.Session
 	SelectedChannel   *discordgo.Channel
 	Config            *config.Config
@@ -23,20 +24,24 @@ type App struct {
 }
 
 func NewApp() *App {
-	s, _ := discordgo.New()
-
-	return &App{
-		Application:       tview.NewApplication(),
-		MainFlex:          tview.NewFlex(),
-		GuildsList:        tview.NewList(),
-		ChannelsTreeView:  tview.NewTreeView(),
-		MessagesTextView:  tview.NewTextView(),
-		MessageInputField: tview.NewInputField(),
-
-		Session:         s,
-		Config:          config.NewConfig(),
+	app := &App{
+		MainFlex:        tview.NewFlex(),
 		SelectedMessage: -1,
 	}
+
+	app.GuildsList = NewGuildsList(app)
+	app.ChannelsTreeView = NewChannelsTreeView(app)
+	app.MessagesTextView = NewMessagesTextView(app)
+	app.MessageInputField = NewMessageInputField(app)
+
+	app.Session, _ = discordgo.New()
+	app.Config = config.NewConfig()
+
+	app.Application = tview.NewApplication()
+	app.EnableMouse(app.Config.General.Mouse)
+	app.SetInputCapture(app.onInputCapture)
+
+	return app
 }
 
 func (app *App) Connect(token string) error {
@@ -59,12 +64,51 @@ func (app *App) Connect(token string) error {
 
 	app.Session.Token = token
 	app.Session.Identify.Token = token
-	app.Session.AddHandler(app.onGuildCreate)
+	app.Session.AddHandler(app.onSessionGuildCreate)
 	app.Session.AddHandler(app.onSessionMessageCreate)
 
 	return app.Session.Open()
 }
 
+func (app *App) onInputCapture(e *tcell.EventKey) *tcell.EventKey {
+	if app.MessageInputField.HasFocus() {
+		return e
+	}
+
+	switch e.Name() {
+	case app.Config.Keybindings.ToggleGuildsList:
+		app.SetFocus(app.GuildsList)
+		return nil
+	case app.Config.Keybindings.ToggleChannelsTreeView:
+		app.SetFocus(app.ChannelsTreeView)
+		return nil
+	case app.Config.Keybindings.ToggleMessagesTextView:
+		app.SetFocus(app.MessagesTextView)
+		return nil
+	case app.Config.Keybindings.ToggleMessageInputField:
+		app.SetFocus(app.MessageInputField)
+		return nil
+	}
+
+	return e
+}
+
+func (app *App) DrawMainFlex() {
+	leftFlex := tview.NewFlex().
+		SetDirection(tview.FlexRow).
+		AddItem(app.GuildsList, 10, 1, false).
+		AddItem(app.ChannelsTreeView, 0, 1, false)
+	rightFlex := tview.NewFlex().
+		SetDirection(tview.FlexRow).
+		AddItem(app.MessagesTextView, 0, 1, false).
+		AddItem(app.MessageInputField, 3, 1, false)
+	app.MainFlex.
+		AddItem(leftFlex, 0, 1, false).
+		AddItem(rightFlex, 0, 4, false)
+
+	app.SetRoot(app.MainFlex, true)
+}
+
 func (app *App) onSessionReady(_ *discordgo.Session, r *discordgo.Ready) {
 	app.GuildsList.AddItem("Direct Messages", "", 0, nil)
 
@@ -87,7 +131,7 @@ func (app *App) onSessionReady(_ *discordgo.Session, r *discordgo.Ready) {
 	}
 }
 
-func (app *App) onGuildCreate(_ *discordgo.Session, g *discordgo.GuildCreate) {
+func (app *App) onSessionGuildCreate(_ *discordgo.Session, g *discordgo.GuildCreate) {
 	app.GuildsList.AddItem(g.Name, "", 0, nil)
 }
 

+ 65 - 0
ui/channels.go

@@ -0,0 +1,65 @@
+package ui
+
+import (
+	"github.com/ayntgl/discordgo"
+	"github.com/rivo/tview"
+)
+
+type ChannelsTreeView struct {
+	*tview.TreeView
+	app *App
+}
+
+func NewChannelsTreeView(app *App) *ChannelsTreeView {
+	ctv := &ChannelsTreeView{
+		TreeView: tview.NewTreeView(),
+		app:      app,
+	}
+
+	ctv.SetTopLevel(1)
+	ctv.SetRoot(tview.NewTreeNode(""))
+	ctv.SetTitle("Channels")
+	ctv.SetTitleAlign(tview.AlignLeft)
+	ctv.SetBorder(true)
+	ctv.SetBorderPadding(0, 0, 1, 1)
+	ctv.SetSelectedFunc(ctv.onSelected)
+	return ctv
+}
+
+func (ctv *ChannelsTreeView) onSelected(n *tview.TreeNode) {
+	ctv.app.SelectedMessage = -1
+	ctv.app.MessagesTextView.
+		Highlight().
+		Clear()
+	ctv.app.MessageInputField.SetText("")
+
+	c, err := ctv.app.Session.State.Channel(n.GetReference().(string))
+	if err != nil {
+		return
+	}
+
+	if c.Type == discordgo.ChannelTypeGuildCategory {
+		n.SetExpanded(!n.IsExpanded())
+		return
+	}
+
+	ctv.app.SelectedChannel = c
+	ctv.app.SetFocus(ctv.app.MessageInputField)
+
+	go func() {
+		ms, err := ctv.app.Session.ChannelMessages(c.ID, ctv.app.Config.General.FetchMessagesLimit, "", "", "")
+		if err != nil {
+			return
+		}
+
+		for i := len(ms) - 1; i >= 0; i-- {
+			ctv.app.SelectedChannel.Messages = append(ctv.app.SelectedChannel.Messages, ms[i])
+			_, err = ctv.app.MessagesTextView.Write(buildMessage(ctv.app, ms[i]))
+			if err != nil {
+				return
+			}
+		}
+
+		ctv.app.MessagesTextView.ScrollToEnd()
+	}()
+}

+ 111 - 0
ui/guilds.go

@@ -0,0 +1,111 @@
+package ui
+
+import (
+	"sort"
+
+	"github.com/ayntgl/discordgo"
+	"github.com/ayntgl/discordo/discord"
+	"github.com/rivo/tview"
+)
+
+type GuildsList struct {
+	*tview.List
+	app *App
+}
+
+func NewGuildsList(app *App) *GuildsList {
+	gl := &GuildsList{
+		List: tview.NewList(),
+		app:  app,
+	}
+
+	gl.ShowSecondaryText(false)
+	gl.SetTitle("Guilds")
+	gl.SetTitleAlign(tview.AlignLeft)
+	gl.SetBorder(true)
+	gl.SetBorderPadding(0, 0, 1, 1)
+	gl.SetSelectedFunc(gl.onSelected)
+	return gl
+}
+
+func (gl *GuildsList) onSelected(idx int, mainText string, secondaryText string, shortcut rune) {
+	rootTreeNode := gl.app.ChannelsTreeView.GetRoot()
+	rootTreeNode.ClearChildren()
+	gl.app.SelectedMessage = -1
+	gl.app.MessagesTextView.
+		Highlight().
+		Clear()
+	gl.app.MessageInputField.SetText("")
+
+	// If the user is a bot account, the direct messages item does not exist in the guilds list.
+	if gl.app.Session.State.User.Bot && idx == 0 {
+		idx = 1
+	}
+
+	if idx == 0 { // Direct Messages
+		cs := gl.app.Session.State.PrivateChannels
+		sort.Slice(cs, func(i, j int) bool {
+			return cs[i].LastMessageID > cs[j].LastMessageID
+		})
+
+		for _, c := range cs {
+			channelTreeNode := tview.NewTreeNode(discord.ChannelToString(c)).
+				SetReference(c.ID)
+			rootTreeNode.AddChild(channelTreeNode)
+		}
+	} else { // Guild
+		cs := gl.app.Session.State.Guilds[idx-1].Channels
+		sort.Slice(cs, func(i, j int) bool {
+			return cs[i].Position < cs[j].Position
+		})
+
+		for _, c := range cs {
+			if (c.Type == discordgo.ChannelTypeGuildText || c.Type == discordgo.ChannelTypeGuildNews) && (c.ParentID == "") {
+				channelTreeNode := tview.NewTreeNode(discord.ChannelToString(c)).
+					SetReference(c.ID)
+				rootTreeNode.AddChild(channelTreeNode)
+			}
+		}
+
+	CATEGORY:
+		for _, c := range cs {
+			if c.Type == discordgo.ChannelTypeGuildCategory {
+				for _, nestedChannel := range cs {
+					if nestedChannel.ParentID == c.ID {
+						channelTreeNode := tview.NewTreeNode(c.Name).
+							SetReference(c.ID)
+						rootTreeNode.AddChild(channelTreeNode)
+						continue CATEGORY
+					}
+				}
+
+				channelTreeNode := tview.NewTreeNode(c.Name).
+					SetReference(c.ID)
+				rootTreeNode.AddChild(channelTreeNode)
+			}
+		}
+
+		for _, c := range cs {
+			if (c.Type == discordgo.ChannelTypeGuildText || c.Type == discordgo.ChannelTypeGuildNews) && (c.ParentID != "") {
+				var parentTreeNode *tview.TreeNode
+				rootTreeNode.Walk(func(node, _ *tview.TreeNode) bool {
+					if node.GetReference() == c.ParentID {
+						parentTreeNode = node
+						return false
+					}
+
+					return true
+				})
+
+				if parentTreeNode != nil {
+					channelTreeNode := tview.NewTreeNode(discord.ChannelToString(c)).
+						SetReference(c.ID)
+					parentTreeNode.AddChild(channelTreeNode)
+				}
+			}
+		}
+	}
+
+	gl.app.ChannelsTreeView.SetCurrentNode(rootTreeNode)
+	gl.app.SetFocus(gl.app.ChannelsTreeView)
+}

+ 0 - 392
ui/handlers.go

@@ -1,392 +0,0 @@
-package ui
-
-import (
-	"io"
-	"os"
-	"os/exec"
-	"sort"
-	"strings"
-
-	"github.com/atotto/clipboard"
-	"github.com/ayntgl/discordgo"
-	"github.com/ayntgl/discordo/discord"
-	"github.com/gdamore/tcell/v2"
-	"github.com/rivo/tview"
-)
-
-func onAppInputCapture(app *App, e *tcell.EventKey) *tcell.EventKey {
-	if app.MessageInputField.HasFocus() {
-		return e
-	}
-
-	switch e.Name() {
-	case app.Config.Keybindings.ToggleGuildsList:
-		app.SetFocus(app.GuildsList)
-		return nil
-	case app.Config.Keybindings.ToggleChannelsTreeView:
-		app.SetFocus(app.ChannelsTreeView)
-		return nil
-	case app.Config.Keybindings.ToggleMessagesTextView:
-		app.SetFocus(app.MessagesTextView)
-		return nil
-	case app.Config.Keybindings.ToggleMessageInputField:
-		app.SetFocus(app.MessageInputField)
-		return nil
-	}
-
-	return e
-}
-
-func onGuildsListSelected(app *App, guildIdx int) {
-	rootTreeNode := app.ChannelsTreeView.GetRoot()
-	rootTreeNode.ClearChildren()
-	app.SelectedMessage = -1
-	app.MessagesTextView.
-		Highlight().
-		Clear()
-	app.MessageInputField.SetText("")
-
-	// If the user is a bot account, the direct messages item does not exist in the guilds list.
-	if app.Session.State.User.Bot && guildIdx == 0 {
-		guildIdx = 1
-	}
-
-	if guildIdx == 0 { // Direct Messages
-		cs := app.Session.State.PrivateChannels
-		sort.Slice(cs, func(i, j int) bool {
-			return cs[i].LastMessageID > cs[j].LastMessageID
-		})
-
-		for _, c := range cs {
-			channelTreeNode := tview.NewTreeNode(discord.ChannelToString(c)).
-				SetReference(c.ID)
-			rootTreeNode.AddChild(channelTreeNode)
-		}
-	} else { // Guild
-		cs := app.Session.State.Guilds[guildIdx-1].Channels
-		sort.Slice(cs, func(i, j int) bool {
-			return cs[i].Position < cs[j].Position
-		})
-
-		for _, c := range cs {
-			if (c.Type == discordgo.ChannelTypeGuildText || c.Type == discordgo.ChannelTypeGuildNews) && (c.ParentID == "") {
-				channelTreeNode := tview.NewTreeNode(discord.ChannelToString(c)).
-					SetReference(c.ID)
-				rootTreeNode.AddChild(channelTreeNode)
-			}
-		}
-
-	CATEGORY:
-		for _, c := range cs {
-			if c.Type == discordgo.ChannelTypeGuildCategory {
-				for _, nestedChannel := range cs {
-					if nestedChannel.ParentID == c.ID {
-						channelTreeNode := tview.NewTreeNode(c.Name).
-							SetReference(c.ID)
-						rootTreeNode.AddChild(channelTreeNode)
-						continue CATEGORY
-					}
-				}
-
-				channelTreeNode := tview.NewTreeNode(c.Name).
-					SetReference(c.ID)
-				rootTreeNode.AddChild(channelTreeNode)
-			}
-		}
-
-		for _, c := range cs {
-			if (c.Type == discordgo.ChannelTypeGuildText || c.Type == discordgo.ChannelTypeGuildNews) && (c.ParentID != "") {
-				var parentTreeNode *tview.TreeNode
-				rootTreeNode.Walk(func(node, _ *tview.TreeNode) bool {
-					if node.GetReference() == c.ParentID {
-						parentTreeNode = node
-						return false
-					}
-
-					return true
-				})
-
-				if parentTreeNode != nil {
-					channelTreeNode := tview.NewTreeNode(discord.ChannelToString(c)).
-						SetReference(c.ID)
-					parentTreeNode.AddChild(channelTreeNode)
-				}
-			}
-		}
-	}
-
-	app.ChannelsTreeView.SetCurrentNode(rootTreeNode)
-	app.SetFocus(app.ChannelsTreeView)
-}
-
-func onChannelsTreeViewSelected(app *App, n *tview.TreeNode) {
-	app.SelectedMessage = -1
-	app.MessagesTextView.
-		Highlight().
-		Clear()
-	app.MessageInputField.SetText("")
-
-	c, err := app.Session.State.Channel(n.GetReference().(string))
-	if err != nil {
-		return
-	}
-
-	if c.Type == discordgo.ChannelTypeGuildCategory {
-		n.SetExpanded(!n.IsExpanded())
-		return
-	}
-
-	app.SelectedChannel = c
-	app.SetFocus(app.MessageInputField)
-
-	go func() {
-		ms, err := app.Session.ChannelMessages(c.ID, app.Config.General.FetchMessagesLimit, "", "", "")
-		if err != nil {
-			return
-		}
-
-		for i := len(ms) - 1; i >= 0; i-- {
-			app.SelectedChannel.Messages = append(app.SelectedChannel.Messages, ms[i])
-			_, err = app.MessagesTextView.Write(buildMessage(app, ms[i]))
-			if err != nil {
-				return
-			}
-		}
-
-		app.MessagesTextView.ScrollToEnd()
-	}()
-}
-
-func onMessagesTextViewInputCapture(app *App, e *tcell.EventKey) *tcell.EventKey {
-	if app.SelectedChannel == nil {
-		return nil
-	}
-
-	ms := app.SelectedChannel.Messages
-	if len(ms) == 0 {
-		return nil
-	}
-
-	switch e.Name() {
-	case app.Config.Keybindings.SelectPreviousMessage:
-		if len(app.MessagesTextView.GetHighlights()) == 0 {
-			app.SelectedMessage = len(ms) - 1
-		} else {
-			app.SelectedMessage--
-			if app.SelectedMessage < 0 {
-				app.SelectedMessage = 0
-			}
-		}
-
-		app.MessagesTextView.
-			Highlight(ms[app.SelectedMessage].ID).
-			ScrollToHighlight()
-
-		return nil
-	case app.Config.Keybindings.SelectNextMessage:
-		if len(app.MessagesTextView.GetHighlights()) == 0 {
-			app.SelectedMessage = len(ms) - 1
-		} else {
-			app.SelectedMessage++
-			if app.SelectedMessage >= len(ms) {
-				app.SelectedMessage = len(ms) - 1
-			}
-		}
-
-		app.MessagesTextView.
-			Highlight(ms[app.SelectedMessage].ID).
-			ScrollToHighlight()
-
-		return nil
-	case app.Config.Keybindings.SelectFirstMessage:
-		app.SelectedMessage = 0
-		app.MessagesTextView.
-			Highlight(ms[app.SelectedMessage].ID).
-			ScrollToHighlight()
-
-		return nil
-	case app.Config.Keybindings.SelectLastMessage:
-		app.SelectedMessage = len(ms) - 1
-		app.MessagesTextView.
-			Highlight(ms[app.SelectedMessage].ID).
-			ScrollToHighlight()
-
-		return nil
-	case app.Config.Keybindings.ToggleMessageActionsList:
-		messageActionsList := tview.NewList()
-
-		hs := app.MessagesTextView.GetHighlights()
-		if len(hs) == 0 {
-			return nil
-		}
-
-		_, m := discord.FindMessageByID(app.SelectedChannel.Messages, hs[0])
-		if m == nil {
-			return nil
-		}
-
-		if discord.HasPermission(app.Session.State, app.SelectedChannel.ID, discordgo.PermissionSendMessages) {
-			messageActionsList.
-				AddItem("Reply", "", 'r', nil).
-				AddItem("Mention Reply", "", 'R', nil)
-		}
-
-		if m.ReferencedMessage != nil {
-			messageActionsList.AddItem("Select Reply", "", 'm', nil)
-		}
-
-		messageActionsList.
-			ShowSecondaryText(false).
-			AddItem("Copy Content", "", 'c', nil).
-			AddItem("Copy ID", "", 'i', nil).
-			SetDoneFunc(func() {
-				app.
-					SetRoot(app.MainFlex, true).
-					SetFocus(app.MessagesTextView)
-			}).
-			SetSelectedFunc(func(_ int, mainText string, _ string, _ rune) {
-				onMessageActionsListSelected(app, mainText, m)
-			}).
-			SetTitle("Press the Escape key to close").
-			SetBorder(true)
-
-		app.SetRoot(messageActionsList, true)
-
-		return nil
-	case "Esc":
-		app.SelectedMessage = -1
-		app.SetFocus(app.MainFlex)
-		app.MessagesTextView.
-			Clear().
-			Highlight()
-
-		return nil
-	}
-
-	return e
-}
-
-func onMessageActionsListSelected(app *App, mainText string, m *discordgo.Message) {
-	switch mainText {
-	case "Copy Content":
-		if err := clipboard.WriteAll(m.Content); err != nil {
-			return
-		}
-
-		app.SetRoot(app.MainFlex, false)
-	case "Copy ID":
-		if err := clipboard.WriteAll(m.ID); err != nil {
-			return
-		}
-
-		app.SetRoot(app.MainFlex, false)
-	case "Reply":
-		app.MessageInputField.SetTitle("Replying to " + m.Author.String())
-		app.
-			SetRoot(app.MainFlex, false).
-			SetFocus(app.MessageInputField)
-	case "Mention Reply":
-		app.MessageInputField.SetTitle("[@] Replying to " + m.Author.String())
-		app.
-			SetRoot(app.MainFlex, false).
-			SetFocus(app.MessageInputField)
-	case "Select Reply":
-		app.SelectedMessage, _ = discord.FindMessageByID(app.SelectedChannel.Messages, m.ReferencedMessage.ID)
-		app.MessagesTextView.
-			Highlight(m.ReferencedMessage.ID).
-			ScrollToHighlight()
-		app.
-			SetRoot(app.MainFlex, false).
-			SetFocus(app.MessagesTextView)
-	}
-}
-
-func onMessageInputFieldInputCapture(app *App, e *tcell.EventKey) *tcell.EventKey {
-	switch e.Name() {
-	case "Enter":
-		if app.SelectedChannel == nil {
-			return nil
-		}
-
-		t := strings.TrimSpace(app.MessageInputField.GetText())
-		if t == "" {
-			return nil
-		}
-
-		if len(app.MessagesTextView.GetHighlights()) != 0 {
-			_, m := discord.FindMessageByID(app.SelectedChannel.Messages, app.MessagesTextView.GetHighlights()[0])
-			d := &discordgo.MessageSend{
-				Content:         t,
-				Reference:       m.Reference(),
-				AllowedMentions: &discordgo.MessageAllowedMentions{RepliedUser: false},
-			}
-			if strings.HasPrefix(app.MessageInputField.GetTitle(), "[@]") {
-				d.AllowedMentions.RepliedUser = true
-			} else {
-				d.AllowedMentions.RepliedUser = false
-			}
-
-			go app.Session.ChannelMessageSendComplex(m.ChannelID, d)
-
-			app.SelectedMessage = -1
-			app.MessagesTextView.Highlight()
-
-			app.MessageInputField.SetTitle("")
-		} else {
-			go app.Session.ChannelMessageSend(app.SelectedChannel.ID, t)
-		}
-
-		app.MessageInputField.SetText("")
-
-		return nil
-	case "Ctrl+V":
-		text, _ := clipboard.ReadAll()
-		text = app.MessageInputField.GetText() + text
-		app.MessageInputField.SetText(text)
-
-		return nil
-	case "Esc":
-		app.MessageInputField.
-			SetText("").
-			SetTitle("")
-		app.SetFocus(app.MainFlex)
-
-		app.SelectedMessage = -1
-		app.MessagesTextView.Highlight()
-
-		return nil
-	case app.Config.Keybindings.ToggleExternalEditor:
-		e := os.Getenv("EDITOR")
-		if e == "" {
-			return nil
-		}
-
-		f, err := os.CreateTemp(os.TempDir(), "discordo-*.md")
-		if err != nil {
-			return nil
-		}
-		defer os.Remove(f.Name())
-
-		cmd := exec.Command(e, f.Name())
-		cmd.Stdin = os.Stdin
-		cmd.Stdout = os.Stdout
-
-		app.Suspend(func() {
-			err = cmd.Run()
-			if err != nil {
-				return
-			}
-		})
-
-		b, err := io.ReadAll(f)
-		if err != nil {
-			return nil
-		}
-
-		app.MessageInputField.SetText(string(b))
-
-		return nil
-	}
-
-	return e
-}

+ 28 - 0
ui/login.go

@@ -0,0 +1,28 @@
+package ui
+
+import "github.com/rivo/tview"
+
+type LoginForm struct {
+	*tview.Form
+}
+
+func NewLoginForm(mfa bool) *LoginForm {
+	lf := &LoginForm{
+		Form: tview.NewForm(),
+	}
+
+	if mfa {
+		lf.AddPasswordField("MFA Code (optional)", "", 0, 0, nil)
+	} else {
+		lf.
+			AddInputField("Email", "", 0, nil, nil).
+			AddPasswordField("Password", "", 0, 0, nil)
+	}
+
+	lf.SetButtonsAlign(tview.AlignCenter)
+	lf.SetTitle("Login")
+	lf.SetTitleAlign(tview.AlignLeft)
+	lf.SetBorder(true)
+	lf.SetBorderPadding(0, 0, 1, 1)
+	return lf
+}

+ 292 - 0
ui/messages.go

@@ -0,0 +1,292 @@
+package ui
+
+import (
+	"io"
+	"os"
+	"os/exec"
+	"strings"
+
+	"github.com/atotto/clipboard"
+	"github.com/ayntgl/discordgo"
+	"github.com/ayntgl/discordo/discord"
+	"github.com/gdamore/tcell/v2"
+	"github.com/rivo/tview"
+)
+
+type MessagesTextView struct {
+	*tview.TextView
+	app *App
+}
+
+func NewMessagesTextView(app *App) *MessagesTextView {
+	mtv := &MessagesTextView{
+		TextView: tview.NewTextView(),
+		app:      app,
+	}
+
+	mtv.SetDynamicColors(true)
+	mtv.SetRegions(true)
+	mtv.SetWordWrap(true)
+	mtv.SetChangedFunc(func() {
+		mtv.app.Draw()
+	})
+	mtv.SetBorder(true)
+	mtv.SetBorderPadding(0, 0, 1, 1)
+	mtv.SetInputCapture(mtv.onInputCapture)
+	return mtv
+}
+
+func (mtv *MessagesTextView) onInputCapture(e *tcell.EventKey) *tcell.EventKey {
+	if mtv.app.SelectedChannel == nil {
+		return nil
+	}
+
+	ms := mtv.app.SelectedChannel.Messages
+	if len(ms) == 0 {
+		return nil
+	}
+
+	switch e.Name() {
+	case mtv.app.Config.Keybindings.SelectPreviousMessage:
+		if len(mtv.app.MessagesTextView.GetHighlights()) == 0 {
+			mtv.app.SelectedMessage = len(ms) - 1
+		} else {
+			mtv.app.SelectedMessage--
+			if mtv.app.SelectedMessage < 0 {
+				mtv.app.SelectedMessage = 0
+			}
+		}
+
+		mtv.app.MessagesTextView.
+			Highlight(ms[mtv.app.SelectedMessage].ID).
+			ScrollToHighlight()
+
+		return nil
+	case mtv.app.Config.Keybindings.SelectNextMessage:
+		if len(mtv.app.MessagesTextView.GetHighlights()) == 0 {
+			mtv.app.SelectedMessage = len(ms) - 1
+		} else {
+			mtv.app.SelectedMessage++
+			if mtv.app.SelectedMessage >= len(ms) {
+				mtv.app.SelectedMessage = len(ms) - 1
+			}
+		}
+
+		mtv.app.MessagesTextView.
+			Highlight(ms[mtv.app.SelectedMessage].ID).
+			ScrollToHighlight()
+
+		return nil
+	case mtv.app.Config.Keybindings.SelectFirstMessage:
+		mtv.app.SelectedMessage = 0
+		mtv.app.MessagesTextView.
+			Highlight(ms[mtv.app.SelectedMessage].ID).
+			ScrollToHighlight()
+
+		return nil
+	case mtv.app.Config.Keybindings.SelectLastMessage:
+		mtv.app.SelectedMessage = len(ms) - 1
+		mtv.app.MessagesTextView.
+			Highlight(ms[mtv.app.SelectedMessage].ID).
+			ScrollToHighlight()
+
+		return nil
+	case mtv.app.Config.Keybindings.ToggleMessageActionsList:
+		messageActionsList := tview.NewList()
+
+		hs := mtv.app.MessagesTextView.GetHighlights()
+		if len(hs) == 0 {
+			return nil
+		}
+
+		_, m := discord.FindMessageByID(mtv.app.SelectedChannel.Messages, hs[0])
+		if m == nil {
+			return nil
+		}
+
+		if discord.HasPermission(mtv.app.Session.State, mtv.app.SelectedChannel.ID, discordgo.PermissionSendMessages) {
+			messageActionsList.
+				AddItem("Reply", "", 'r', nil).
+				AddItem("Mention Reply", "", 'R', nil)
+		}
+
+		if m.ReferencedMessage != nil {
+			messageActionsList.AddItem("Select Reply", "", 'm', nil)
+		}
+
+		messageActionsList.
+			ShowSecondaryText(false).
+			AddItem("Copy Content", "", 'c', nil).
+			AddItem("Copy ID", "", 'i', nil).
+			SetDoneFunc(func() {
+				mtv.app.
+					SetRoot(mtv.app.MainFlex, true).
+					SetFocus(mtv.app.MessagesTextView)
+			}).
+			SetSelectedFunc(func(_ int, mainText string, _ string, _ rune) {
+				onMessageActionsListSelected(mtv.app, mainText, m)
+			}).
+			SetTitle("Press the Escape key to close").
+			SetBorder(true)
+
+		mtv.app.SetRoot(messageActionsList, true)
+
+		return nil
+	case "Esc":
+		mtv.app.SelectedMessage = -1
+		mtv.app.SetFocus(mtv.app.MainFlex)
+		mtv.app.MessagesTextView.
+			Clear().
+			Highlight()
+
+		return nil
+	}
+
+	return e
+}
+
+func onMessageActionsListSelected(app *App, mainText string, m *discordgo.Message) {
+	switch mainText {
+	case "Copy Content":
+		if err := clipboard.WriteAll(m.Content); err != nil {
+			return
+		}
+
+		app.SetRoot(app.MainFlex, false)
+	case "Copy ID":
+		if err := clipboard.WriteAll(m.ID); err != nil {
+			return
+		}
+
+		app.SetRoot(app.MainFlex, false)
+	case "Reply":
+		app.MessageInputField.SetTitle("Replying to " + m.Author.String())
+		app.
+			SetRoot(app.MainFlex, false).
+			SetFocus(app.MessageInputField)
+	case "Mention Reply":
+		app.MessageInputField.SetTitle("[@] Replying to " + m.Author.String())
+		app.
+			SetRoot(app.MainFlex, false).
+			SetFocus(app.MessageInputField)
+	case "Select Reply":
+		app.SelectedMessage, _ = discord.FindMessageByID(app.SelectedChannel.Messages, m.ReferencedMessage.ID)
+		app.MessagesTextView.
+			Highlight(m.ReferencedMessage.ID).
+			ScrollToHighlight()
+		app.
+			SetRoot(app.MainFlex, false).
+			SetFocus(app.MessagesTextView)
+	}
+}
+
+type MessageInputField struct {
+	*tview.InputField
+	app *App
+}
+
+func NewMessageInputField(app *App) *MessageInputField {
+	mi := &MessageInputField{
+		InputField: tview.NewInputField(),
+		app:        app,
+	}
+
+	mi.SetFieldBackgroundColor(tview.Styles.PrimitiveBackgroundColor)
+	mi.SetPlaceholder("Message...")
+	mi.SetPlaceholderStyle(tcell.StyleDefault.Background(tview.Styles.PrimitiveBackgroundColor))
+	mi.SetTitleAlign(tview.AlignLeft)
+	mi.SetBorder(true)
+	mi.SetBorderPadding(0, 0, 1, 1)
+	mi.SetInputCapture(mi.onInputCapture)
+	return mi
+}
+
+func (mi *MessageInputField) onInputCapture(e *tcell.EventKey) *tcell.EventKey {
+	switch e.Name() {
+	case "Enter":
+		if mi.app.SelectedChannel == nil {
+			return nil
+		}
+
+		t := strings.TrimSpace(mi.app.MessageInputField.GetText())
+		if t == "" {
+			return nil
+		}
+
+		if len(mi.app.MessagesTextView.GetHighlights()) != 0 {
+			_, m := discord.FindMessageByID(mi.app.SelectedChannel.Messages, mi.app.MessagesTextView.GetHighlights()[0])
+			d := &discordgo.MessageSend{
+				Content:         t,
+				Reference:       m.Reference(),
+				AllowedMentions: &discordgo.MessageAllowedMentions{RepliedUser: false},
+			}
+			if strings.HasPrefix(mi.app.MessageInputField.GetTitle(), "[@]") {
+				d.AllowedMentions.RepliedUser = true
+			} else {
+				d.AllowedMentions.RepliedUser = false
+			}
+
+			go mi.app.Session.ChannelMessageSendComplex(m.ChannelID, d)
+
+			mi.app.SelectedMessage = -1
+			mi.app.MessagesTextView.Highlight()
+
+			mi.app.MessageInputField.SetTitle("")
+		} else {
+			go mi.app.Session.ChannelMessageSend(mi.app.SelectedChannel.ID, t)
+		}
+
+		mi.app.MessageInputField.SetText("")
+
+		return nil
+	case "Ctrl+V":
+		text, _ := clipboard.ReadAll()
+		text = mi.app.MessageInputField.GetText() + text
+		mi.app.MessageInputField.SetText(text)
+
+		return nil
+	case "Esc":
+		mi.app.MessageInputField.
+			SetText("").
+			SetTitle("")
+		mi.app.SetFocus(mi.app.MainFlex)
+
+		mi.app.SelectedMessage = -1
+		mi.app.MessagesTextView.Highlight()
+
+		return nil
+	case mi.app.Config.Keybindings.ToggleExternalEditor:
+		e := os.Getenv("EDITOR")
+		if e == "" {
+			return nil
+		}
+
+		f, err := os.CreateTemp(os.TempDir(), "discordo-*.md")
+		if err != nil {
+			return nil
+		}
+		defer os.Remove(f.Name())
+
+		cmd := exec.Command(e, f.Name())
+		cmd.Stdin = os.Stdin
+		cmd.Stdout = os.Stdout
+
+		mi.app.Suspend(func() {
+			err = cmd.Run()
+			if err != nil {
+				return
+			}
+		})
+
+		b, err := io.ReadAll(f)
+		if err != nil {
+			return nil
+		}
+
+		mi.app.MessageInputField.SetText(string(b))
+
+		return nil
+	}
+
+	return e
+}

+ 0 - 87
ui/widgets.go

@@ -1,87 +0,0 @@
-package ui
-
-import (
-	"github.com/gdamore/tcell/v2"
-	"github.com/rivo/tview"
-)
-
-func NewMainFlex(app *App) *tview.Flex {
-	app.SetInputCapture(func(e *tcell.EventKey) *tcell.EventKey {
-		return onAppInputCapture(app, e)
-	})
-
-	app.GuildsList.
-		ShowSecondaryText(false).
-		SetSelectedFunc(func(guildIdx int, _ string, _ string, _ rune) {
-			onGuildsListSelected(app, guildIdx)
-		}).
-		SetTitle("Guilds").
-		SetTitleAlign(tview.AlignLeft).
-		SetBorder(true).
-		SetBorderPadding(0, 0, 1, 1)
-
-	app.ChannelsTreeView.
-		SetTopLevel(1).
-		SetRoot(tview.NewTreeNode("")).
-		SetSelectedFunc(func(n *tview.TreeNode) {
-			onChannelsTreeViewSelected(app, n)
-		}).
-		SetTitle("Channels").
-		SetTitleAlign(tview.AlignLeft).
-		SetBorder(true).
-		SetBorderPadding(0, 0, 1, 1)
-
-	app.MessagesTextView.
-		SetRegions(true).
-		SetDynamicColors(true).
-		SetWordWrap(true).
-		SetChangedFunc(func() { app.Draw() }).
-		SetInputCapture(func(e *tcell.EventKey) *tcell.EventKey {
-			return onMessagesTextViewInputCapture(app, e)
-		}).
-		SetBorder(true).
-		SetBorderPadding(0, 0, 1, 1)
-
-	app.MessageInputField.
-		SetPlaceholder("Message...").
-		SetFieldBackgroundColor(tview.Styles.PrimitiveBackgroundColor).
-		SetPlaceholderStyle(tcell.StyleDefault.Background(tview.Styles.PrimitiveBackgroundColor)).
-		SetInputCapture(func(e *tcell.EventKey) *tcell.EventKey {
-			return onMessageInputFieldInputCapture(app, e)
-		}).
-		SetTitleAlign(tview.AlignLeft).
-		SetBorder(true).
-		SetBorderPadding(0, 0, 1, 0)
-
-	leftFlex := tview.NewFlex().
-		SetDirection(tview.FlexRow).
-		AddItem(app.GuildsList, 10, 1, false).
-		AddItem(app.ChannelsTreeView, 0, 1, false)
-	rightFlex := tview.NewFlex().
-		SetDirection(tview.FlexRow).
-		AddItem(app.MessagesTextView, 0, 1, false).
-		AddItem(app.MessageInputField, 3, 1, false)
-	app.MainFlex.
-		AddItem(leftFlex, 0, 1, false).
-		AddItem(rightFlex, 0, 4, false)
-
-	return app.MainFlex
-}
-
-func NewLoginForm(mfa bool) *tview.Form {
-	loginForm := tview.NewForm()
-	loginForm.
-		SetButtonsAlign(tview.AlignCenter).
-		SetBorder(true).
-		SetBorderPadding(0, 0, 1, 0)
-
-	if mfa {
-		loginForm.AddPasswordField("MFA Code (optional)", "", 0, 0, nil)
-	} else {
-		loginForm.
-			AddInputField("Email", "", 0, nil, nil).
-			AddPasswordField("Password", "", 0, 0, nil)
-	}
-
-	return loginForm
-}