From 008bfd1efb481a8e34ac10f5bd892090a4281938 Mon Sep 17 00:00:00 2001 From: Robin Olsen Date: Wed, 4 Feb 2026 15:22:32 +0100 Subject: [PATCH] Add basic issue view, styling and ux need improvement --- cmd/tui/main.go | 2 +- go.mod | 2 +- pkg/tui/styles/styles.go | 30 +++++++++++++ pkg/tui/tui.go | 13 ++++-- pkg/tui/views/dashboard/deligate.go | 38 ++++++++++++++++ pkg/tui/views/dashboard/keys.go | 56 ++++++++++++++++++++++++ pkg/tui/views/dashboard/model.go | 29 +++++++++++++ pkg/tui/views/dashboard/update.go | 60 ++++++++++++++++++++++++++ pkg/tui/views/dashboard/view.go | 63 +++++++++++++++++++++++++++ pkg/tui/views/issue/view.go | 67 +++++++++++++++++++++++++++++ pkg/tui/views/views.go | 10 +++++ 11 files changed, 364 insertions(+), 6 deletions(-) create mode 100644 pkg/tui/styles/styles.go create mode 100644 pkg/tui/views/dashboard/deligate.go create mode 100644 pkg/tui/views/dashboard/keys.go create mode 100644 pkg/tui/views/dashboard/model.go create mode 100644 pkg/tui/views/dashboard/update.go create mode 100644 pkg/tui/views/dashboard/view.go create mode 100644 pkg/tui/views/issue/view.go create mode 100644 pkg/tui/views/views.go diff --git a/cmd/tui/main.go b/cmd/tui/main.go index 9d2e455..6871019 100644 --- a/cmd/tui/main.go +++ b/cmd/tui/main.go @@ -14,7 +14,7 @@ func main() { IssuePrefix: "pm", } - if err := tui.Run(context.Background(), config); err != nil { + if _, err := tui.Run(context.Background(), config); err != nil { panic(err) } } diff --git a/go.mod b/go.mod index abdfeb5..362b55b 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/Dicklesworthstone/beads_viewer v0.14.3 github.com/NYTimes/gziphandler v1.1.1 github.com/a-h/templ v0.3.977 + github.com/charmbracelet/bubbles v0.21.1 github.com/charmbracelet/bubbletea v1.3.10 github.com/google/uuid v1.6.0 github.com/rs/cors v1.11.1 @@ -24,7 +25,6 @@ require ( github.com/aymerick/douceur v0.2.0 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect - github.com/charmbracelet/bubbles v0.21.1 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/glamour v0.10.0 // indirect github.com/charmbracelet/huh v0.8.0 // indirect diff --git a/pkg/tui/styles/styles.go b/pkg/tui/styles/styles.go new file mode 100644 index 0000000..e7901b3 --- /dev/null +++ b/pkg/tui/styles/styles.go @@ -0,0 +1,30 @@ +package styles + +import "github.com/charmbracelet/lipgloss" + +var ( + AppStyle = lipgloss.NewStyle().Padding(3, 3) + + TitleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("12")). + Padding(0, 1).Bold(true).Border(lipgloss.NormalBorder()) + + SelectedItemStyle = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder(), false, false, false, true). + Foreground(lipgloss.Color("2")). + Padding(0, 0, 0, 1) + + ItemStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("7")). + Padding(0, 0, 0, 2) + + IssueStyle = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("8")). + Padding(1) + + FocusedIssueStyle = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("2")). + Padding(1) +) diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index 19db02a..b0feda5 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -4,17 +4,22 @@ import ( "context" "github.com/LazyBachelor/LazyPM/internal/service" + "github.com/LazyBachelor/LazyPM/pkg/tui/views" + tea "github.com/charmbracelet/bubbletea" ) type TUIConfig = service.Config -func Run(ctx context.Context, config TUIConfig) error { - _, cleanup, err := service.NewServices(ctx, config) +func Run(ctx context.Context, config TUIConfig) (tea.Model, error) { + svc, cleanup, err := service.NewServices(ctx, config) if err != nil { - return err + return nil, err } + defer cleanup() - return nil + app := tea.NewProgram(views.NewDashboardView(svc), + tea.WithAltScreen(), tea.WithMouseAllMotion()) + return app.Run() } diff --git a/pkg/tui/views/dashboard/deligate.go b/pkg/tui/views/dashboard/deligate.go new file mode 100644 index 0000000..f446e59 --- /dev/null +++ b/pkg/tui/views/dashboard/deligate.go @@ -0,0 +1,38 @@ +package dashboard + +import ( + "fmt" + "io" + + "github.com/LazyBachelor/LazyPM/pkg/tui/styles" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" +) + +type IssueListDelegate struct{} + +func (d IssueListDelegate) Height() int { return 2 } +func (d IssueListDelegate) Spacing() int { return 1 } +func (d IssueListDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil } +func (d IssueListDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { + issue, ok := listItem.(ListIssue) + + if !ok { + return + } + + id := issue.ID + title := issue.Title() + description := issue.Description() + + str := fmt.Sprintf("ID: %s\t%s\nDescription:\t%s", id, title, description) + + fn := styles.ItemStyle.Render + if index == m.Index() { + fn = func(s ...string) string { + return styles.SelectedItemStyle.Render(s...) + } + } + + fmt.Fprint(w, fn(str)) +} diff --git a/pkg/tui/views/dashboard/keys.go b/pkg/tui/views/dashboard/keys.go new file mode 100644 index 0000000..70026b3 --- /dev/null +++ b/pkg/tui/views/dashboard/keys.go @@ -0,0 +1,56 @@ +package dashboard + +import ( + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" +) + +type DashboardKeyMap struct { + Quit key.Binding + SelectIssue key.Binding + BackToList key.Binding + ScrollUp key.Binding + ScrollDown key.Binding +} + +var defaultDashboardKeyMap = DashboardKeyMap{ + Quit: key.NewBinding( + key.WithKeys("q", "ctrl+c"), + key.WithHelp("q", "quit"), + ), + SelectIssue: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "view issue"), + ), + BackToList: key.NewBinding( + key.WithKeys("b"), + key.WithHelp("b", "back to list"), + ), + ScrollUp: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "scroll up"), + ), + ScrollDown: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "scroll down"), + ), +} + +func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { + var cmd tea.Cmd + + switch { + case key.Matches(msg, d.keyMap.Quit): + return tea.Quit + case !d.focusedOnIssue && key.Matches(msg, d.keyMap.SelectIssue): + d.focusedOnIssue = true + case d.focusedOnIssue && key.Matches(msg, d.keyMap.BackToList): + d.focusedOnIssue = false + case d.focusedOnIssue && key.Matches(msg, d.keyMap.ScrollUp): + d.issueView.Viewport.LineUp(1) + case d.focusedOnIssue && key.Matches(msg, d.keyMap.ScrollDown): + d.issueView.Viewport.LineDown(1) + } + + return cmd +} diff --git a/pkg/tui/views/dashboard/model.go b/pkg/tui/views/dashboard/model.go new file mode 100644 index 0000000..fed9456 --- /dev/null +++ b/pkg/tui/views/dashboard/model.go @@ -0,0 +1,29 @@ +package dashboard + +import ( + "github.com/LazyBachelor/LazyPM/internal/models" + "github.com/LazyBachelor/LazyPM/internal/service" + "github.com/LazyBachelor/LazyPM/pkg/tui/views/issue" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" +) + +type Model struct { + issueView issue.Model + issueList list.Model + keyMap DashboardKeyMap + svc *service.Services + focusedOnIssue bool +} + +func (d Model) Init() tea.Cmd { + return nil +} + +type ListIssue struct { + models.Issue +} + +func (i ListIssue) Title() string { return i.Issue.Title } +func (i ListIssue) Description() string { return i.Issue.Description } +func (i ListIssue) FilterValue() string { return i.Issue.ID + " " + i.Issue.Title } diff --git a/pkg/tui/views/dashboard/update.go b/pkg/tui/views/dashboard/update.go new file mode 100644 index 0000000..b8dd033 --- /dev/null +++ b/pkg/tui/views/dashboard/update.go @@ -0,0 +1,60 @@ +package dashboard + +import ( + "fmt" + + "github.com/LazyBachelor/LazyPM/pkg/tui/styles" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" +) + +func (d Model) Update(m tea.Msg) (tea.Model, tea.Cmd) { + switch msg := m.(type) { + case tea.KeyMsg: + if d.issueList.FilterState() == list.Filtering { + break + } + cmd := d.handleKeyMsg(msg) + if cmd != nil { + return d, cmd + } + case tea.WindowSizeMsg: + d.updateSizes(msg.Width, msg.Height) + } + + var cmd tea.Cmd + oldIndex := d.issueList.Index() + d.issueList, cmd = d.issueList.Update(m) + + if d.issueList.Index() != oldIndex { + d.updateIssueView() + } + + return d, cmd +} + +func (d *Model) updateIssueView() { + if item, ok := d.issueList.SelectedItem().(ListIssue); ok { + d.issueView.ID = item.ID + d.issueView.Title = item.Issue.Title + d.issueView.Description = item.Issue.Description + d.issueView.Status = string(item.Issue.Status) + d.issueView.IssueType = string(item.Issue.IssueType) + + content := fmt.Sprintf("ID: %s\nTitle: %s\nDescription: %s\nStatus: %s\nType: %s", + d.issueView.ID, d.issueView.Title, d.issueView.Description, d.issueView.Status, d.issueView.IssueType) + + d.issueView.Viewport.SetContent(content) + } +} + +func (d *Model) updateSizes(width, height int) { + listWidth := width / 2 + issueWidth := width - listWidth + + w, h := styles.AppStyle.GetFrameSize() + d.issueList.SetSize(listWidth-w, height-h) + d.issueView.SetSize(issueWidth, height-h) + d.issueView.Viewport.Width = issueWidth + d.issueView.Viewport.Height = height - h +} diff --git a/pkg/tui/views/dashboard/view.go b/pkg/tui/views/dashboard/view.go new file mode 100644 index 0000000..31f7b71 --- /dev/null +++ b/pkg/tui/views/dashboard/view.go @@ -0,0 +1,63 @@ +package dashboard + +import ( + "context" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/lipgloss" + + "github.com/LazyBachelor/LazyPM/internal/service" + "github.com/LazyBachelor/LazyPM/pkg/tui/styles" + "github.com/LazyBachelor/LazyPM/pkg/tui/views/issue" +) + +func NewDashboard(svc *service.Services) Model { + + issues, err := svc.Beads.AllIssues(context.Background()) + + listIssues := []ListIssue{} + for _, issue := range issues { + listIssues = append(listIssues, ListIssue{Issue: issue}) + } + + if err != nil { + listIssues = []ListIssue{} + } + + items := make([]list.Item, len(listIssues)) + for i, issue := range listIssues { + items[i] = issue + } + + issueList := list.New(items, IssueListDelegate{}, 0, 0) + + issueList.Title = "Issues" + issueList.Styles.Title = styles.TitleStyle + + issueView := issue.NewIssueView(issue.Model{}) + + m := Model{ + issueView: issueView, + issueList: issueList, + keyMap: defaultDashboardKeyMap, + svc: svc, + } + + m.updateIssueView() + + return m +} + +func (d Model) View() string { + issueView := d.issueView.View() + + if d.focusedOnIssue { + issueView = styles.FocusedIssueStyle.Render(issueView) + } else { + issueView = styles.IssueStyle.Render(issueView) + } + + str := lipgloss.JoinHorizontal(lipgloss.Left, styles.AppStyle.Render(d.issueList.View()), issueView) + + return str +} diff --git a/pkg/tui/views/issue/view.go b/pkg/tui/views/issue/view.go new file mode 100644 index 0000000..ff80d86 --- /dev/null +++ b/pkg/tui/views/issue/view.go @@ -0,0 +1,67 @@ +package issue + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" +) + +type Model struct { + Viewport viewport.Model + ID string + Title string + Description string + Status string + IssueType string + Width, Height int + ready bool +} + +func NewIssueView(issue Model) Model { + return Model{ + ID: issue.ID, + Title: issue.Title, + Description: issue.Description, + Status: issue.Status, + IssueType: issue.IssueType, + Width: issue.Width, + Height: issue.Height, + Viewport: viewport.New(issue.Width, issue.Height), + ready: false, + } +} + +func (m *Model) SetSize(width, height int) { + m.Width = width + m.Height = height +} + +func (m Model) Init() tea.Cmd { + return nil +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + + case tea.WindowSizeMsg: + m.SetSize(msg.Width, msg.Height) + m.Viewport.Width = m.Width + m.Viewport.Height = m.Height + m.ready = true + } + + if !m.ready { + return m, nil + } + + content := fmt.Sprintf("ID: %s\nTitle: %s\nDescription: %s\nStatus: %s\nType: %s", + m.ID, m.Title, m.Description, m.Status, m.IssueType) + + m.Viewport.SetContent(content) + return m, nil +} + +func (m Model) View() string { + return fmt.Sprintf("%s", m.Viewport.View()) +} diff --git a/pkg/tui/views/views.go b/pkg/tui/views/views.go new file mode 100644 index 0000000..a246942 --- /dev/null +++ b/pkg/tui/views/views.go @@ -0,0 +1,10 @@ +package views + +import ( + "github.com/LazyBachelor/LazyPM/internal/service" + "github.com/LazyBachelor/LazyPM/pkg/tui/views/dashboard" +) + +func NewDashboardView(svc *service.Services) dashboard.Model { + return dashboard.NewDashboard(svc) +}