From f60810005040c0db4cb0a487fcc81bac5287555a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=83=9E=E3=83=AA=E3=82=A6=E3=82=B9?= Date: Mon, 2 Jan 2023 15:29:29 -0500 Subject: [PATCH] Enhanced UI implementation --- ui/cmd/cmd.go | 2 + ui/ui.go | 13 +- ui/views/posts/posts.go | 48 ++- ui/windowmanager/windowmanager.go | 24 +- .../postcreatedialog/postcreatedialog.go | 340 ++++++++++++++++++ ui/windows/postdialog/postdialog.go | 50 ++- 6 files changed, 426 insertions(+), 51 deletions(-) create mode 100644 ui/windows/postcreatedialog/postcreatedialog.go diff --git a/ui/cmd/cmd.go b/ui/cmd/cmd.go index 1ed75bf..55b4167 100644 --- a/ui/cmd/cmd.go +++ b/ui/cmd/cmd.go @@ -9,10 +9,12 @@ const ( WinFocus WinBlur WinRefreshData + WinFreshData ViewFocus ViewBlur ViewRefreshData + ViewFreshData ) type Arg struct { diff --git a/ui/ui.go b/ui/ui.go index aedc43a..bf688e9 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -15,6 +15,7 @@ import ( "github.com/mrusme/gobbs/ui/views" "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" ) @@ -109,20 +110,24 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { msg.Target, postdialog.NewModel(m.ctx), [4]int{3, 2, 10, 6}, - msg.GetArgs()..., + &msg, ) m.ctx.Logger.Debugf("got back ccmds: %v\n", ccmds) default: - m.ctx.Logger.Debugf("updating all with cmd: %v\n", msg) - ccmds = m.wm.UpdateAll(msg) + if msg.Call < cmd.ViewFocus { + m.ctx.Logger.Debugf("updating all with cmd: %v\n", msg) + ccmds = m.wm.UpdateAll(msg) + } } cmds = append(cmds, ccmds...) + case spinner.TickMsg: + // Do nothing + default: m.ctx.Logger.Debugf("updating all with default: %v\n", msg) cmds = append(cmds, m.wm.UpdateAll(msg)...) - } v, vcmd := m.views[m.currentView].Update(msg) diff --git a/ui/views/posts/posts.go b/ui/views/posts/posts.go index e749f9c..f6ffe1c 100644 --- a/ui/views/posts/posts.go +++ b/ui/views/posts/posts.go @@ -15,16 +15,19 @@ import ( "github.com/mrusme/gobbs/models/reply" "github.com/mrusme/gobbs/ui/cmd" "github.com/mrusme/gobbs/ui/ctx" + "github.com/mrusme/gobbs/ui/windows/postdialog" ) var ( + VIEW_ID = "posts" + viewportStyle = lipgloss.NewStyle(). - Margin(0, 0, 0, 0). - Padding(0, 0). - BorderTop(false). - BorderLeft(false). - BorderRight(false). - BorderBottom(false) + Margin(0, 0, 0, 0). + Padding(0, 0). + BorderTop(false). + BorderLeft(false). + BorderRight(false). + BorderBottom(false) ) type KeyMap struct { @@ -145,7 +148,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if ok { // m.ctx.Loading = true // cmds = append(cmds, m.loadItem(&i)) - cmd := cmd.New(cmd.WinOpen, "post", cmd.Arg{ + cmd := cmd.New(cmd.WinOpen, postdialog.WIN_ID, cmd.Arg{ Name: "post", Value: &i, }) @@ -265,12 +268,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // m.viewport.Height = viewportHeight + 1 // // cmds = append(cmds, viewport.Sync(m.viewport)) - case []list.Item: - m.items = msg - m.list.SetItems(m.items) - m.ctx.Loading = false - return m, nil - // case *post.Post: // m.viewport.SetContent(m.renderViewport(msg)) // m.WMOpen("post") @@ -280,20 +277,31 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case cmd.Command: switch msg.Call { case cmd.ViewFocus: - if msg.Target == "*" { + if msg.Target == VIEW_ID || + msg.Target == "*" { m.focused = true } return m, nil case cmd.ViewBlur: - if msg.Target == "*" { + if msg.Target == VIEW_ID || + msg.Target == "*" { m.focused = false } return m, nil case cmd.ViewRefreshData: - if msg.Target == "*" { + if msg.Target == VIEW_ID || + msg.Target == "*" { m.ctx.Loading = true cmds = append(cmds, m.refresh()) } + case cmd.ViewFreshData: + if msg.Target == VIEW_ID || + msg.Target == "*" { + m.items = msg.GetArg("items").([]list.Item) + m.list.SetItems(m.items) + m.ctx.Loading = false + return m, nil + } } } @@ -328,7 +336,13 @@ func (m *Model) refresh() tea.Cmd { items = append(items, post) } - return items + c := cmd.New( + cmd.ViewFreshData, + VIEW_ID, + cmd.Arg{Name: "items", Value: items}, + ) + + return *c } } diff --git a/ui/windowmanager/windowmanager.go b/ui/windowmanager/windowmanager.go index 534f42f..aebee2d 100644 --- a/ui/windowmanager/windowmanager.go +++ b/ui/windowmanager/windowmanager.go @@ -23,7 +23,7 @@ func New() *WM { return wm } -func (wm *WM) Open(id string, win windows.Window, xywh [4]int, args ...cmd.Arg) []tea.Cmd { +func (wm *WM) Open(id string, win windows.Window, xywh [4]int, command *cmd.Command) []tea.Cmd { var tcmds []tea.Cmd if wm.IsOpen(id) { @@ -40,18 +40,18 @@ func (wm *WM) Open(id string, win windows.Window, xywh [4]int, args ...cmd.Arg) wm.stack = append(wm.stack, *item) - tcmds = append(tcmds, wm.Update(id, tea.WindowSizeMsg{ - Width: item.XYWH[2], - Height: item.XYWH[3], - })) - tcmds = append(tcmds, wm.Update(id, *cmd.New( - cmd.WinRefreshData, - id, - args..., - ))) + // tcmds = append(tcmds, wm.Update(id, *command)) + // tcmds = append(tcmds, wm.Update(id, tea.WindowSizeMsg{ + // Width: item.XYWH[2], + // Height: item.XYWH[3], + // })) + // tcmds = append(tcmds, wm.Update(id, *cmd.New( + // cmd.WinRefreshData, + // id, + // ))) - fcmds := wm.Focus(id) - tcmds = append(tcmds, fcmds...) + // fcmds := wm.Focus(id) + // tcmds = append(tcmds, fcmds...) return tcmds } diff --git a/ui/windows/postcreatedialog/postcreatedialog.go b/ui/windows/postcreatedialog/postcreatedialog.go new file mode 100644 index 0000000..806f175 --- /dev/null +++ b/ui/windows/postcreatedialog/postcreatedialog.go @@ -0,0 +1,340 @@ +package postcreatedialog + +import ( + "fmt" + "strconv" + "strings" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/lipgloss" + "github.com/mrusme/gobbs/aggregator" + "github.com/mrusme/gobbs/models/post" + "github.com/mrusme/gobbs/models/reply" + "github.com/mrusme/gobbs/ui/cmd" + "github.com/mrusme/gobbs/ui/ctx" + "github.com/mrusme/gobbs/ui/helpers" +) + +type KeyMap struct { + Refresh key.Binding + Select key.Binding + Esc key.Binding + Quit key.Binding + Reply key.Binding +} + +var DefaultKeyMap = KeyMap{ + Refresh: key.NewBinding( + key.WithKeys("ctrl+r"), + key.WithHelp("ctrl+r", "refresh"), + ), + Select: key.NewBinding( + key.WithKeys("r", "enter"), + key.WithHelp("r/enter", "read"), + ), + Esc: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "close"), + ), + Quit: key.NewBinding( + key.WithKeys("q"), + ), + Reply: key.NewBinding( + key.WithKeys("ctrl+s"), + key.WithHelp("ctrl+s", "reply"), + ), +} + +type Model struct { + ctx *ctx.Ctx + keymap KeyMap + textarea textarea.Model + focused bool + + a *aggregator.Aggregator + glam *glamour.TermRenderer + + activeReply *reply.Reply +} + +func (m Model) Init() tea.Cmd { + return nil +} + +func (m Model) Focus() { + m.focused = true +} + +func (m Model) Blur() { + m.focused = false +} + +func NewModel(c *ctx.Ctx) Model { + m := Model{ + ctx: c, + keymap: DefaultKeyMap, + } + + m.a, _ = aggregator.New(m.ctx) + + 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.Reply): + replyToIdx, _ := strconv.Atoi(m.buffer) + + m.ctx.Logger.Debugf("replyToIdx: %d", replyToIdx) + + var irtID string = "" + var irtIRT string = "" + var irtSysIDX int = 0 + + if replyToIdx == 0 { + irtID = m.activePost.ID + irtSysIDX = m.activePost.SysIDX + } else { + irt := m.allReplies[(replyToIdx - 1)] + irtID = strconv.Itoa(replyToIdx + 1) + irtIRT = irt.InReplyTo + irtSysIDX = irt.SysIDX + } + + r := reply.Reply{ + ID: irtID, + InReplyTo: irtIRT, + Body: m.textarea.Value(), + SysIDX: irtSysIDX, + } + err := m.a.CreateReply(&r) + if err != nil { + m.ctx.Logger.Error(err) + } + + m.textarea.Reset() + m.buffer = "" + m.WMClose("reply") + return m, nil + + } + + case tea.WindowSizeMsg: + m.ctx.Logger.Debug("received WindowSizeMsg") + viewportWidth := m.ctx.Content[0] - 9 + viewportHeight := m.ctx.Content[1] - 10 + + viewportStyle.Width(viewportWidth) + viewportStyle.Height(viewportHeight) + m.viewport = viewport.New(viewportWidth-4, viewportHeight-4) + m.viewport.Width = viewportWidth - 4 + m.viewport.Height = viewportHeight + 1 + // cmds = append(cmds, viewport.Sync(m.viewport)) + + case *post.Post: + m.ctx.Logger.Debug("got *post.Post") + m.activePost = msg + m.viewport.SetContent(m.renderViewport(m.activePost)) + m.ctx.Loading = false + return m, nil + + case cmd.Command: + m.ctx.Logger.Debugf("got command: %v\n", msg) + switch msg.Call { + case cmd.WinRefreshData: + if msg.Target == "post" { + m.activePost = msg.GetArg("post").(*post.Post) + m.ctx.Logger.Debugf("loading post: %v", m.activePost.ID) + m.ctx.Loading = true + return m, m.loadPost(m.activePost) + } + return m, nil + case cmd.WinFocus: + if msg.Target == "post" { + m.focused = true + } + return m, nil + case cmd.WinBlur: + if msg.Target == "post" { + m.focused = false + } + return m, nil + default: + m.ctx.Logger.Debugf("received unhandled command: %v\n", msg) + } + + default: + m.ctx.Logger.Debugf("received unhandled msg: %v\n", msg) + } + + var cmd tea.Cmd + + m.viewport, cmd = m.viewport.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +func (m *Model) loadPost(p *post.Post) tea.Cmd { + return func() tea.Msg { + m.ctx.Logger.Debug("------ EXECUTED -----") + if err := m.a.LoadPost(p); err != nil { + m.ctx.Logger.Error(err) + } + return p + } +} + +func (m Model) View() string { + return m.buildView(true) +} + +func (m Model) buildView(cached bool) string { + var view strings.Builder = strings.Builder{} + + var l string = "" + view.WriteString(lipgloss.JoinHorizontal( + lipgloss.Top, + l, + )) + + var style lipgloss.Style + if m.focused { + style = m.ctx.Theme.DialogBox.Titlebar.Focused + } else { + style = m.ctx.Theme.DialogBox.Titlebar.Blurred + } + titlebar := style.Align(lipgloss.Center). + Width(m.viewport.Width + 4). + Render("Post") + + bottombar := m.ctx.Theme.DialogBox.Bottombar. + Width(m.viewport.Width + 4). + Render("[#]r reply ยท esc close") + + ui := lipgloss.JoinVertical( + lipgloss.Center, + titlebar, + viewportStyle.Render(m.viewport.View()), + bottombar, + ) + + var tmp string + if m.focused { + tmp = helpers.PlaceOverlay(3, 2, + m.ctx.Theme.DialogBox.Window.Focused.Render(ui), + view.String(), true) + } else { + tmp = helpers.PlaceOverlay(3, 2, + m.ctx.Theme.DialogBox.Window.Blurred.Render(ui), + view.String(), true) + } + + view = strings.Builder{} + view.WriteString(tmp) + + return view.String() +} + +func (m *Model) renderViewport(p *post.Post) string { + var out string = "" + + var err error + m.glam, err = glamour.NewTermRenderer( + glamour.WithAutoStyle(), + glamour.WithWordWrap(m.viewport.Width), + ) + if err != nil { + m.ctx.Logger.Error(err) + m.glam = nil + } + + adj := "writes" + if p.Subject[len(p.Subject)-1:] == "?" { + adj = "asks" + } + + body, err := m.glam.Render(p.Body) + if err != nil { + m.ctx.Logger.Error(err) + body = p.Body + } + out += fmt.Sprintf( + " %s\n\n %s\n%s", + m.ctx.Theme.Post.Author.Render( + fmt.Sprintf("%s %s:", p.Author.Name, adj), + ), + m.ctx.Theme.Post.Subject.Render(p.Subject), + body, + ) + + m.replyIDs = []string{p.ID} + m.activePost = p + out += m.renderReplies(0, p.Author.Name, &p.Replies) + + return out +} + +func (m *Model) renderReplies( + level int, + inReplyTo string, + replies *[]reply.Reply, +) string { + var out string = "" + + if replies == nil { + return "" + } + + for ri, re := range *replies { + var err error = nil + var body string = "" + var author string = "" + + if re.Deleted { + body = "\n DELETED\n\n" + author = "DELETED" + } else { + body, err = m.glam.Render(re.Body) + if err != nil { + m.ctx.Logger.Error(err) + body = re.Body + } + + author = re.Author.Name + } + + m.replyIDs = append(m.replyIDs, re.ID) + m.allReplies = append(m.allReplies, &(*replies)[ri]) + idx := len(m.replyIDs) - 1 + + out += fmt.Sprintf( + "\n\n %s %s%s%s\n%s", + m.ctx.Theme.Reply.Author.Render( + author, + ), + lipgloss.NewStyle(). + Foreground(m.ctx.Theme.Reply.Author.GetBackground()). + Render(fmt.Sprintf("writes in reply to %s:", inReplyTo)), + strings.Repeat(" ", (m.viewport.Width-len(author)-len(inReplyTo)-28)), + lipgloss.NewStyle(). + Foreground(lipgloss.Color("#777777")). + Render(fmt.Sprintf("#%d", idx)), + body, + ) + + idx++ + out += m.renderReplies(level+1, re.Author.Name, &re.Replies) + } + + return out +} diff --git a/ui/windows/postdialog/postdialog.go b/ui/windows/postdialog/postdialog.go index 5a82495..b4cc47b 100644 --- a/ui/windows/postdialog/postdialog.go +++ b/ui/windows/postdialog/postdialog.go @@ -19,13 +19,15 @@ import ( ) var ( + WIN_ID = "postShow" + viewportStyle = lipgloss.NewStyle(). - Margin(0, 0, 0, 0). - Padding(0, 0). - BorderTop(false). - BorderLeft(false). - BorderRight(false). - BorderBottom(false) + Margin(0, 0, 0, 0). + Padding(0, 0). + BorderTop(false). + BorderLeft(false). + BorderRight(false). + BorderBottom(false) ) type KeyMap struct { @@ -153,34 +155,40 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.viewport.Height = viewportHeight + 1 // cmds = append(cmds, viewport.Sync(m.viewport)) - case *post.Post: - m.ctx.Logger.Debug("got *post.Post") - m.activePost = msg - m.viewport.SetContent(m.renderViewport(m.activePost)) - m.ctx.Loading = false - return m, nil - case cmd.Command: m.ctx.Logger.Debugf("got command: %v\n", msg) switch msg.Call { - case cmd.WinRefreshData: - if msg.Target == "post" { + case cmd.WinOpen, cmd.WinRefreshData: + if msg.Target == WIN_ID { + m.ctx.Logger.Debug("got own WinOpen command") m.activePost = msg.GetArg("post").(*post.Post) + m.viewport.SetContent(m.renderViewport(m.activePost)) m.ctx.Logger.Debugf("loading post: %v", m.activePost.ID) m.ctx.Loading = true return m, m.loadPost(m.activePost) } return m, nil case cmd.WinFocus: - if msg.Target == "post" { + if msg.Target == WIN_ID || + msg.Target == "*" { m.focused = true } return m, nil case cmd.WinBlur: - if msg.Target == "post" { + if msg.Target == WIN_ID || + msg.Target == "*" { m.focused = false } return m, nil + case cmd.WinFreshData: + if msg.Target == WIN_ID || + msg.Target == "*" { + m.ctx.Logger.Debug("got *post.Post") + m.activePost = msg.GetArg("post").(*post.Post) + m.viewport.SetContent(m.renderViewport(m.activePost)) + m.ctx.Loading = false + return m, nil + } default: m.ctx.Logger.Debugf("received unhandled command: %v\n", msg) } @@ -203,7 +211,13 @@ func (m *Model) loadPost(p *post.Post) tea.Cmd { if err := m.a.LoadPost(p); err != nil { m.ctx.Logger.Error(err) } - return p + + c := cmd.New( + cmd.WinFreshData, + WIN_ID, + cmd.Arg{Name: "post", Value: p}, + ) + return *c } }