瀏覽代碼

refactor: create separate struct for main Flex

ayntgl 3 年之前
父節點
當前提交
11201c8649
共有 11 個文件被更改,包括 419 次插入396 次删除
  1. 8 45
      main.go
  2. 39 39
      ui/actions_view.go
  3. 202 0
      ui/application.go
  4. 7 7
      ui/builder.go
  5. 14 14
      ui/channels_view.go
  6. 0 242
      ui/core.go
  7. 12 12
      ui/guilds_view.go
  8. 18 18
      ui/input_view.go
  9. 7 5
      ui/login_view.go
  10. 14 14
      ui/messages_view.go
  11. 98 0
      ui/view.go

+ 8 - 45
main.go

@@ -2,16 +2,11 @@ package main
 
 import (
 	"flag"
-	"fmt"
 	"log"
 	"os"
-	"runtime"
 
 	"github.com/ayntgl/discordo/config"
 	"github.com/ayntgl/discordo/ui"
-	"github.com/diamondburned/arikawa/v3/api"
-	"github.com/diamondburned/arikawa/v3/gateway"
-	"github.com/rivo/tview"
 	"github.com/zalando/go-keyring"
 )
 
@@ -22,26 +17,6 @@ var (
 )
 
 func init() {
-	tview.Borders.TopLeftFocus = tview.Borders.TopLeft
-	tview.Borders.TopRightFocus = tview.Borders.TopRight
-	tview.Borders.BottomLeftFocus = tview.Borders.BottomLeft
-	tview.Borders.BottomRightFocus = tview.Borders.BottomRight
-	tview.Borders.HorizontalFocus = tview.Borders.Horizontal
-	tview.Borders.VerticalFocus = tview.Borders.Vertical
-	tview.Borders.TopLeft = 0
-	tview.Borders.TopRight = 0
-	tview.Borders.BottomLeft = 0
-	tview.Borders.BottomRight = 0
-	tview.Borders.Horizontal = 0
-	tview.Borders.Vertical = 0
-
-	api.UserAgent = fmt.Sprintf("%s/%s %s/%s", config.Name, "0.1", "arikawa", "v3")
-	gateway.DefaultIdentity = gateway.IdentifyProperties{
-		OS:      runtime.GOOS,
-		Browser: config.Name,
-		Device:  "",
-	}
-
 	flag.StringVar(&flagToken, "token", "", "The authentication token.")
 	flag.StringVar(&flagConfig, "config", config.DefaultConfigPath(), "The path to the configuration file.")
 	flag.StringVar(&flagLog, "log", config.DefaultLogPath(), "The path to the log file.")
@@ -62,12 +37,15 @@ func main() {
 	}
 
 	cfg := config.New()
-	err := cfg.Load(flagConfig)
-	if err != nil {
+	if err := cfg.Load(flagConfig); err != nil {
 		log.Fatal(err)
 	}
 
-	var token string
+	var (
+		token string
+		err   error
+	)
+
 	if flagToken != "" {
 		token = flagToken
 		go keyring.Set(config.Name, "token", token)
@@ -78,21 +56,6 @@ func main() {
 		}
 	}
 
-	c := ui.NewCore(cfg)
-	if token != "" {
-		err = c.Run(token)
-		if err != nil {
-			log.Fatal(err)
-		}
-
-		c.Draw()
-	} else {
-		loginView := ui.NewLoginView(c)
-		c.App.SetRoot(loginView, true)
-	}
-
-	err = c.App.Run()
-	if err != nil {
-		log.Fatal(err)
-	}
+	app := ui.NewApplication(cfg)
+	app.Run(token)
 }

+ 39 - 39
ui/actions_view.go

@@ -17,27 +17,27 @@ var linkRegex = regexp.MustCompile("https?://.+")
 
 type ActionsView struct {
 	*tview.List
-	core    *Core
+	app     *Application
 	message *discord.Message
 }
 
-func newActionsView(c *Core, m *discord.Message) *ActionsView {
+func newActionsView(app *Application, m *discord.Message) *ActionsView {
 	v := &ActionsView{
 		List:    tview.NewList(),
-		core:    c,
+		app:     app,
 		message: m,
 	}
 
 	v.ShowSecondaryText(false)
 	v.SetDoneFunc(func() {
-		c.App.SetRoot(c.View, true)
-		c.App.SetFocus(c.MessagesView)
+		app.SetRoot(app.view, true)
+		app.SetFocus(app.view.MessagesView)
 	})
 
-	isDM := channelIsInDMCategory(c.ChannelsView.selectedChannel)
+	isDM := channelIsInDMCategory(app.view.ChannelsView.selectedChannel)
 
 	// If the client user has the `SEND_MESSAGES` permission, add "Reply" and "Mention Reply" actions.
-	if isDM || !isDM && hasPermission(c.State, c.ChannelsView.selectedChannel.ID, discord.PermissionSendMessages) {
+	if isDM || !isDM && hasPermission(app.state, app.view.ChannelsView.selectedChannel.ID, discord.PermissionSendMessages) {
 		v.AddItem("Reply", "", 'r', v.replyAction)
 		v.AddItem("Mention Reply", "", 'R', v.mentionReplyAction)
 	}
@@ -55,8 +55,8 @@ func newActionsView(c *Core, m *discord.Message) *ActionsView {
 				go open.Run(l)
 			}
 
-			c.App.SetRoot(c.View, true)
-			c.App.SetFocus(c.MessagesView)
+			app.SetRoot(app.view, true)
+			app.SetFocus(app.view.MessagesView)
 		})
 	}
 
@@ -66,10 +66,10 @@ func newActionsView(c *Core, m *discord.Message) *ActionsView {
 		v.AddItem("Download Attachment", "", 'd', v.downloadAttachmentAction)
 	}
 
-	me, _ := c.State.MeStore.Me()
+	me, _ := app.state.MeStore.Me()
 
 	// If the client user has the `MANAGE_MESSAGES` permission, add a new action to delete the message.
-	if (isDM && m.Author.ID == me.ID) || (!isDM && hasPermission(c.State, c.ChannelsView.selectedChannel.ID, discord.PermissionManageMessages)) {
+	if (isDM && m.Author.ID == me.ID) || (!isDM && hasPermission(app.state, app.view.ChannelsView.selectedChannel.ID, discord.PermissionManageMessages)) {
 		v.AddItem("Delete", "", 'd', v.deleteAction)
 	}
 
@@ -86,32 +86,32 @@ func newActionsView(c *Core, m *discord.Message) *ActionsView {
 }
 
 func (v *ActionsView) replyAction() {
-	v.core.InputView.SetTitle("Replying to " + v.message.Author.Tag())
+	v.app.view.InputView.SetTitle("Replying to " + v.message.Author.Tag())
 
-	v.core.App.SetRoot(v.core.View, true)
-	v.core.App.SetFocus(v.core.InputView)
+	v.app.SetRoot(v.app.view, true)
+	v.app.SetFocus(v.app.view.InputView)
 }
 
 func (v *ActionsView) mentionReplyAction() {
-	v.core.InputView.SetTitle("[@] Replying to " + v.message.Author.Tag())
+	v.app.view.InputView.SetTitle("[@] Replying to " + v.message.Author.Tag())
 
-	v.core.App.SetRoot(v.core.View, true)
-	v.core.App.SetFocus(v.core.InputView)
+	v.app.SetRoot(v.app.view, true)
+	v.app.SetFocus(v.app.view.InputView)
 }
 
 func (v *ActionsView) selectReplyAction() {
-	ms, err := v.core.State.Cabinet.Messages(v.message.ChannelID)
+	ms, err := v.app.state.Cabinet.Messages(v.message.ChannelID)
 	if err != nil {
 		return
 	}
 
-	v.core.MessagesView.selectedMessage, _ = findMessageByID(ms, v.message.ReferencedMessage.ID)
-	v.core.MessagesView.
+	v.app.view.MessagesView.selectedMessage, _ = findMessageByID(ms, v.message.ReferencedMessage.ID)
+	v.app.view.MessagesView.
 		Highlight(v.message.ReferencedMessage.ID.String()).
 		ScrollToHighlight()
 
-	v.core.App.SetRoot(v.core.View, true)
-	v.core.App.SetFocus(v.core.MessagesView)
+	v.app.SetRoot(v.app.view, true)
+	v.app.SetFocus(v.app.view.MessagesView)
 }
 
 func (v *ActionsView) openAttachmentAction() {
@@ -137,8 +137,8 @@ func (v *ActionsView) openAttachmentAction() {
 		go open.Run(f.Name())
 	}
 
-	v.core.App.SetRoot(v.core.View, true)
-	v.core.App.SetFocus(v.core.MessagesView)
+	v.app.SetRoot(v.app.view, true)
+	v.app.SetFocus(v.app.view.MessagesView)
 }
 
 func (v *ActionsView) downloadAttachmentAction() {
@@ -168,38 +168,38 @@ func (v *ActionsView) downloadAttachmentAction() {
 		f.Write(d)
 	}
 
-	v.core.App.SetRoot(v.core.View, true)
-	v.core.App.SetFocus(v.core.MessagesView)
+	v.app.SetRoot(v.app.view, true)
+	v.app.SetFocus(v.app.view.MessagesView)
 }
 
 func (v *ActionsView) deleteAction() {
-	v.core.MessagesView.Clear()
+	v.app.view.MessagesView.Clear()
 
-	err := v.core.State.MessageRemove(v.message.ChannelID, v.message.ID)
+	err := v.app.state.MessageRemove(v.message.ChannelID, v.message.ID)
 	if err != nil {
 		return
 	}
 
-	err = v.core.State.DeleteMessage(v.message.ChannelID, v.message.ID, "Unknown")
+	err = v.app.state.DeleteMessage(v.message.ChannelID, v.message.ID, "Unknown")
 	if err != nil {
 		return
 	}
 
 	// The returned slice will be sorted from latest to oldest.
-	ms, err := v.core.State.Cabinet.Messages(v.message.ChannelID)
+	ms, err := v.app.state.Cabinet.Messages(v.message.ChannelID)
 	if err != nil {
 		return
 	}
 
 	for i := len(ms) - 1; i >= 0; i-- {
-		_, err = v.core.MessagesView.Write(buildMessage(v.core, ms[i]))
+		_, err = v.app.view.MessagesView.Write(buildMessage(v.app, ms[i]))
 		if err != nil {
 			return
 		}
 	}
 
-	v.core.App.SetRoot(v.core.View, true)
-	v.core.App.SetFocus(v.core.MessagesView)
+	v.app.SetRoot(v.app.view, true)
+	v.app.SetFocus(v.app.view.MessagesView)
 }
 
 func (v *ActionsView) copyContentAction() {
@@ -208,8 +208,8 @@ func (v *ActionsView) copyContentAction() {
 		return
 	}
 
-	v.core.App.SetRoot(v.core.View, true)
-	v.core.App.SetFocus(v.core.MessagesView)
+	v.app.SetRoot(v.app.view, true)
+	v.app.SetFocus(v.app.view.MessagesView)
 }
 
 func (v *ActionsView) copyIDAction() {
@@ -218,8 +218,8 @@ func (v *ActionsView) copyIDAction() {
 		return
 	}
 
-	v.core.App.SetRoot(v.core.View, true)
-	v.core.App.SetFocus(v.core.MessagesView)
+	v.app.SetRoot(v.app.view, true)
+	v.app.SetFocus(v.app.view.MessagesView)
 }
 
 func (v *ActionsView) copyLinkAction() {
@@ -228,6 +228,6 @@ func (v *ActionsView) copyLinkAction() {
 		return
 	}
 
-	v.core.App.SetRoot(v.core.View, true)
-	v.core.App.SetFocus(v.core.MessagesView)
+	v.app.SetRoot(v.app.view, true)
+	v.app.SetFocus(v.app.view.MessagesView)
 }

+ 202 - 0
ui/application.go

@@ -0,0 +1,202 @@
+package ui
+
+import (
+	"context"
+	"fmt"
+	"log"
+	"runtime"
+	"strings"
+
+	"github.com/ayntgl/discordo/config"
+	"github.com/diamondburned/arikawa/v3/api"
+	"github.com/diamondburned/arikawa/v3/discord"
+	"github.com/diamondburned/arikawa/v3/gateway"
+	"github.com/diamondburned/arikawa/v3/state"
+	"github.com/gdamore/tcell/v2"
+	"github.com/rivo/tview"
+)
+
+func init() {
+	tview.Borders.TopLeftFocus = tview.Borders.TopLeft
+	tview.Borders.TopRightFocus = tview.Borders.TopRight
+	tview.Borders.BottomLeftFocus = tview.Borders.BottomLeft
+	tview.Borders.BottomRightFocus = tview.Borders.BottomRight
+	tview.Borders.HorizontalFocus = tview.Borders.Horizontal
+	tview.Borders.VerticalFocus = tview.Borders.Vertical
+	tview.Borders.TopLeft = 0
+	tview.Borders.TopRight = 0
+	tview.Borders.BottomLeft = 0
+	tview.Borders.BottomRight = 0
+	tview.Borders.Horizontal = 0
+	tview.Borders.Vertical = 0
+
+	api.UserAgent = fmt.Sprintf("%s/%s %s/%s", config.Name, "0.1", "arikawa", "v3")
+	gateway.DefaultIdentity = gateway.IdentifyProperties{
+		OS:      runtime.GOOS,
+		Browser: config.Name,
+		Device:  "",
+	}
+}
+
+// Application is responsible for initialization and management of the application, widgets, configuration, and state.
+type Application struct {
+	*tview.Application
+
+	view   *View
+	config *config.Config
+	state  *state.State
+}
+
+func NewApplication(cfg *config.Config) *Application {
+	app := &Application{
+		Application: tview.NewApplication(),
+		config:      cfg,
+	}
+
+	app.EnableMouse(app.config.Mouse)
+	app.SetBeforeDrawFunc(app.onBeforeDraw)
+
+	app.view = newView(app)
+
+	tview.Styles.PrimitiveBackgroundColor = tcell.GetColor(cfg.Theme.Background)
+	tview.Styles.BorderColor = tcell.GetColor(cfg.Theme.Border)
+	tview.Styles.TitleColor = tcell.GetColor(cfg.Theme.Title)
+
+	return app
+}
+
+func (app *Application) Run(token string) {
+	if token != "" {
+		if err := app.Connect(token); err != nil {
+			log.Fatal(err)
+		}
+
+		app.SetRoot(app.view, true)
+		app.SetFocus(app.view.GuildsView)
+	} else {
+		loginView := newLoginView(app)
+		app.SetRoot(loginView, true)
+	}
+
+	if err := app.Application.Run(); err != nil {
+		log.Fatal(err)
+	}
+}
+
+func (app *Application) Connect(token string) error {
+	app.state = state.New(token)
+	app.state.AddHandler(app.onReady)
+	app.state.AddHandler(app.onGuildCreate)
+	app.state.AddHandler(app.onGuildDelete)
+	app.state.AddHandler(app.onMessageCreate)
+
+	return app.state.Open(context.Background())
+}
+
+func (app *Application) onBeforeDraw(screen tcell.Screen) bool {
+	if app.config.Theme.Background == "default" {
+		screen.Clear()
+	}
+
+	return false
+}
+
+func (c *Application) onReady(r *gateway.ReadyEvent) {
+	root := c.view.GuildsView.GetRoot()
+	for _, gf := range r.UserSettings.GuildFolders {
+		if gf.ID == 0 {
+			for _, gID := range gf.GuildIDs {
+				g, err := c.state.Cabinet.Guild(gID)
+				if err != nil {
+					log.Println(err)
+					continue
+				}
+
+				guildNode := tview.NewTreeNode(g.Name)
+				guildNode.SetReference(g.ID)
+				root.AddChild(guildNode)
+			}
+		} else {
+			var b strings.Builder
+
+			if gf.Color != discord.NullColor {
+				b.WriteByte('[')
+				b.WriteString(gf.Color.String())
+				b.WriteByte(']')
+			} else {
+				b.WriteString("[#ED4245]")
+			}
+
+			if gf.Name != "" {
+				b.WriteString(gf.Name)
+			} else {
+				b.WriteString("Folder")
+			}
+
+			b.WriteString("[-]")
+
+			folderNode := tview.NewTreeNode(b.String())
+			root.AddChild(folderNode)
+
+			for _, gID := range gf.GuildIDs {
+				g, err := c.state.Cabinet.Guild(gID)
+				if err != nil {
+					log.Println(err)
+					continue
+				}
+
+				guildNode := tview.NewTreeNode(g.Name)
+				guildNode.SetReference(g.ID)
+				folderNode.AddChild(guildNode)
+			}
+		}
+
+	}
+
+	c.view.GuildsView.SetCurrentNode(root)
+	c.SetFocus(c.view.GuildsView)
+}
+
+func (c *Application) onGuildCreate(g *gateway.GuildCreateEvent) {
+	guildNode := tview.NewTreeNode(g.Name)
+	guildNode.SetReference(g.ID)
+
+	rootNode := c.view.GuildsView.GetRoot()
+	rootNode.AddChild(guildNode)
+
+	c.view.GuildsView.SetCurrentNode(rootNode)
+	c.SetFocus(c.view.GuildsView)
+	c.Draw()
+}
+
+func (c *Application) onGuildDelete(g *gateway.GuildDeleteEvent) {
+	rootNode := c.view.GuildsView.GetRoot()
+	var parentNode *tview.TreeNode
+	rootNode.Walk(func(node, _ *tview.TreeNode) bool {
+		if node.GetReference() == g.ID {
+			parentNode = node
+			return false
+		}
+
+		return true
+	})
+
+	if parentNode != nil {
+		rootNode.RemoveChild(parentNode)
+	}
+
+	c.Draw()
+}
+
+func (c *Application) onMessageCreate(m *gateway.MessageCreateEvent) {
+	if c.view.ChannelsView.selectedChannel != nil && c.view.ChannelsView.selectedChannel.ID == m.ChannelID {
+		_, err := c.view.MessagesView.Write(buildMessage(c, m.Message))
+		if err != nil {
+			return
+		}
+
+		if len(c.view.MessagesView.GetHighlights()) == 0 {
+			c.view.MessagesView.ScrollToEnd()
+		}
+	}
+}

+ 7 - 7
ui/builder.go

@@ -8,7 +8,7 @@ import (
 	"github.com/diamondburned/arikawa/v3/discord"
 )
 
-func buildMessage(c *Core, m discord.Message) []byte {
+func buildMessage(c *Application, m discord.Message) []byte {
 	var b strings.Builder
 
 	switch m.Type {
@@ -20,25 +20,25 @@ func buildMessage(c *Core, m discord.Message) []byte {
 		b.WriteString(m.ID.String())
 		b.WriteString("\"]")
 		// Build the message associated with crosspost, channel follow add, pin, or a reply.
-		buildReferencedMessage(&b, m.ReferencedMessage, c.State.Ready().User.ID)
+		buildReferencedMessage(&b, m.ReferencedMessage, c.state.Ready().User.ID)
 
-		if c.Config.Timestamps {
-			loc, err := time.LoadLocation(c.Config.Timezone)
+		if c.config.Timestamps {
+			loc, err := time.LoadLocation(c.config.Timezone)
 			if err != nil {
 				return nil
 			}
 
 			b.WriteString("[::d]")
-			b.WriteString(m.Timestamp.Time().In(loc).Format(c.Config.TimeFormat))
+			b.WriteString(m.Timestamp.Time().In(loc).Format(c.config.TimeFormat))
 			b.WriteString("[::-]")
 			b.WriteByte(' ')
 		}
 
 		// Build the author of this message.
-		buildAuthor(&b, m.Author, c.State.Ready().User.ID)
+		buildAuthor(&b, m.Author, c.state.Ready().User.ID)
 
 		// Build the contents of the message.
-		buildContent(&b, m, c.State.Ready().User.ID)
+		buildContent(&b, m, c.state.Ready().User.ID)
 
 		if m.EditedTimestamp.IsValid() {
 			b.WriteString(" [::d](edited)[::-]")

+ 14 - 14
ui/channels_view.go

@@ -11,13 +11,13 @@ import (
 type ChannelsView struct {
 	*tview.TreeView
 	selectedChannel *discord.Channel
-	core            *Core
+	app             *Application
 }
 
-func newChannelsView(c *Core) *ChannelsView {
+func newChannelsView(app *Application) *ChannelsView {
 	v := &ChannelsView{
 		TreeView: tview.NewTreeView(),
-		core:     c,
+		app:      app,
 	}
 
 	v.SetRoot(tview.NewTreeNode(""))
@@ -34,15 +34,15 @@ func newChannelsView(c *Core) *ChannelsView {
 
 func (v *ChannelsView) onSelected(node *tview.TreeNode) {
 	v.selectedChannel = nil
-	v.core.MessagesView.selectedMessage = -1
-	v.core.MessagesView.
+	v.app.view.MessagesView.selectedMessage = -1
+	v.app.view.MessagesView.
 		Highlight().
 		Clear().
 		SetTitle("")
-	v.core.InputView.SetText("")
+	v.app.view.InputView.SetText("")
 
 	ref := node.GetReference()
-	c, err := v.core.State.Cabinet.Channel(ref.(discord.ChannelID))
+	c, err := v.app.state.Cabinet.Channel(ref.(discord.ChannelID))
 	if err != nil {
 		return
 	}
@@ -54,31 +54,31 @@ func (v *ChannelsView) onSelected(node *tview.TreeNode) {
 	}
 
 	v.selectedChannel = c
-	v.core.App.SetFocus(v.core.InputView)
+	v.app.SetFocus(v.app.view.InputView)
 
 	title := channelToString(*c)
 	if c.Topic != "" {
 		title += " - " + parseMarkdown(c.Topic)
 	}
-	v.core.MessagesView.SetTitle(title)
+	v.app.view.MessagesView.SetTitle(title)
 
 	go func() {
 		// The returned slice will be sorted from latest to oldest.
-		ms, err := v.core.State.Messages(c.ID, v.core.Config.MessagesLimit)
+		ms, err := v.app.state.Messages(c.ID, v.app.config.MessagesLimit)
 		if err != nil {
 			log.Println(err)
 			return
 		}
 
 		for i := len(ms) - 1; i >= 0; i-- {
-			_, err = v.core.MessagesView.Write(buildMessage(v.core, ms[i]))
+			_, err = v.app.view.MessagesView.Write(buildMessage(v.app, ms[i]))
 			if err != nil {
 				log.Println(err)
 				continue
 			}
 		}
 
-		v.core.MessagesView.ScrollToEnd()
+		v.app.view.MessagesView.ScrollToEnd()
 	}()
 }
 
@@ -90,7 +90,7 @@ func (v *ChannelsView) createChannelNode(c discord.Channel) *tview.TreeNode {
 }
 
 func (v *ChannelsView) createPrivateChannelNodes(root *tview.TreeNode) {
-	cs, err := v.core.State.Cabinet.PrivateChannels()
+	cs, err := v.app.state.Cabinet.PrivateChannels()
 	if err != nil {
 		log.Println(err)
 		return
@@ -106,7 +106,7 @@ func (v *ChannelsView) createPrivateChannelNodes(root *tview.TreeNode) {
 }
 
 func (v *ChannelsView) createGuildChannelNodes(root *tview.TreeNode, gID discord.GuildID) {
-	cs, err := v.core.State.Cabinet.Channels(gID)
+	cs, err := v.app.state.Cabinet.Channels(gID)
 	if err != nil {
 		log.Println(err)
 		return

+ 0 - 242
ui/core.go

@@ -1,242 +0,0 @@
-package ui
-
-import (
-	"context"
-	"log"
-	"strings"
-
-	"github.com/ayntgl/discordo/config"
-	"github.com/diamondburned/arikawa/v3/discord"
-	"github.com/diamondburned/arikawa/v3/gateway"
-	"github.com/diamondburned/arikawa/v3/state"
-	"github.com/gdamore/tcell/v2"
-	"github.com/rivo/tview"
-)
-
-type FocusedID int
-
-const (
-	guildsView FocusedID = iota
-	channelsView
-	messagesView
-	inputView
-)
-
-// Core is responsible for the following:
-// - Initialization of the application, UI elements, configuration, and state.
-// - Configuration of the application and state when Run is called.
-// - Management of the application and state.
-type Core struct {
-	App          *tview.Application
-	View         *tview.Flex
-	GuildsView   *GuildsView
-	ChannelsView *ChannelsView
-	MessagesView *MessagesView
-	InputView    *InputView
-
-	Config *config.Config
-	State  *state.State
-
-	focusedID FocusedID
-}
-
-func NewCore(cfg *config.Config) *Core {
-	c := &Core{
-		Config: cfg,
-	}
-
-	tview.Styles.PrimitiveBackgroundColor = tcell.GetColor(cfg.Theme.Background)
-	tview.Styles.BorderColor = tcell.GetColor(cfg.Theme.Border)
-	tview.Styles.TitleColor = tcell.GetColor(cfg.Theme.Title)
-
-	c.App = tview.NewApplication()
-	c.App.EnableMouse(c.Config.Mouse)
-	c.App.SetBeforeDrawFunc(c.onAppBeforeDraw)
-
-	c.View = tview.NewFlex()
-	c.View.SetInputCapture(c.onViewInputCapture)
-
-	c.GuildsView = newGuildsView(c)
-	c.ChannelsView = newChannelsView(c)
-	c.MessagesView = newMessagesView(c)
-	c.InputView = newInputView(c)
-
-	return c
-}
-
-func (c *Core) Run(token string) error {
-	c.State = state.New(token)
-	c.State.AddHandler(c.onReady)
-	c.State.AddHandler(c.onGuildCreate)
-	c.State.AddHandler(c.onGuildDelete)
-	c.State.AddHandler(c.onMessageCreate)
-	return c.State.Open(context.Background())
-}
-
-func (c *Core) Draw() {
-	left := tview.NewFlex().
-		SetDirection(tview.FlexRow).
-		AddItem(c.GuildsView, 10, 1, false).
-		AddItem(c.ChannelsView, 0, 1, false)
-	right := tview.NewFlex().
-		SetDirection(tview.FlexRow).
-		AddItem(c.MessagesView, 0, 1, false).
-		AddItem(c.InputView, 3, 1, false)
-
-	c.View.AddItem(left, 0, 1, false)
-	c.View.AddItem(right, 0, 4, false)
-
-	c.App.SetRoot(c.View, true)
-	c.App.SetFocus(c.GuildsView)
-}
-
-func (c *Core) onAppBeforeDraw(screen tcell.Screen) bool {
-	if c.Config.Theme.Background == "default" {
-		screen.Clear()
-	}
-
-	return false
-}
-
-func (c *Core) onViewInputCapture(event *tcell.EventKey) *tcell.EventKey {
-	switch event.Key() {
-	case tcell.KeyEsc:
-		c.focusedID = 0
-	case tcell.KeyBacktab:
-		// If the currently focused view is the guilds view (first), then focus the input view (last)
-		if c.focusedID == 0 {
-			c.focusedID = inputView
-		} else {
-			c.focusedID--
-		}
-
-		c.setFocus()
-	case tcell.KeyTab:
-		// If the currently focused view is the input view (last), then focus the guilds view (first)
-		if c.focusedID == inputView {
-			c.focusedID = guildsView
-		} else {
-			c.focusedID++
-		}
-
-		c.setFocus()
-	}
-
-	return event
-}
-
-func (c *Core) setFocus() {
-	var p tview.Primitive
-	switch c.focusedID {
-	case guildsView:
-		p = c.GuildsView
-	case channelsView:
-		p = c.ChannelsView
-	case messagesView:
-		p = c.MessagesView
-	case inputView:
-		p = c.InputView
-	}
-
-	c.App.SetFocus(p)
-}
-
-func (c *Core) onReady(r *gateway.ReadyEvent) {
-	root := c.GuildsView.GetRoot()
-	for _, gf := range r.UserSettings.GuildFolders {
-		if gf.ID == 0 {
-			for _, gID := range gf.GuildIDs {
-				g, err := c.State.Cabinet.Guild(gID)
-				if err != nil {
-					log.Println(err)
-					continue
-				}
-
-				guildNode := tview.NewTreeNode(g.Name)
-				guildNode.SetReference(g.ID)
-				root.AddChild(guildNode)
-			}
-		} else {
-			var b strings.Builder
-
-			if gf.Color != discord.NullColor {
-				b.WriteByte('[')
-				b.WriteString(gf.Color.String())
-				b.WriteByte(']')
-			} else {
-				b.WriteString("[#ED4245]")
-			}
-
-			if gf.Name != "" {
-				b.WriteString(gf.Name)
-			} else {
-				b.WriteString("Folder")
-			}
-
-			b.WriteString("[-]")
-
-			folderNode := tview.NewTreeNode(b.String())
-			root.AddChild(folderNode)
-
-			for _, gID := range gf.GuildIDs {
-				g, err := c.State.Cabinet.Guild(gID)
-				if err != nil {
-					log.Println(err)
-					continue
-				}
-
-				guildNode := tview.NewTreeNode(g.Name)
-				guildNode.SetReference(g.ID)
-				folderNode.AddChild(guildNode)
-			}
-		}
-
-	}
-
-	c.GuildsView.SetCurrentNode(root)
-	c.App.SetFocus(c.GuildsView)
-}
-
-func (c *Core) onGuildCreate(g *gateway.GuildCreateEvent) {
-	guildNode := tview.NewTreeNode(g.Name)
-	guildNode.SetReference(g.ID)
-
-	rootNode := c.GuildsView.GetRoot()
-	rootNode.AddChild(guildNode)
-
-	c.GuildsView.SetCurrentNode(rootNode)
-	c.App.SetFocus(c.GuildsView)
-	c.App.Draw()
-}
-
-func (c *Core) onGuildDelete(g *gateway.GuildDeleteEvent) {
-	rootNode := c.GuildsView.GetRoot()
-	var parentNode *tview.TreeNode
-	rootNode.Walk(func(node, _ *tview.TreeNode) bool {
-		if node.GetReference() == g.ID {
-			parentNode = node
-			return false
-		}
-
-		return true
-	})
-
-	if parentNode != nil {
-		rootNode.RemoveChild(parentNode)
-	}
-
-	c.App.Draw()
-}
-
-func (c *Core) onMessageCreate(m *gateway.MessageCreateEvent) {
-	if c.ChannelsView.selectedChannel != nil && c.ChannelsView.selectedChannel.ID == m.ChannelID {
-		_, err := c.MessagesView.Write(buildMessage(c, m.Message))
-		if err != nil {
-			return
-		}
-
-		if len(c.MessagesView.GetHighlights()) == 0 {
-			c.MessagesView.ScrollToEnd()
-		}
-	}
-}

+ 12 - 12
ui/guilds_view.go

@@ -7,13 +7,13 @@ import (
 
 type GuildsView struct {
 	*tview.TreeView
-	core *Core
+	app *Application
 }
 
-func newGuildsView(c *Core) *GuildsView {
+func newGuildsView(app *Application) *GuildsView {
 	v := &GuildsView{
 		TreeView: tview.NewTreeView(),
-		core:     c,
+		app:      app,
 	}
 
 	root := tview.NewTreeNode("")
@@ -32,15 +32,15 @@ func newGuildsView(c *Core) *GuildsView {
 }
 
 func (v *GuildsView) onSelected(node *tview.TreeNode) {
-	v.core.ChannelsView.selectedChannel = nil
-	v.core.MessagesView.selectedMessage = -1
-	rootNode := v.core.ChannelsView.GetRoot()
+	v.app.view.ChannelsView.selectedChannel = nil
+	v.app.view.MessagesView.selectedMessage = -1
+	rootNode := v.app.view.ChannelsView.GetRoot()
 	rootNode.ClearChildren()
-	v.core.MessagesView.
+	v.app.view.MessagesView.
 		Highlight().
 		Clear().
 		SetTitle("")
-	v.core.InputView.SetText("")
+	v.app.view.InputView.SetText("")
 
 	// If the selected node has children (guild folder), expand the selected node if it is collapsed, otherwise collapse.
 	if len(node.GetChildren()) != 0 {
@@ -51,11 +51,11 @@ func (v *GuildsView) onSelected(node *tview.TreeNode) {
 	ref := node.GetReference()
 	// If the reference of the selected node is nil, it must be the direct messages node.
 	if ref == nil {
-		v.core.ChannelsView.createPrivateChannelNodes(rootNode)
+		v.app.view.ChannelsView.createPrivateChannelNodes(rootNode)
 	} else { // Guild
-		v.core.ChannelsView.createGuildChannelNodes(rootNode, ref.(discord.GuildID))
+		v.app.view.ChannelsView.createGuildChannelNodes(rootNode, ref.(discord.GuildID))
 	}
 
-	v.core.ChannelsView.SetCurrentNode(rootNode)
-	v.core.App.SetFocus(v.core.ChannelsView)
+	v.app.view.ChannelsView.SetCurrentNode(rootNode)
+	v.app.SetFocus(v.app.view.ChannelsView)
 }

+ 18 - 18
ui/input_view.go

@@ -17,13 +17,13 @@ import (
 
 type InputView struct {
 	*tview.InputField
-	core *Core
+	app *Application
 }
 
-func newInputView(c *Core) *InputView {
+func newInputView(app *Application) *InputView {
 	v := &InputView{
 		InputField: tview.NewInputField(),
-		core:       c,
+		app:        app,
 	}
 
 	v.SetFieldBackgroundColor(tview.Styles.PrimitiveBackgroundColor)
@@ -42,16 +42,16 @@ func (v *InputView) inputCapture(event *tcell.EventKey) *tcell.EventKey {
 	switch event.Name() {
 	case "Enter":
 		return v.sendMessage()
-	case v.core.Config.Keys.InputView.OpenExternalEditor:
+	case v.app.config.Keys.InputView.OpenExternalEditor:
 		return v.openExternalEditor()
-	case v.core.Config.Keys.InputView.PasteClipboard:
+	case v.app.config.Keys.InputView.PasteClipboard:
 		return v.pasteClipboard()
 	case "Esc":
 		v.
 			SetText("").
 			SetTitle("")
-		v.core.MessagesView.selectedMessage = -1
-		v.core.MessagesView.Highlight()
+		v.app.view.MessagesView.selectedMessage = -1
+		v.app.view.MessagesView.Highlight()
 		return nil
 	}
 
@@ -59,7 +59,7 @@ func (v *InputView) inputCapture(event *tcell.EventKey) *tcell.EventKey {
 }
 
 func (v *InputView) sendMessage() *tcell.EventKey {
-	if v.core.ChannelsView.selectedChannel == nil {
+	if v.app.view.ChannelsView.selectedChannel == nil {
 		return nil
 	}
 
@@ -68,14 +68,14 @@ func (v *InputView) sendMessage() *tcell.EventKey {
 		return nil
 	}
 
-	ms, err := v.core.State.Messages(v.core.ChannelsView.selectedChannel.ID, v.core.Config.MessagesLimit)
+	ms, err := v.app.state.Messages(v.app.view.ChannelsView.selectedChannel.ID, v.app.config.MessagesLimit)
 	if err != nil {
 		log.Println(err)
 		return nil
 	}
 
-	if len(v.core.MessagesView.GetHighlights()) != 0 {
-		mID, err := discord.ParseSnowflake(v.core.MessagesView.GetHighlights()[0])
+	if len(v.app.view.MessagesView.GetHighlights()) != 0 {
+		mID, err := discord.ParseSnowflake(v.app.view.MessagesView.GetHighlights()[0])
 		if err != nil {
 			log.Println(err)
 			return nil
@@ -83,8 +83,8 @@ func (v *InputView) sendMessage() *tcell.EventKey {
 
 		_, m := findMessageByID(ms, discord.MessageID(mID))
 		d := api.SendMessageData{
-			Content:         t,
-			Reference:       &discord.MessageReference{
+			Content: t,
+			Reference: &discord.MessageReference{
 				MessageID: m.ID,
 			},
 			AllowedMentions: &api.AllowedMentions{RepliedUser: option.False},
@@ -95,14 +95,14 @@ func (v *InputView) sendMessage() *tcell.EventKey {
 			d.AllowedMentions.RepliedUser = option.True
 		}
 
-		go v.core.State.SendMessageComplex(m.ChannelID, d)
+		go v.app.state.SendMessageComplex(m.ChannelID, d)
 
-		v.core.MessagesView.selectedMessage = -1
-		v.core.MessagesView.Highlight()
+		v.app.view.MessagesView.selectedMessage = -1
+		v.app.view.MessagesView.Highlight()
 
 		v.SetTitle("")
 	} else {
-		go v.core.State.SendMessage(v.core.ChannelsView.selectedChannel.ID, t)
+		go v.app.state.SendMessage(v.app.view.ChannelsView.selectedChannel.ID, t)
 	}
 
 	v.SetText("")
@@ -139,7 +139,7 @@ func (v *InputView) openExternalEditor() *tcell.EventKey {
 	cmd.Stdin = os.Stdin
 	cmd.Stdout = os.Stdout
 
-	v.core.App.Suspend(func() {
+	v.app.Suspend(func() {
 		err = cmd.Run()
 		if err != nil {
 			log.Println(err)

+ 7 - 5
ui/login_view.go

@@ -12,13 +12,13 @@ import (
 
 type LoginView struct {
 	*tview.Form
-	core *Core
+	app *Application
 }
 
-func NewLoginView(c *Core) *LoginView {
+func newLoginView(app *Application) *LoginView {
 	v := &LoginView{
 		Form: tview.NewForm(),
-		core: c,
+		app:  app,
 	}
 
 	v.AddInputField("Email", "", 0, nil, nil)
@@ -63,11 +63,13 @@ func (v *LoginView) onLoginButtonSelected() {
 		}
 	}
 
-	err = v.core.Run(l.Token)
+	err = v.app.Connect(l.Token)
 	if err != nil {
 		log.Fatal(err)
 	}
 
-	v.core.Draw()
+	v.app.SetRoot(v.app.view, true)
+	v.app.SetFocus(v.app.view.GuildsView)
+
 	go keyring.Set(config.Name, "token", l.Token)
 }

+ 14 - 14
ui/messages_view.go

@@ -10,14 +10,14 @@ type MessagesView struct {
 	*tview.TextView
 	// The index of the currently selected message. A negative index indicates that there is no currently selected message.
 	selectedMessage int
-	core            *Core
+	app             *Application
 }
 
-func newMessagesView(c *Core) *MessagesView {
+func newMessagesView(app *Application) *MessagesView {
 	v := &MessagesView{
 		TextView:        tview.NewTextView(),
 		selectedMessage: -1,
-		core:            c,
+		app:             app,
 	}
 
 	v.SetDynamicColors(true)
@@ -25,7 +25,7 @@ func newMessagesView(c *Core) *MessagesView {
 	v.SetWordWrap(true)
 	v.SetInputCapture(v.onInputCapture)
 	v.SetChangedFunc(func() {
-		v.core.App.Draw()
+		v.app.Draw()
 	})
 
 	v.SetTitle("Messages")
@@ -37,31 +37,31 @@ func newMessagesView(c *Core) *MessagesView {
 }
 
 func (v *MessagesView) onInputCapture(e *tcell.EventKey) *tcell.EventKey {
-	if v.core.ChannelsView.selectedChannel == nil {
+	if v.app.view.ChannelsView.selectedChannel == nil {
 		return nil
 	}
 
 	// Messages should return messages ordered from latest to earliest.
-	ms, err := v.core.State.Cabinet.Messages(v.core.ChannelsView.selectedChannel.ID)
+	ms, err := v.app.state.Cabinet.Messages(v.app.view.ChannelsView.selectedChannel.ID)
 	if err != nil || len(ms) == 0 {
 		return nil
 	}
 
 	switch e.Name() {
-	case v.core.Config.Keys.MessagesView.OpenActionsView:
+	case v.app.config.Keys.MessagesView.OpenActionsView:
 		return v.openActionsView(ms)
 
-	case v.core.Config.Keys.MessagesView.SelectPreviousMessage:
+	case v.app.config.Keys.MessagesView.SelectPreviousMessage:
 		return v.selectPreviousMessage(ms)
-	case v.core.Config.Keys.MessagesView.SelectNextMessage:
+	case v.app.config.Keys.MessagesView.SelectNextMessage:
 		return v.selectNextMessage(ms)
-	case v.core.Config.Keys.MessagesView.SelectFirstMessage:
+	case v.app.config.Keys.MessagesView.SelectFirstMessage:
 		return v.selectFirstMessage(ms)
-	case v.core.Config.Keys.MessagesView.SelectLastMessage:
+	case v.app.config.Keys.MessagesView.SelectLastMessage:
 		return v.selectLastMessage(ms)
 	case "Esc":
 		v.selectedMessage = -1
-		v.core.App.SetFocus(v.core.View)
+		v.app.SetFocus(v.app.view)
 		v.
 			Clear().
 			Highlight().
@@ -141,7 +141,7 @@ func (v *MessagesView) openActionsView(ms []discord.Message) *tcell.EventKey {
 		return nil
 	}
 
-	actionsView := newActionsView(v.core, m)
-	v.core.App.SetRoot(actionsView, true)
+	actionsView := newActionsView(v.app, m)
+	v.app.SetRoot(actionsView, true)
 	return nil
 }

+ 98 - 0
ui/view.go

@@ -0,0 +1,98 @@
+package ui
+
+import (
+	"github.com/gdamore/tcell/v2"
+	"github.com/rivo/tview"
+)
+
+type FocusedID int
+
+const (
+	guildsView FocusedID = iota
+	channelsView
+	messagesView
+	inputView
+)
+
+type View struct {
+	*tview.Flex
+
+	GuildsView   *GuildsView
+	ChannelsView *ChannelsView
+	MessagesView *MessagesView
+	InputView    *InputView
+
+	app     *Application
+	focused FocusedID
+}
+
+func newView(app *Application) *View {
+	v := &View{
+		Flex:         tview.NewFlex(),
+		GuildsView:   newGuildsView(app),
+		ChannelsView: newChannelsView(app),
+		MessagesView: newMessagesView(app),
+		InputView:    newInputView(app),
+
+		app: app,
+	}
+
+	left := tview.NewFlex().
+		SetDirection(tview.FlexRow).
+		AddItem(v.GuildsView, 10, 1, false).
+		AddItem(v.ChannelsView, 0, 1, false)
+	right := tview.NewFlex().
+		SetDirection(tview.FlexRow).
+		AddItem(v.MessagesView, 0, 1, false).
+		AddItem(v.InputView, 3, 1, false)
+
+	v.AddItem(left, 0, 1, false)
+	v.AddItem(right, 0, 4, false)
+
+	v.SetInputCapture(v.onInputCapture)
+
+	return v
+}
+
+func (v *View) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
+	switch event.Key() {
+	case tcell.KeyEsc:
+		v.focused = 0
+	case tcell.KeyBacktab:
+		// If the currently focused view is the guilds view (first), then focus the input view (last)
+		if v.focused == 0 {
+			v.focused = inputView
+		} else {
+			v.focused--
+		}
+
+		v.setFocus()
+	case tcell.KeyTab:
+		// If the currently focused view is the input view (last), then focus the guilds view (first)
+		if v.focused == inputView {
+			v.focused = guildsView
+		} else {
+			v.focused++
+		}
+
+		v.setFocus()
+	}
+
+	return event
+}
+
+func (v *View) setFocus() {
+	var p tview.Primitive
+	switch v.focused {
+	case guildsView:
+		p = v.GuildsView
+	case channelsView:
+		p = v.ChannelsView
+	case messagesView:
+		p = v.MessagesView
+	case inputView:
+		p = v.InputView
+	}
+
+	v.app.SetFocus(p)
+}