From 770eb43e52bf53177d7c14f658b277ebd66099bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=83=9E=E3=83=AA=E3=82=A6=E3=82=B9?= Date: Wed, 28 Dec 2022 22:22:36 -0500 Subject: [PATCH] Implemented first draft structure --- gobbs.go | 25 ++++ models/post/post.go | 19 +++ system/adapter/adapter.go | 6 + system/discourse/discourse.go | 36 ++++++ system/lemmy/lemmy.go | 36 ++++++ system/system.go | 38 ++++++ ui/ctx/ctx.go | 23 ++++ ui/navigation/navigation.go | 151 ++++++++++++++++++++++ ui/ui.go | 237 ++++++++++++++++++++++++++++++++++ ui/views/posts/posts.go | 195 ++++++++++++++++++++++++++++ ui/views/views.go | 10 ++ 11 files changed, 776 insertions(+) create mode 100644 gobbs.go create mode 100644 models/post/post.go create mode 100644 system/adapter/adapter.go create mode 100644 system/discourse/discourse.go create mode 100644 system/lemmy/lemmy.go create mode 100644 system/system.go create mode 100644 ui/ctx/ctx.go create mode 100644 ui/navigation/navigation.go create mode 100644 ui/ui.go create mode 100644 ui/views/posts/posts.go create mode 100644 ui/views/views.go diff --git a/gobbs.go b/gobbs.go new file mode 100644 index 0000000..55daeb3 --- /dev/null +++ b/gobbs.go @@ -0,0 +1,25 @@ +package main + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/mrusme/gobbs/system" + "github.com/mrusme/gobbs/ui" + "github.com/mrusme/gobbs/ui/ctx" +) + +func main() { + c := ctx.New() + + discourse, err := system.New("discourse", nil) + if err != nil { + panic(err) + } + + c.AddSystem(&discourse) + + tui := tea.NewProgram(ui.NewModel(&c), tea.WithAltScreen()) + err = tui.Start() + if err != nil { + panic(err) + } +} diff --git a/models/post/post.go b/models/post/post.go new file mode 100644 index 0000000..53b3f0b --- /dev/null +++ b/models/post/post.go @@ -0,0 +1,19 @@ +package post + +type Post struct { + ID string + + Subject string +} + +func (post Post) FilterValue() string { + return post.Subject +} + +func (post Post) Title() string { + return post.Subject +} + +func (post Post) Description() string { + return post.ID +} diff --git a/system/adapter/adapter.go b/system/adapter/adapter.go new file mode 100644 index 0000000..bc4f194 --- /dev/null +++ b/system/adapter/adapter.go @@ -0,0 +1,6 @@ +package adapter + +type Capability struct { + ID string + Name string +} diff --git a/system/discourse/discourse.go b/system/discourse/discourse.go new file mode 100644 index 0000000..7124e52 --- /dev/null +++ b/system/discourse/discourse.go @@ -0,0 +1,36 @@ +package discourse + +import ( + "github.com/mrusme/gobbs/models/post" + "github.com/mrusme/gobbs/system/adapter" +) + +type System struct { +} + +func (sys *System) Load() error { + return nil +} + +func (sys *System) ListPosts() ([]post.Post, error) { + return []post.Post{}, nil +} + +func (sys *System) GetCapabilities() []adapter.Capability { + var caps []adapter.Capability + + caps = append(caps, adapter.Capability{ + ID: "posts", + Name: "Posts", + }) + caps = append(caps, adapter.Capability{ + ID: "groups", + Name: "Groups", + }) + caps = append(caps, adapter.Capability{ + ID: "search", + Name: "Search", + }) + + return caps +} diff --git a/system/lemmy/lemmy.go b/system/lemmy/lemmy.go new file mode 100644 index 0000000..be88895 --- /dev/null +++ b/system/lemmy/lemmy.go @@ -0,0 +1,36 @@ +package lemmy + +import ( + "github.com/mrusme/gobbs/models/post" + "github.com/mrusme/gobbs/system/adapter" +) + +type System struct { +} + +func (sys *System) Load() error { + return nil +} + +func (sys *System) ListPosts() ([]post.Post, error) { + return []post.Post{}, nil +} + +func (sys *System) GetCapabilities() []adapter.Capability { + var caps []adapter.Capability + + caps = append(caps, adapter.Capability{ + ID: "posts", + Name: "Posts", + }) + caps = append(caps, adapter.Capability{ + ID: "groups", + Name: "Groups", + }) + caps = append(caps, adapter.Capability{ + ID: "search", + Name: "Search", + }) + + return caps +} diff --git a/system/system.go b/system/system.go new file mode 100644 index 0000000..9b4b147 --- /dev/null +++ b/system/system.go @@ -0,0 +1,38 @@ +package system + +import ( + "errors" + + "github.com/mrusme/gobbs/models/post" + "github.com/mrusme/gobbs/system/adapter" + "github.com/mrusme/gobbs/system/discourse" + "github.com/mrusme/gobbs/system/lemmy" +) + +type System interface { + GetCapabilities() []adapter.Capability + + Load() error + + ListPosts() ([]post.Post, error) +} + +func New(sysType string, sysConfig *map[string]interface{}) (System, error) { + var sys System + + switch sysType { + case "discourse": + sys = new(discourse.System) + case "lemmy": + sys = new(lemmy.System) + default: + return nil, errors.New("No such system") + } + + err := sys.Load() + if err != nil { + return nil, err + } + + return sys, nil +} diff --git a/ui/ctx/ctx.go b/ui/ctx/ctx.go new file mode 100644 index 0000000..7af9cdb --- /dev/null +++ b/ui/ctx/ctx.go @@ -0,0 +1,23 @@ +package ctx + +import "github.com/mrusme/gobbs/system" + +type Ctx struct { + Screen [2]int + Content [2]int + Systems []*system.System + Loading bool +} + +func New() Ctx { + return Ctx{ + Screen: [2]int{0, 0}, + Content: [2]int{0, 0}, + Loading: false, + } +} + +func (c *Ctx) AddSystem(sys *system.System) error { + c.Systems = append(c.Systems, sys) + return nil +} diff --git a/ui/navigation/navigation.go b/ui/navigation/navigation.go new file mode 100644 index 0000000..9c51885 --- /dev/null +++ b/ui/navigation/navigation.go @@ -0,0 +1,151 @@ +package navigation + +import ( + "strings" + + "github.com/mrusme/gobbs/ui/ctx" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + highlight = lipgloss.AdaptiveColor{Light: "#874BFD", Dark: "#7D56F4"} + + activeTabBorder = lipgloss.Border{ + Top: "─", + Bottom: " ", + Left: "│", + Right: "│", + TopLeft: "╭", + TopRight: "╮", + BottomLeft: "┘", + BottomRight: "└", + } + + tabBorder = lipgloss.Border{ + Top: "─", + Bottom: "─", + Left: "│", + Right: "│", + TopLeft: "╭", + TopRight: "╮", + BottomLeft: "┴", + BottomRight: "┴", + } + + tab = lipgloss.NewStyle(). + Border(tabBorder, true). + BorderForeground(highlight). + Padding(0, 1) + + activeTab = tab.Copy().Border(activeTabBorder, true) + + tabGap = tab.Copy(). + BorderTop(false). + BorderLeft(false). + BorderRight(false) +) + +var Navigation = []string{} + +type Model struct { + CurrentId int + ctx *ctx.Ctx + spinner spinner.Model +} + +func NewModel(c *ctx.Ctx) Model { + m := Model{ + CurrentId: 0, + ctx: c, + } + + m.spinner = spinner.New() + m.spinner.Spinner = spinner.Dot + m.spinner.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) + + return m +} + +func (m Model) Init() tea.Cmd { + return m.spinner.Tick +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + var cmds []tea.Cmd + + if m.ctx.Loading == true { + cmds = append(cmds, m.spinner.Tick) + } + + switch msg := msg.(type) { + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) +} + +func (m Model) View() string { + var items []string + + for i, nav := range Navigation { + if m.CurrentId == i { + items = append(items, activeTab.Render(nav)) + } else { + items = append(items, tab.Render(nav)) + } + } + + row := lipgloss.JoinHorizontal( + lipgloss.Top, + items..., + ) + + if m.ctx.Loading == false { + gap := tabGap.Render(strings.Repeat(" ", max(0, m.ctx.Screen[0]-lipgloss.Width(row)-2))) + row = lipgloss.JoinHorizontal(lipgloss.Bottom, row, gap) + } else { + gap := tabGap.Render(strings.Repeat(" ", max(0, m.ctx.Screen[0]-lipgloss.Width(row)-4))) + row = lipgloss.JoinHorizontal(lipgloss.Bottom, row, gap, " ", m.spinner.View()) + } + + return lipgloss.JoinHorizontal(lipgloss.Top, row, "\n\n") +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func (m *Model) NthTab(nth int) { + if nth > len(Navigation) { + nth = len(Navigation) + } else if nth < 1 { + nth = 1 + } + + m.CurrentId = nth - 1 +} + +func (m *Model) PrevTab() { + m.CurrentId-- + + if m.CurrentId < 0 { + m.CurrentId = len(Navigation) - 1 + } +} + +func (m *Model) NextTab() { + m.CurrentId++ + + if m.CurrentId >= len(Navigation) { + m.CurrentId = 0 + } +} diff --git a/ui/ui.go b/ui/ui.go new file mode 100644 index 0000000..01f3384 --- /dev/null +++ b/ui/ui.go @@ -0,0 +1,237 @@ +package ui + +import ( + // "fmt" + + "strings" + + "github.com/mrusme/gobbs/ui/ctx" + "github.com/mrusme/gobbs/ui/navigation" + + "github.com/mrusme/gobbs/ui/views" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" +) + +type KeyMap struct { + FirstTab key.Binding + SecondTab key.Binding + ThirdTab key.Binding + FourthTab key.Binding + FifthTab key.Binding + SixthTab key.Binding + SeventhTab key.Binding + EightTab key.Binding + NinthTab key.Binding + TenthTab key.Binding + EleventhTab key.Binding + TwelfthTab key.Binding + ThirteenthTab key.Binding + PrevTab key.Binding + NextTab key.Binding + Up key.Binding + Down key.Binding + Quit key.Binding +} + +var DefaultKeyMap = KeyMap{ + FirstTab: key.NewBinding( + key.WithKeys("f1"), + key.WithHelp("f1", "first tab"), + ), + SecondTab: key.NewBinding( + key.WithKeys("f2"), + key.WithHelp("f2", "second tab"), + ), + ThirdTab: key.NewBinding( + key.WithKeys("f3"), + key.WithHelp("f3", "third tab"), + ), + FourthTab: key.NewBinding( + key.WithKeys("f4"), + key.WithHelp("f4", "fourth tab"), + ), + FifthTab: key.NewBinding( + key.WithKeys("f5"), + key.WithHelp("f5", "fifth tab"), + ), + SixthTab: key.NewBinding( + key.WithKeys("f6"), + key.WithHelp("f6", "sixth tab"), + ), + SeventhTab: key.NewBinding( + key.WithKeys("f7"), + key.WithHelp("f7", "seventh tab"), + ), + EightTab: key.NewBinding( + key.WithKeys("f8"), + key.WithHelp("f8", "eight tab"), + ), + NinthTab: key.NewBinding( + key.WithKeys("f9"), + key.WithHelp("f9", "ninth tab"), + ), + TenthTab: key.NewBinding( + key.WithKeys("f10"), + key.WithHelp("f10", "tenth tab"), + ), + EleventhTab: key.NewBinding( + key.WithKeys("f11"), + key.WithHelp("f11", "eleventh tab"), + ), + TwelfthTab: key.NewBinding( + key.WithKeys("f12"), + key.WithHelp("f12", "twelfth tab"), + ), + ThirteenthTab: key.NewBinding( + key.WithKeys("f13"), + key.WithHelp("f13", "thirteenth tab"), + ), + PrevTab: key.NewBinding( + key.WithKeys("ctrl+p"), + key.WithHelp("ctrl+p", "previous tab"), + ), + NextTab: key.NewBinding( + key.WithKeys("ctrl+n"), + key.WithHelp("ctrl+n", "next tab"), + ), + Up: key.NewBinding( + key.WithKeys("k", "up"), + key.WithHelp("↑/k", "move up"), + ), + Down: key.NewBinding( + key.WithKeys("j", "down"), + key.WithHelp("↓/j", "move down"), + ), + Quit: key.NewBinding( + key.WithKeys("q", "ctrl+q"), + key.WithHelp("q/Q", "quit"), + ), +} + +type Model struct { + keymap KeyMap + nav navigation.Model + views []views.View + ctx *ctx.Ctx +} + +func NewModel(c *ctx.Ctx) Model { + m := Model{ + keymap: DefaultKeyMap, + ctx: c, + } + + m.nav = navigation.NewModel(m.ctx) + + return m +} + +func (m Model) Init() tea.Cmd { + return tea.Batch(tea.EnterAltScreen) +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + cmds := make([]tea.Cmd, 0) + + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, m.keymap.Quit): + return m, tea.Quit + + case key.Matches(msg, m.keymap.FirstTab): + m.nav.NthTab(1) + return m, nil + + case key.Matches(msg, m.keymap.SecondTab): + m.nav.NthTab(2) + return m, nil + + case key.Matches(msg, m.keymap.ThirdTab): + m.nav.NthTab(3) + return m, nil + + case key.Matches(msg, m.keymap.FourthTab): + m.nav.NthTab(4) + return m, nil + + case key.Matches(msg, m.keymap.FifthTab): + m.nav.NthTab(5) + return m, nil + + case key.Matches(msg, m.keymap.SixthTab): + m.nav.NthTab(6) + return m, nil + + case key.Matches(msg, m.keymap.SeventhTab): + m.nav.NthTab(7) + return m, nil + + case key.Matches(msg, m.keymap.EightTab): + m.nav.NthTab(8) + return m, nil + + case key.Matches(msg, m.keymap.NinthTab): + m.nav.NthTab(9) + return m, nil + + case key.Matches(msg, m.keymap.TenthTab): + m.nav.NthTab(10) + return m, nil + + case key.Matches(msg, m.keymap.EleventhTab): + m.nav.NthTab(11) + return m, nil + + case key.Matches(msg, m.keymap.TwelfthTab): + m.nav.NthTab(12) + return m, nil + + case key.Matches(msg, m.keymap.ThirteenthTab): + m.nav.NthTab(13) + return m, nil + + case key.Matches(msg, m.keymap.PrevTab): + m.nav.PrevTab() + return m, nil + + case key.Matches(msg, m.keymap.NextTab): + m.nav.NextTab() + return m, nil + } + + case tea.WindowSizeMsg: + m.setSizes(msg.Width, msg.Height) + for i := range m.views { + v, cmd := m.views[i].Update(msg) + m.views[i] = v + cmds = append(cmds, cmd) + } + } + + v, cmd := m.views[m.nav.CurrentId].Update(msg) + m.views[m.nav.CurrentId] = v + cmds = append(cmds, cmd) + + nav, cmd := m.nav.Update(msg) + m.nav = nav + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +func (m Model) View() string { + s := strings.Builder{} + s.WriteString(m.nav.View() + "\n\n") + s.WriteString(m.views[m.nav.CurrentId].View()) + return s.String() +} + +func (m Model) setSizes(winWidth int, winHeight int) { + (*m.ctx).Screen[0] = winWidth + (*m.ctx).Screen[1] = winHeight + m.ctx.Content[0] = m.ctx.Screen[0] + m.ctx.Content[1] = m.ctx.Screen[1] - 5 +} diff --git a/ui/views/posts/posts.go b/ui/views/posts/posts.go new file mode 100644 index 0000000..7ce3ec9 --- /dev/null +++ b/ui/views/posts/posts.go @@ -0,0 +1,195 @@ +package posts + +import ( + "fmt" + "math" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/mrusme/gobbs/models/post" + "github.com/mrusme/gobbs/ui/ctx" +) + +var ( + listStyle = lipgloss.NewStyle(). + Margin(0, 0, 0, 0). + Padding(1, 1). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#874BFD")). + BorderTop(true). + BorderLeft(true). + BorderRight(true). + BorderBottom(true) + + viewportStyle = lipgloss.NewStyle(). + Margin(0, 0, 0, 0). + Padding(1, 1). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#874BFD")). + BorderTop(true). + BorderLeft(true). + BorderRight(true). + BorderBottom(true) +) + +type KeyMap struct { + Refresh key.Binding + Select key.Binding + SwitchFocus key.Binding +} + +var DefaultKeyMap = KeyMap{ + Refresh: key.NewBinding( + key.WithKeys("r", "R"), + key.WithHelp("r/R", "refresh"), + ), + Select: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "select"), + ), + SwitchFocus: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "switch focus"), + ), +} + +type Model struct { + keymap KeyMap + list list.Model + items []list.Item + viewport viewport.Model + ctx *ctx.Ctx + + focused int + focusables [2]tea.Model +} + +func (m Model) Init() tea.Cmd { + return nil +} + +func NewModel(c *ctx.Ctx) Model { + m := Model{ + keymap: DefaultKeyMap, + focused: 0, + } + + // m.focusables = append(m.focusables, m.list) + // m.focusables = append(m.focusables, m.viewport) + + m.list = list.New(m.items, list.NewDefaultDelegate(), 0, 0) + m.list.Title = "Posts" + m.ctx = c + + return m +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, m.keymap.Refresh): + m.ctx.Loading = true + cmds = append(cmds, m.refresh()) + + case key.Matches(msg, m.keymap.SwitchFocus): + m.focused++ + if m.focused >= len(m.focusables) { + m.focused = 0 + } + // return m, nil + + case key.Matches(msg, m.keymap.Select): + i, ok := m.list.SelectedItem().(post.Post) + if ok { + m.viewport.SetContent(m.renderViewport(&i)) + return m, nil + } + } + + case tea.WindowSizeMsg: + listWidth := int(math.Floor(float64(m.ctx.Content[0]) / 4.0)) + listHeight := m.ctx.Content[1] - 1 + viewportWidth := m.ctx.Content[0] - listWidth - 4 + viewportHeight := m.ctx.Content[1] - 1 + + listStyle.Width(listWidth) + listStyle.Height(listHeight) + m.list.SetSize( + listWidth-2, + listHeight-2, + ) + + viewportStyle.Width(viewportWidth) + viewportStyle.Height(viewportHeight) + m.viewport = viewport.New(viewportWidth-4, viewportHeight-4) + m.viewport.Width = viewportWidth - 4 + m.viewport.Height = viewportHeight - 4 + // cmds = append(cmds, viewport.Sync(m.viewport)) + + case []list.Item: + m.items = msg + m.list.SetItems(m.items) + m.ctx.Loading = false + } + + var cmd tea.Cmd + + if m.focused == 0 { + listStyle.BorderForeground(lipgloss.Color("#FFFFFF")) + viewportStyle.BorderForeground(lipgloss.Color("#874BFD")) + m.list, cmd = m.list.Update(msg) + cmds = append(cmds, cmd) + } else if m.focused == 1 { + listStyle.BorderForeground(lipgloss.Color("#874BFD")) + viewportStyle.BorderForeground(lipgloss.Color("#FFFFFF")) + m.viewport, cmd = m.viewport.Update(msg) + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) +} + +func (m Model) View() string { + var view string + + view = lipgloss.JoinHorizontal( + lipgloss.Top, + listStyle.Render(m.list.View()), + viewportStyle.Render(m.viewport.View()), + ) + + return view +} + +func (m *Model) refresh() tea.Cmd { + return func() tea.Msg { + var items []list.Item + + posts, err := (*m.ctx.Systems[0]).ListPosts() + if err != nil { + fmt.Printf("%s", err) // TODO: Implement error message + } + for _, post := range posts { + items = append(items, post) + } + + return items + } +} + +func (m *Model) renderViewport(post *post.Post) string { + var vp string = "" + + vp = fmt.Sprintf( + "%s\n", + post.Subject, + ) + + return vp +} diff --git a/ui/views/views.go b/ui/views/views.go new file mode 100644 index 0000000..5eac9f4 --- /dev/null +++ b/ui/views/views.go @@ -0,0 +1,10 @@ +package views + +import ( + tea "github.com/charmbracelet/bubbletea" +) + +type View interface { + View() string + Update(msg tea.Msg) (tea.Model, tea.Cmd) +}