diff --git a/main.go b/main.go index 52c970e..b29f530 100644 --- a/main.go +++ b/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) } diff --git a/ui/actions_view.go b/ui/actions_view.go index 3584947..1baec30 100644 --- a/ui/actions_view.go +++ b/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) } diff --git a/ui/application.go b/ui/application.go new file mode 100644 index 0000000..331bb5e --- /dev/null +++ b/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() + } + } +} diff --git a/ui/builder.go b/ui/builder.go index 75f351e..547019b 100644 --- a/ui/builder.go +++ b/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)[::-]") diff --git a/ui/channels_view.go b/ui/channels_view.go index 62c8ada..dae1108 100644 --- a/ui/channels_view.go +++ b/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 diff --git a/ui/core.go b/ui/core.go deleted file mode 100644 index dfdb986..0000000 --- a/ui/core.go +++ /dev/null @@ -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() - } - } -} diff --git a/ui/guilds_view.go b/ui/guilds_view.go index 8976658..9fed5dd 100644 --- a/ui/guilds_view.go +++ b/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) } diff --git a/ui/input_view.go b/ui/input_view.go index 7d8e64b..2c2ccfe 100644 --- a/ui/input_view.go +++ b/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) diff --git a/ui/login_view.go b/ui/login_view.go index c4dc68f..0764fb8 100644 --- a/ui/login_view.go +++ b/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) } diff --git a/ui/messages_view.go b/ui/messages_view.go index b31a238..1fdfa69 100644 --- a/ui/messages_view.go +++ b/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 } diff --git a/ui/view.go b/ui/view.go new file mode 100644 index 0000000..99834a5 --- /dev/null +++ b/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) +}