diff --git a/pkg/tui/msgs/msgs.go b/pkg/tui/msgs/msgs.go new file mode 100644 index 0000000..8b48d50 --- /dev/null +++ b/pkg/tui/msgs/msgs.go @@ -0,0 +1,7 @@ +package msgs + +// SwitchToDashboardMsg signals to switch to the main dashboard view. +type SwitchToDashboardMsg struct{} + +// SwitchToDashboard2Msg signals to switch to the dashboard2 view. +type SwitchToDashboard2Msg struct{} diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index 6227f27..17c8c80 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -29,7 +29,7 @@ func (t *Tui) Run(ctx context.Context, config Config) error { defer cleanup() - p := tea.NewProgram(views.NewDashboardView(app, t.feedbackChan, t.quitChan), + p := tea.NewProgram(views.NewRootView(app, t.feedbackChan, t.quitChan), tea.WithAltScreen(), tea.WithMouseAllMotion()) if t.quitChan != nil { diff --git a/pkg/tui/views/dashboard/help_bar.go b/pkg/tui/views/dashboard/help_bar.go index 3ad63cc..2234e61 100644 --- a/pkg/tui/views/dashboard/help_bar.go +++ b/pkg/tui/views/dashboard/help_bar.go @@ -32,6 +32,7 @@ func (h HelpBar) View() string { func (h HelpBar) shortHelp() string { keys := []string{ styles.HighlightKey("tab") + " switch", + styles.HighlightKey("ctrl+2") + " dash2", styles.HighlightKey("↑/k") + " up", styles.HighlightKey("↓/j") + " down", styles.HighlightKey("pgup/pgdn") + " page", @@ -74,10 +75,11 @@ func (h HelpBar) fullHelp() string { renderRow("enter", "view issue", "↓/j", "down"), renderRow("pgup", "page up", "pgdn", "page down"), renderRow("b", "back to list", "a", "add issue"), - renderRow("e", "edit title", "d", "edit desc"), + renderRow("e", "edit title", "d", "edit description"), renderRow("s", "change status", "p", "change priority"), renderRow("t", "change type", "x", "delete issue"), - renderRow("?", "help", "q", "quit"), + renderRow("ctrl+2", "dashboard 2", "q", "quit"), + renderRow("?", "help", "", ""), } content := lipgloss.JoinVertical(lipgloss.Left, rows...) return lipgloss.NewStyle(). diff --git a/pkg/tui/views/dashboard/keys.go b/pkg/tui/views/dashboard/keys.go index 34760f5..b6ecc67 100644 --- a/pkg/tui/views/dashboard/keys.go +++ b/pkg/tui/views/dashboard/keys.go @@ -1,25 +1,27 @@ package dashboard import ( + "github.com/LazyBachelor/LazyPM/pkg/tui/msgs" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" ) type DashboardKeyMap struct { - Help key.Binding - Quit key.Binding - SelectIssue key.Binding - BackToList key.Binding - ScrollUp key.Binding - ScrollDown key.Binding - SwitchWindow key.Binding - EditTitle key.Binding - EditDescription key.Binding - ChangeStatus key.Binding - ChangePriority key.Binding - ChangeType key.Binding - AddIssue key.Binding - DeleteIssue key.Binding + Help key.Binding + Quit key.Binding + SelectIssue key.Binding + BackToList key.Binding + ScrollUp key.Binding + ScrollDown key.Binding + SwitchWindow key.Binding + SwitchToDashboard2 key.Binding + EditTitle key.Binding + EditDescription key.Binding + ChangeStatus key.Binding + ChangePriority key.Binding + ChangeType key.Binding + AddIssue key.Binding + DeleteIssue key.Binding } var defaultDashboardKeyMap = DashboardKeyMap{ @@ -51,6 +53,10 @@ var defaultDashboardKeyMap = DashboardKeyMap{ key.WithKeys("tab"), key.WithHelp("tab", "switch window"), ), + SwitchToDashboard2: key.NewBinding( + key.WithKeys("2"), + key.WithHelp("2", "dashboard 2"), + ), EditTitle: key.NewBinding( key.WithKeys("e"), key.WithHelp("e", "edit title"), @@ -91,6 +97,8 @@ func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { return tea.Quit case key.Matches(msg, d.keyMap.SwitchWindow): d.ToggleFocusedWindow() + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.SwitchToDashboard2): + return func() tea.Msg { return msgs.SwitchToDashboard2Msg{} } case d.IsFocusedOnList() && key.Matches(msg, d.keyMap.SelectIssue): d.FocusDetail() case d.IsFocusedOnDetail() && key.Matches(msg, d.keyMap.BackToList): diff --git a/pkg/tui/views/dashboard2/header.go b/pkg/tui/views/dashboard2/header.go new file mode 100644 index 0000000..4cdfe35 --- /dev/null +++ b/pkg/tui/views/dashboard2/header.go @@ -0,0 +1,32 @@ +package dashboard2 + +import ( + "github.com/LazyBachelor/LazyPM/pkg/tui/styles" + "github.com/charmbracelet/lipgloss" +) + +type Header struct { + title string +} + +func NewHeader(title string) Header { + return Header{ + title: title, + } +} + +func (h Header) View(width int) string { + title := styles.HeaderTitleStyle.Render(h.title) + + return lipgloss.PlaceHorizontal( + width, + lipgloss.Left, + title, + lipgloss.WithWhitespaceChars("─"), + lipgloss.WithWhitespaceForeground(styles.Primary), + ) +} + +func (h Header) Height() int { + return 1 +} diff --git a/pkg/tui/views/dashboard2/help_bar.go b/pkg/tui/views/dashboard2/help_bar.go new file mode 100644 index 0000000..d997b41 --- /dev/null +++ b/pkg/tui/views/dashboard2/help_bar.go @@ -0,0 +1,106 @@ +package dashboard2 + +import ( + "github.com/LazyBachelor/LazyPM/pkg/tui/styles" + "github.com/charmbracelet/lipgloss" +) + +type HelpBar struct { + keyMap DashboardKeyMap + showAll bool + width int +} + +func NewHelpBar(keyMap DashboardKeyMap) HelpBar { + return HelpBar{keyMap: keyMap} +} + +func (h *HelpBar) SetWidth(width int) { + h.width = width +} + +func (h HelpBar) View() string { + if h.width == 0 { + return "" + } + if h.showAll { + return h.fullHelp() + } + return h.shortHelp() +} + +func (h HelpBar) shortHelp() string { + keys := []string{ + styles.HighlightKey("tab") + " switch", + styles.HighlightKey("ctrl+1") + " dash1", + styles.HighlightKey("↑/k") + " up", + styles.HighlightKey("↓/j") + " down", + styles.HighlightKey("pgup/pgdn") + " page", + styles.HighlightKey("a") + " add", + styles.HighlightKey("e/d/s/p/t") + " edit", + styles.HighlightKey("x") + " delete", + styles.HighlightKey("q") + " quit", + styles.HighlightKey("?") + " help", + } + content := lipgloss.JoinHorizontal(lipgloss.Left, keys...) + return lipgloss.NewStyle(). + Border(lipgloss.Border{Top: "─"}, true, false, false, false). + BorderForeground(styles.SecondaryBorder). + Padding(0, 1). + Width(h.width). + Render(content) +} + +func (h HelpBar) fullHelp() string { + keyStyle := lipgloss.NewStyle().Width(8).Align(lipgloss.Right) + descStyle := lipgloss.NewStyle().Width(12) + + renderHelpItem := func(key, desc string) string { + return lipgloss.JoinHorizontal( + lipgloss.Left, + keyStyle.Render(styles.HighlightKey(key)), + " ", + descStyle.Render(desc), + ) + } + + renderRow := func(leftKey, leftDesc, rightKey, rightDesc string) string { + leftItem := renderHelpItem(leftKey, leftDesc) + rightItem := renderHelpItem(rightKey, rightDesc) + return lipgloss.JoinHorizontal(lipgloss.Left, leftItem, " ", rightItem) + } + + rows := []string{ + renderRow("tab", "switch window", "↑/k", "up"), + renderRow("enter", "view issue", "↓/j", "down"), + renderRow("pgup", "page up", "pgdn", "page down"), + renderRow("b", "back to list", "a", "add issue"), + renderRow("e", "edit title", "d", "edit description"), + renderRow("s", "change status", "p", "change priority"), + renderRow("t", "change type", "x", "delete issue"), + renderRow("ctrl+1", "dashboard 1", "q", "quit"), + renderRow("?", "help", "", ""), + } + content := lipgloss.JoinVertical(lipgloss.Left, rows...) + return lipgloss.NewStyle(). + Border(lipgloss.Border{Top: "─"}, true, false, false, false). + BorderForeground(styles.SecondaryBorder). + Padding(0, 1). + Width(h.width). + Render(content) +} + +func (h HelpBar) Height() int { + if h.width == 0 { + return 0 + } + return lipgloss.Height(h.View()) +} + +func (h HelpBar) IsExpanded() bool { + return h.showAll +} + +func (h *HelpBar) ToggleHelp() { + h.showAll = !h.showAll +} diff --git a/pkg/tui/views/dashboard2/issue_detail.go b/pkg/tui/views/dashboard2/issue_detail.go new file mode 100644 index 0000000..3a18022 --- /dev/null +++ b/pkg/tui/views/dashboard2/issue_detail.go @@ -0,0 +1,100 @@ +package dashboard2 + +import ( + "github.com/LazyBachelor/LazyPM/internal/models" + "github.com/LazyBachelor/LazyPM/pkg/tui/styles" + "github.com/charmbracelet/bubbles/viewport" + "github.com/charmbracelet/lipgloss" +) + +type IssueDetail struct { + viewport viewport.Model + issue models.Issue + focused bool +} + +func NewIssueDetail() IssueDetail { + vp := viewport.New(0, 0) + return IssueDetail{ + viewport: vp, + } +} + +func (i *IssueDetail) SetIssue(issue models.Issue) { + i.issue = issue + i.refreshContent() +} + +func (i *IssueDetail) SetSize(width, height int) { + i.viewport.Height = height + i.viewport.Width = width + i.refreshContent() +} + +func (i *IssueDetail) SetFocused(focused bool) { + i.focused = focused +} + +func (i *IssueDetail) refreshContent() { + + titleRow := styles.RowStyle.Render( + styles.TitleStyle.Render(i.issue.Title), + ) + + idRow := styles.RowStyle.Render( + styles.LabelStyle.Render("ID:") + styles.ValueStyle.Render(i.issue.ID), + ) + + typeRow := styles.RowStyle.Render( + styles.LabelStyle.Render("Type:") + styles.ValueStyle.Render(string(i.issue.IssueType)), + ) + + statusRow := styles.RowStyle.Render( + styles.LabelStyle.Render("Status:") + styles.StatusStyle(string(i.issue.Status)).Render(string(i.issue.Status)), + ) + + priorityRow := styles.RowStyle.Render( + styles.LabelStyle.Render("Priority:") + styles.ValueStyle.Render(priorityCodeName(i.issue.Priority)), + ) + + descLabel := styles.LabelStyle.Render("Description:") + descContent := styles.ValueStyle.Render(i.issue.Description) + + content := lipgloss.JoinVertical(lipgloss.Left, + titleRow, + idRow, + typeRow, + statusRow, + priorityRow, + descLabel, + descContent, + ) + + i.viewport.SetContent(content) +} + +func (i IssueDetail) View() string { + content := i.viewport.View() + + if i.focused { + return styles.DetailsContainerStyle. + BorderForeground(styles.PrimaryBorder). + Width(i.viewport.Width). + Height(i.viewport.Height). + MaxHeight(i.viewport.Height). + Render(content) + } + return styles.DetailsContainerStyle. + Width(i.viewport.Width). + Height(i.viewport.Height). + MaxHeight(i.viewport.Height). + Render(content) +} + +func (i *IssueDetail) ScrollUp(lines int) { + i.viewport.ScrollUp(lines) +} + +func (i *IssueDetail) ScrollDown(lines int) { + i.viewport.ScrollDown(lines) +} diff --git a/pkg/tui/views/dashboard2/issue_list.go b/pkg/tui/views/dashboard2/issue_list.go new file mode 100644 index 0000000..3c5c35a --- /dev/null +++ b/pkg/tui/views/dashboard2/issue_list.go @@ -0,0 +1,346 @@ +package dashboard2 + +import ( + "context" + "fmt" + "io" + "sort" + + "github.com/LazyBachelor/LazyPM/internal/app" + "github.com/LazyBachelor/LazyPM/internal/models" + "github.com/LazyBachelor/LazyPM/pkg/tui/styles" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/reflow/truncate" +) + +type IssueList struct { + list list.Model + app *app.App + width int + height int +} + +type ListIssue struct { + models.Issue +} + +func (l ListIssue) Title() string { return l.Issue.Title } +func (l ListIssue) Description() string { return l.Issue.Description } +func (l ListIssue) FilterValue() string { return l.Issue.ID + " " + l.Issue.Title } + +type TableColumn struct { + width uint + label string + key string +} + +func getTableColumns(width int) []TableColumn { + switch { + case width < 45: + return []TableColumn{ + {width: 10, label: "ID", key: "id"}, + {width: uint(width - 10), label: "TITLE", key: "title"}, + } + case width < 60: + return []TableColumn{ + {width: 10, label: "ID", key: "id"}, + {width: 20, label: "TITLE", key: "title"}, + {width: 15, label: "STATUS", key: "status"}, + } + default: + return []TableColumn{ + {width: 12, label: "ID", key: "id"}, + {width: 20, label: "TITLE", key: "title"}, + {width: 15, label: "STATUS", key: "status"}, + {width: 10, label: "TYPE", key: "type"}, + {width: 15, label: "PRIORITY", key: "priority"}, + } + } +} + +func renderHeaders(cols []TableColumn) string { + var parts []string + headerStyle := lipgloss.NewStyle().Foreground(styles.FaintText).Bold(true) + + for _, col := range cols { + colWidth := col.width + if colWidth == 0 { + colWidth = 1 + } + style := lipgloss.NewStyle().Width(int(colWidth)) + headerText := headerStyle.Render(truncate.StringWithTail(col.label, colWidth, "...")) + parts = append(parts, style.Render(headerText)) + } + + return lipgloss.JoinHorizontal(lipgloss.Left, parts...) +} + +func NewIssueList(app *app.App, width, height int) IssueList { + issues, err := app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) + if err != nil { + return IssueList{} + } + + listIssues := []ListIssue{} + for _, issue := range issues { + listIssues = append(listIssues, ListIssue{Issue: *issue}) + } + + items := make([]list.Item, len(listIssues)) + for i, issue := range listIssues { + items[i] = issue + } + + l := list.New(items, NewIssueListDelegate(width), width, height) + l.SetShowTitle(false) + l.SetShowHelp(false) + l.SetShowStatusBar(false) + l.SetFilteringEnabled(true) + l.FilterInput.PromptStyle = styles.FilterPromptStyle + l.FilterInput.Cursor.Style = styles.FilterStyle + l.FilterInput.TextStyle = styles.FilterInputStyle + l.FilterInput.Prompt = "🔍 " + + return IssueList{ + list: l, + app: app, + width: width, + height: height, + } +} + +func NewIssueListFromIssues(app *app.App, issues []*models.Issue, width, height int) IssueList { + // for making an IssueList from a pre-existing list of issues. + listIssues := make([]list.Item, len(issues)) + for i, issue := range issues { + listIssues[i] = ListIssue{Issue: *issue} + } + l := list.New(listIssues, NewIssueListDelegate(width), width, height) + l.SetShowTitle(false) + l.SetShowHelp(false) + l.SetShowStatusBar(false) + l.SetFilteringEnabled(true) + l.FilterInput.PromptStyle = styles.FilterPromptStyle + l.FilterInput.Cursor.Style = styles.FilterStyle + l.FilterInput.TextStyle = styles.FilterInputStyle + l.FilterInput.Prompt = "🔍 " + return IssueList{ + list: l, + app: app, + width: width, + height: height, + } +} + +func OpenAndInProgressOnly(issues []*models.Issue) []*models.Issue { + // used to display open & in-progress issues in the first window in the dashboard + out := make([]*models.Issue, 0, len(issues)) + for _, issue := range issues { + if issue.Status == models.StatusOpen || issue.Status == models.StatusInProgress { + out = append(out, issue) + } + } + sortByPriorityDesc(out) + return out +} + +func ClosedOnly(issues []*models.Issue) []*models.Issue { + // used to display issues in the second window in the dashboard + out := make([]*models.Issue, 0, len(issues)) + for _, issue := range issues { + if issue.Status == models.StatusClosed { + out = append(out, issue) + } + } + sortByPriorityDesc(out) + return out +} + +func sortByPriorityDesc(issues []*models.Issue) { + // sorts issues by priority, highest first. + sort.Slice(issues, func(i, j int) bool { + return issues[i].Priority > issues[j].Priority + }) +} + +func (l *IssueList) Update(msg tea.Msg) (tea.Cmd, bool) { + var cmd tea.Cmd + oldIndex := l.list.Index() + l.list, cmd = l.list.Update(msg) + changed := l.list.Index() != oldIndex + return cmd, changed +} + +func (l *IssueList) SetSize(width, height int) { + l.width = width + l.height = height + + l.list.SetSize(width, height) + l.list.SetDelegate(NewIssueListDelegate(width)) +} + +func (l IssueList) View() string { + return l.renderResponsive() +} + +func (l IssueList) renderResponsive() string { + cols := getTableColumns(l.width) + header := renderHeaders(cols) + + var content []string + + if l.list.FilterState() == list.Filtering { + filterText := l.list.FilterInput.Value() + filterView := styles.FilterStyle.Render("🔍 " + filterText) + content = append(content, filterView) + } + + itemsView := l.renderFilteredItems() + content = append(content, header, itemsView) + + return styles.ContainerStyle. + Width(l.width). + MaxWidth(l.width). + MaxHeight(l.height). + Render(lipgloss.JoinVertical(lipgloss.Left, content...)) +} + +func (l IssueList) renderFilteredItems() string { + var items []string + + var visibleItems []list.Item + if l.list.FilterState() == list.Filtering || l.list.FilterState() == list.FilterApplied { + visibleItems = l.list.VisibleItems() + } else { + visibleItems = l.list.Items() + } + + start, end := l.list.Paginator.GetSliceBounds(len(visibleItems)) + + cursor := l.list.Index() + + for i := start; i < end && i < len(visibleItems); i++ { + isSelected := i == cursor + if issue, ok := visibleItems[i].(ListIssue); ok { + cols := getTableColumns(l.width) + row := renderRow(issue, isSelected, cols) + items = append(items, row) + } + } + + return lipgloss.JoinVertical(lipgloss.Left, items...) +} + +func (l IssueList) SelectedItem() ListIssue { + if item, ok := l.list.SelectedItem().(ListIssue); ok { + return item + } + return ListIssue{} +} + +func (l IssueList) Index() int { + return l.list.Index() +} + +func (l IssueList) FilterState() list.FilterState { + return l.list.FilterState() +} + +func (l *IssueList) SetIssues(issues []*models.Issue) tea.Cmd { + listIssues := make([]list.Item, len(issues)) + for i, issue := range issues { + listIssues[i] = ListIssue{Issue: *issue} + } + return l.list.SetItems(listIssues) +} + +func (l *IssueList) SelectIssueID(issueID string) { + items := l.list.Items() + for i := 0; i < len(items); i++ { + if item, ok := items[i].(ListIssue); ok && item.ID == issueID { + l.list.Select(i) + return + } + } +} + +type IssueListDelegate struct { + width int +} + +func NewIssueListDelegate(width int) IssueListDelegate { + return IssueListDelegate{width: width} +} + +func (d IssueListDelegate) Height() int { return 1 } +func (d IssueListDelegate) Spacing() int { return 0 } +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 + } + + isSelected := index == m.Index() + cols := getTableColumns(d.width) + + row := renderRow(issue, isSelected, cols) + io.WriteString(w, row) +} + +func renderRow(issue ListIssue, isSelected bool, cols []TableColumn) string { + var parts []string + + for _, col := range cols { + value := getColumnValue(col, issue) + colWidth := col.width + if colWidth == 0 { + colWidth = 1 + } + + style := lipgloss.NewStyle().Width(int(colWidth)) + if isSelected { + style = style.Background(styles.SelectedBackground).Bold(true) + } + + truncated := truncate.StringWithTail(value, colWidth, "...") + parts = append(parts, style.Render(truncated)) + } + + return lipgloss.JoinHorizontal(lipgloss.Left, parts...) +} + +var priorityCodeNames = map[int]string{ + 0: "irrelevant", + 1: "low", + 2: "normal", + 3: "high", + 4: "critical", +} + +func priorityCodeName(priority int) string { + if name, ok := priorityCodeNames[priority]; ok { + return name + } + return fmt.Sprintf("%d", priority) +} + +func getColumnValue(col TableColumn, issue ListIssue) string { + switch col.key { + case "id": + return issue.ID + case "title": + return issue.Title() + case "status": + return string(issue.Issue.Status) + case "type": + return string(issue.Issue.IssueType) + case "priority": + return priorityCodeName(issue.Issue.Priority) + default: + return "" + } +} diff --git a/pkg/tui/views/dashboard2/keys.go b/pkg/tui/views/dashboard2/keys.go new file mode 100644 index 0000000..73ced55 --- /dev/null +++ b/pkg/tui/views/dashboard2/keys.go @@ -0,0 +1,143 @@ +package dashboard2 + +import ( + "github.com/LazyBachelor/LazyPM/pkg/tui/msgs" + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" +) + +type DashboardKeyMap struct { + Help key.Binding + Quit key.Binding + SelectIssue key.Binding + BackToList key.Binding + ScrollUp key.Binding + ScrollDown key.Binding + SwitchWindow key.Binding + SwitchToDashboard key.Binding + EditTitle key.Binding + EditDescription key.Binding + ChangeStatus key.Binding + ChangePriority key.Binding + ChangeType key.Binding + AddIssue key.Binding + DeleteIssue key.Binding +} + +var defaultDashboardKeyMap = DashboardKeyMap{ + Help: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "help"), + ), + 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", "up"), + ), + ScrollDown: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "down"), + ), + SwitchWindow: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "switch window"), + ), + SwitchToDashboard: key.NewBinding( + key.WithKeys("1"), + key.WithHelp("1", "dashboard 1"), + ), + EditTitle: key.NewBinding( + key.WithKeys("e"), + key.WithHelp("e", "edit title"), + ), + EditDescription: key.NewBinding( + key.WithKeys("d"), + key.WithHelp("d", "edit description"), + ), + ChangeStatus: key.NewBinding( + key.WithKeys("s"), + key.WithHelp("s", "change status"), + ), + ChangePriority: key.NewBinding( + key.WithKeys("p"), + key.WithHelp("p", "change priority"), + ), + ChangeType: key.NewBinding( + key.WithKeys("t"), + key.WithHelp("t", "change type"), + ), + AddIssue: key.NewBinding( + key.WithKeys("a"), + key.WithHelp("a", "add issue"), + ), + DeleteIssue: key.NewBinding( + key.WithKeys("x"), + key.WithHelp("x", "delete issue"), + ), +} + +func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { + var cmd tea.Cmd + + switch { + case key.Matches(msg, d.keyMap.Help): + d.helpBar.ToggleHelp() + case key.Matches(msg, d.keyMap.Quit): + return tea.Quit + case key.Matches(msg, d.keyMap.SwitchWindow): + d.ToggleFocusedWindow() + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.SwitchToDashboard): + return func() tea.Msg { return msgs.SwitchToDashboardMsg{} } + case d.IsFocusedOnList() && key.Matches(msg, d.keyMap.SelectIssue): + d.FocusDetail() + case d.IsFocusedOnDetail() && key.Matches(msg, d.keyMap.BackToList): + d.FocusList() + case d.IsFocusedOnDetail() && key.Matches(msg, d.keyMap.ScrollUp): + d.issueDetail.ScrollUp(1) + case d.IsFocusedOnDetail() && key.Matches(msg, d.keyMap.ScrollDown): + d.issueDetail.ScrollDown(1) + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.EditTitle): + if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { + d.startEditTitle(selected) + cmd = d.titleInput.Focus() + } + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.EditDescription): + if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { + d.startEditDescription(selected) + cmd = d.descriptionInput.Focus() + } + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.ChangeStatus): + if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { + d.startChooseStatus(selected) + } + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.ChangePriority): + if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { + d.startChoosePriority(selected) + } + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.ChangeType): + if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { + d.startChooseType(selected) + } + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.AddIssue): + d.startCreateIssue() + cmd = d.createTitleInput.Focus() + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.DeleteIssue): + fl := d.FocusedIssueList() + if selected := fl.SelectedItem(); selected.ID != "" { + d.startConfirmDelete(selected.ID, fl.Index()) + } + } + + return cmd +} diff --git a/pkg/tui/views/dashboard2/model.go b/pkg/tui/views/dashboard2/model.go new file mode 100644 index 0000000..c46a29f --- /dev/null +++ b/pkg/tui/views/dashboard2/model.go @@ -0,0 +1,214 @@ +package dashboard2 + +import ( + "context" + + "github.com/LazyBachelor/LazyPM/internal/app" + "github.com/LazyBachelor/LazyPM/internal/models" + "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +type ValidationFeedbackMsg struct { + Feedback models.ValidationFeedback +} + +type Model struct { + header Header + issueList IssueList + issueDetail IssueDetail + closedIssueList IssueList + helpBar HelpBar + keyMap DashboardKeyMap + app *app.App + width int + height int + focusedWindow int // 0 = main (display issues), 1 = closed issues + focusedPaneMain int // 0 = list, 1 = detail + focusedPaneClosed int + editingTitle bool // true while we are editing a title + titleInput textinput.Model + editingIssueID string + + editingDescription bool // true while editing a description + descriptionInput textarea.Model + editingDescIssueID string + creatingIssue bool // true while creating a new issue + createTitleInput textinput.Model + + confirmingDelete bool // true while confirming a delete + deleteConfirmID string + deleteConfirmIndex int + + choosingStatus bool // true while choosing a status + statusIssueID string + choosingPriority bool // true while choosing a priority + priorityIssueID string + choosingType bool // true while choosing a type + typeIssueID string + feedbackChan chan models.ValidationFeedback + quitChan chan bool + currentFeedback models.ValidationFeedback + showComplete bool +} + +func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, quitChan chan bool) *Model { + m := &Model{ + header: NewHeader("Project Manager Dashboard"), + keyMap: defaultDashboardKeyMap, + app: app, + width: 80, + height: 24, + focusedWindow: 0, + focusedPaneMain: 0, + focusedPaneClosed: 0, + feedbackChan: feedbackChan, + quitChan: quitChan, + } + + allIssues, _ := app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) + m.issueList = NewIssueListFromIssues(app, OpenAndInProgressOnly(allIssues), 0, 0) + m.issueDetail = NewIssueDetail() + m.closedIssueList = NewIssueListFromIssues(app, ClosedOnly(allIssues), 0, 0) + m.helpBar = NewHelpBar(m.keyMap) + + ti := textinput.New() + ti.Placeholder = "Issue title ..." + ti.CharLimit = 256 + m.titleInput = ti + + createTi := textinput.New() + createTi.Placeholder = "New issue title ..." + createTi.CharLimit = 256 + m.createTitleInput = createTi + + descTa := textarea.New() + descTa.Placeholder = "Issue description..." + descTa.SetWidth(56) + descTa.SetHeight(8) + m.descriptionInput = descTa + + if selected := m.issueList.SelectedItem(); selected.ID != "" { + m.issueDetail.SetIssue(selected.Issue) + } else if selected := m.closedIssueList.SelectedItem(); selected.ID != "" { + m.issueDetail.SetIssue(selected.Issue) + } + + return m +} + +func (m *Model) startEditTitle(selected ListIssue) { + m.editingTitle = true + m.editingIssueID = selected.ID + m.titleInput.SetValue(selected.Issue.Title) + m.titleInput.CursorEnd() +} + +func (m *Model) startEditDescription(selected ListIssue) { + m.editingDescription = true + m.editingDescIssueID = selected.ID + m.descriptionInput.SetValue(selected.Issue.Description) + m.descriptionInput.CursorEnd() +} + +func (m *Model) startCreateIssue() { + m.creatingIssue = true + m.createTitleInput.SetValue("") + m.createTitleInput.Reset() +} + +func (m *Model) startConfirmDelete(issueID string, index int) { + m.confirmingDelete = true + m.deleteConfirmID = issueID + m.deleteConfirmIndex = index +} + +func (m *Model) startChooseStatus(selected ListIssue) { + m.choosingStatus = true + m.statusIssueID = selected.ID +} + +func (m *Model) startChoosePriority(selected ListIssue) { + m.choosingPriority = true + m.priorityIssueID = selected.ID +} + +func (m *Model) startChooseType(selected ListIssue) { + m.choosingType = true + m.typeIssueID = selected.ID +} + +func (m *Model) Init() tea.Cmd { + return m.listenForValidation() +} + +func (m *Model) listenForValidation() tea.Cmd { + return func() tea.Msg { + feedback := <-m.feedbackChan + return ValidationFeedbackMsg{Feedback: feedback} + } +} + +func (m *Model) IsFocusedOnList() bool { + if m.focusedWindow == 0 { + return m.focusedPaneMain == 0 + } + return m.focusedPaneClosed == 0 +} + +func (m *Model) IsFocusedOnDetail() bool { + if m.focusedWindow == 0 { + return m.focusedPaneMain == 1 + } + return m.focusedPaneClosed == 1 +} + +func (m *Model) FocusList() { + if m.focusedWindow == 0 { + m.focusedPaneMain = 0 + } else { + m.focusedPaneClosed = 0 + } + m.issueDetail.SetFocused(false) +} + +func (m *Model) FocusDetail() { + if m.focusedWindow == 0 { + m.focusedPaneMain = 1 + } else { + m.focusedPaneClosed = 1 + } + m.issueDetail.SetFocused(true) +} + +func (m *Model) ToggleFocus() { + if m.IsFocusedOnList() { + m.FocusDetail() + } else { + m.FocusList() + } +} + +func (m *Model) FocusedIssueList() *IssueList { + // return the issue list of the currently focused window so we can use two tui windows for issues + if m.focusedWindow == 0 { + return &m.issueList + } + return &m.closedIssueList +} + +func (m *Model) ToggleFocusedWindow() { + // switch focus between open/in-progress and closed issues window + m.focusedWindow = 1 - m.focusedWindow + if m.focusedWindow == 0 { + if selected := m.issueList.SelectedItem(); selected.ID != "" { + m.issueDetail.SetIssue(selected.Issue) + } + } else { + if selected := m.closedIssueList.SelectedItem(); selected.ID != "" { + m.issueDetail.SetIssue(selected.Issue) + } + } + m.issueDetail.SetFocused(m.IsFocusedOnDetail()) +} diff --git a/pkg/tui/views/dashboard2/operations.go b/pkg/tui/views/dashboard2/operations.go new file mode 100644 index 0000000..a41f4d2 --- /dev/null +++ b/pkg/tui/views/dashboard2/operations.go @@ -0,0 +1,456 @@ +package dashboard2 + +import ( + "context" + + "github.com/LazyBachelor/LazyPM/internal/app" + "github.com/LazyBachelor/LazyPM/internal/models" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" +) + +type issueTitleUpdatedMsg struct { + IssueID string + Err error +} + +type issueDescriptionUpdatedMsg struct { + IssueID string + Err error +} + +type issueStatusUpdatedMsg struct { + IssueID string + Err error +} + +type issuePriorityUpdatedMsg struct { + IssueID string + Err error +} + +type issueTypeUpdatedMsg struct { + IssueID string + Err error +} + +type selectIssueMsg struct { + IssueID string +} + +type issueCreatedMsg struct { + Issue *models.Issue + Err error +} + +type issueDeletedMsg struct { + IssueID string + Err error + PreviousIndex int +} + +func updateIssueTitleCmd(app *app.App, issueID, newTitle string) tea.Cmd { + return func() tea.Msg { + updates := map[string]interface{}{"title": newTitle} + err := app.Issues.UpdateIssue(context.Background(), issueID, updates, "tui") + return issueTitleUpdatedMsg{IssueID: issueID, Err: err} + } +} + +func updateIssueDescriptionCmd(app *app.App, issueID, newDescription string) tea.Cmd { + return func() tea.Msg { + updates := map[string]interface{}{"description": newDescription} + err := app.Issues.UpdateIssue(context.Background(), issueID, updates, "tui") + return issueDescriptionUpdatedMsg{IssueID: issueID, Err: err} + } +} + +func updateIssueStatusCmd(app *app.App, issueID, status string) tea.Cmd { + return func() tea.Msg { + updates := map[string]interface{}{"status": status} + err := app.Issues.UpdateIssue(context.Background(), issueID, updates, "tui") + return issueStatusUpdatedMsg{IssueID: issueID, Err: err} + } +} + +func updateIssuePriorityCmd(app *app.App, issueID string, priority int) tea.Cmd { + return func() tea.Msg { + updates := map[string]interface{}{"priority": priority} + err := app.Issues.UpdateIssue(context.Background(), issueID, updates, "tui") + return issuePriorityUpdatedMsg{IssueID: issueID, Err: err} + } +} + +func updateIssueTypeCmd(app *app.App, issueID string, issueType models.IssueType) tea.Cmd { + return func() tea.Msg { + updates := map[string]interface{}{"issue_type": string(issueType)} + err := app.Issues.UpdateIssue(context.Background(), issueID, updates, "tui") + return issueTypeUpdatedMsg{IssueID: issueID, Err: err} + } +} + +func createIssueCmd(app *app.App, title string) tea.Cmd { + return func() tea.Msg { + issue := &models.Issue{ + Title: title, + Status: models.StatusOpen, + IssueType: models.TypeTask, + Priority: 2, + } + err := app.Issues.CreateIssue(context.Background(), issue, "tui") + return issueCreatedMsg{Issue: issue, Err: err} + } +} + +func deleteIssueCmd(app *app.App, issueID string, currentIndex int) tea.Cmd { + return func() tea.Msg { + err := app.Issues.DeleteIssue(context.Background(), issueID) + return issueDeletedMsg{IssueID: issueID, Err: err, PreviousIndex: currentIndex} + } +} + +func (m *Model) refreshIssueListsAndSelectIssue(issueID string) tea.Cmd { + /* update handler for issueTitleUpdatedMsg, issueDescriptionUpdatedMsg, and issueStatusUpdatedMsg to avoid using nearly identical code for refreshing the issue lists and updating the detail view + Fetch all issues, update both lists, set the detail view for the given issue, and return a command to select that issue. Returns nil if fetch fails. + */ + issues, err := m.app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) + if err != nil { + return nil + } + setItemsCmd := m.issueList.SetIssues(OpenAndInProgressOnly(issues)) + closedSetCmd := m.closedIssueList.SetIssues(ClosedOnly(issues)) + for _, issue := range issues { + if issue.ID == issueID { + m.issueDetail.SetIssue(*issue) + break + } + } + return tea.Sequence(setItemsCmd, closedSetCmd, func() tea.Msg { return selectIssueMsg{IssueID: issueID} }) +} + +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case issueTitleUpdatedMsg: + m.editingTitle = false + m.editingIssueID = "" + m.titleInput.Blur() + if msg.Err != nil { + return m, nil + } + return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) + + case issueDescriptionUpdatedMsg: + m.editingDescription = false + m.editingDescIssueID = "" + m.descriptionInput.Blur() + if msg.Err != nil { + return m, nil + } + return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) + + case issueStatusUpdatedMsg: + m.choosingStatus = false + m.statusIssueID = "" + if msg.Err != nil { + return m, nil + } + return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) + + case issuePriorityUpdatedMsg: + m.choosingPriority = false + m.priorityIssueID = "" + if msg.Err != nil { + return m, nil + } + return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) + + case issueTypeUpdatedMsg: + m.choosingType = false + m.typeIssueID = "" + if msg.Err != nil { + return m, nil + } + return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) + + case selectIssueMsg: + m.issueList.SelectIssueID(msg.IssueID) + m.closedIssueList.SelectIssueID(msg.IssueID) + return m, nil + + case issueCreatedMsg: + m.creatingIssue = false + m.createTitleInput.Blur() + m.createTitleInput.Reset() + if msg.Err != nil || msg.Issue == nil { + return m, nil + } + issues, err := m.app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) + if err != nil { + return m, nil + } + setItemsCmd := m.issueList.SetIssues(OpenAndInProgressOnly(issues)) + closedSetCmd := m.closedIssueList.SetIssues(ClosedOnly(issues)) + + // Determine the created issue from the refreshed list to ensure all fields (like ID) are populated. + selectedIssue := msg.Issue + if selectedIssue.ID == "" { + for _, issue := range issues { + // Prefer an issue that matches the created issue's title when ID is not yet known. + if issue.Title == msg.Issue.Title { + selectedIssue = issue + break + } + } + } + + m.issueDetail.SetIssue(*selectedIssue) + return m, tea.Sequence(setItemsCmd, closedSetCmd, func() tea.Msg { return selectIssueMsg{IssueID: selectedIssue.ID} }) + case issueDeletedMsg: + m.confirmingDelete = false + m.deleteConfirmID = "" + if msg.Err != nil { + return m, nil + } + issues, err := m.app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) + if err != nil { + return m, nil + } + openIssues := OpenAndInProgressOnly(issues) + closedIssues := ClosedOnly(issues) + setItemsCmd := m.issueList.SetIssues(openIssues) + closedSetCmd := m.closedIssueList.SetIssues(closedIssues) + // If there are no issues at all, clear the detail view and return. + if len(openIssues) == 0 && len(closedIssues) == 0 { + m.issueDetail.SetIssue(models.Issue{}) + return m, tea.Sequence(setItemsCmd, closedSetCmd) + } + + // Determine which list to use for the next selection. + var targetIssues []*models.Issue + if m.focusedWindow == 0 { + targetIssues = openIssues + if len(targetIssues) == 0 && len(closedIssues) > 0 { + // The open list became empty; fall back to closed issues. + targetIssues = closedIssues + m.focusedWindow = 1 + } + } else { + targetIssues = closedIssues + if len(targetIssues) == 0 && len(openIssues) > 0 { + // The closed list became empty; fall back to open/in-progress issues. + targetIssues = openIssues + m.focusedWindow = 0 + } + } + + // Safety: if targetIssues is still empty here, just clear detail and return. + if len(targetIssues) == 0 { + m.issueDetail.SetIssue(models.Issue{}) + return m, tea.Sequence(setItemsCmd, closedSetCmd) + } + + newIndex := msg.PreviousIndex + if newIndex >= len(targetIssues) { + newIndex = len(targetIssues) - 1 + } + selectedIssue := targetIssues[newIndex] + m.issueDetail.SetIssue(*selectedIssue) + return m, tea.Sequence(setItemsCmd, closedSetCmd, func() tea.Msg { + return selectIssueMsg{IssueID: selectedIssue.ID} + }) + + case tea.KeyMsg: + if m.confirmingDelete { + switch msg.String() { + case "y", "Y": + issueID := m.deleteConfirmID + idx := m.deleteConfirmIndex + m.confirmingDelete = false + m.deleteConfirmID = "" + return m, deleteIssueCmd(m.app, issueID, idx) + case "n", "N", "esc": + m.confirmingDelete = false + m.deleteConfirmID = "" + return m, nil + } + } + + if m.choosingStatus { + switch msg.String() { + case "o": + issueID := m.statusIssueID + m.choosingStatus = false + m.statusIssueID = "" + return m, updateIssueStatusCmd(m.app, issueID, string(models.StatusOpen)) + case "i": + issueID := m.statusIssueID + m.choosingStatus = false + m.statusIssueID = "" + return m, updateIssueStatusCmd(m.app, issueID, string(models.StatusInProgress)) + case "c": + issueID := m.statusIssueID + m.choosingStatus = false + m.statusIssueID = "" + return m, updateIssueStatusCmd(m.app, issueID, string(models.StatusClosed)) + case "esc": + m.choosingStatus = false + m.statusIssueID = "" + return m, nil + } + } + + if m.choosingPriority { + switch msg.String() { + case "0", "1", "2", "3", "4": + issueID := m.priorityIssueID + priority := int(msg.String()[0] - '0') + m.choosingPriority = false + m.priorityIssueID = "" + return m, updateIssuePriorityCmd(m.app, issueID, priority) + case "esc": + m.choosingPriority = false + m.priorityIssueID = "" + return m, nil + default: + return m, nil + } + } + + if m.choosingType { + switch msg.String() { + case "b": + issueID := m.typeIssueID + m.choosingType = false + m.typeIssueID = "" + return m, updateIssueTypeCmd(m.app, issueID, models.TypeBug) + case "f": + issueID := m.typeIssueID + m.choosingType = false + m.typeIssueID = "" + return m, updateIssueTypeCmd(m.app, issueID, models.TypeFeature) + case "t": + issueID := m.typeIssueID + m.choosingType = false + m.typeIssueID = "" + return m, updateIssueTypeCmd(m.app, issueID, models.TypeTask) + case "e": + issueID := m.typeIssueID + m.choosingType = false + m.typeIssueID = "" + return m, updateIssueTypeCmd(m.app, issueID, models.TypeEpic) + case "c": + issueID := m.typeIssueID + m.choosingType = false + m.typeIssueID = "" + return m, updateIssueTypeCmd(m.app, issueID, models.TypeChore) + case "esc": + m.choosingType = false + m.typeIssueID = "" + return m, nil + default: + return m, nil + } + } + + if m.creatingIssue { + if msg.String() == "enter" { + title := m.createTitleInput.Value() + if title != "" { + return m, createIssueCmd(m.app, title) + } + } + if msg.String() == "esc" { + m.creatingIssue = false + m.createTitleInput.Blur() + m.createTitleInput.Reset() + return m, nil + } + var cmd tea.Cmd + m.createTitleInput, cmd = m.createTitleInput.Update(msg) + return m, cmd + } + + if m.editingTitle { + if msg.String() == "enter" { + newTitle := m.titleInput.Value() + if newTitle != "" { + return m, updateIssueTitleCmd(m.app, m.editingIssueID, newTitle) + } + } + if msg.String() == "esc" { + m.editingTitle = false + m.editingIssueID = "" + m.titleInput.Blur() + return m, nil + } + var cmd tea.Cmd + m.titleInput, cmd = m.titleInput.Update(msg) + return m, cmd + } + + if m.editingDescription { + if msg.String() == "ctrl+s" { + issueID := m.editingDescIssueID + newDesc := m.descriptionInput.Value() + m.editingDescription = false + m.editingDescIssueID = "" + m.descriptionInput.Blur() + return m, updateIssueDescriptionCmd(m.app, issueID, newDesc) + } + if msg.String() == "esc" { + m.editingDescription = false + m.editingDescIssueID = "" + m.descriptionInput.Blur() + return m, nil + } + var cmd tea.Cmd + m.descriptionInput, cmd = m.descriptionInput.Update(msg) + return m, cmd + } + + focusedList := m.FocusedIssueList() + if focusedList.FilterState() == list.Filtering { + cmd, _ := focusedList.Update(msg) + return m, cmd + } + + // On main dashboard, ESC does nothing; only q quits; like in lazybeads. + if msg.String() == "esc" { + return m, nil + } + + cmd := m.handleKeyMsg(msg) + if cmd != nil { + return m, cmd + } + case ValidationFeedbackMsg: + m.currentFeedback = msg.Feedback + if msg.Feedback.Success { + m.showComplete = true + return m, tea.Quit + } + return m, m.listenForValidation() + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + } + + if m.focusedWindow == 0 { + cmd, changed := m.issueList.Update(msg) + if changed { + if selected := m.issueList.SelectedItem(); selected.ID != "" { + m.issueDetail.SetIssue(selected.Issue) + } + } + return m, cmd + } + cmd, changed := m.closedIssueList.Update(msg) + if changed { + if selected := m.closedIssueList.SelectedItem(); selected.ID != "" { + m.issueDetail.SetIssue(selected.Issue) + } + } + return m, cmd +} diff --git a/pkg/tui/views/dashboard2/view.go b/pkg/tui/views/dashboard2/view.go new file mode 100644 index 0000000..8e0c485 --- /dev/null +++ b/pkg/tui/views/dashboard2/view.go @@ -0,0 +1,170 @@ +package dashboard2 + +import ( + "github.com/LazyBachelor/LazyPM/pkg/tui/styles" + "github.com/charmbracelet/lipgloss" +) + +func (m *Model) View() string { + if m.width == 0 || m.height == 0 { + // if there is no space just print a loading message + return "Loading..." + } + + m.helpBar.SetWidth(m.width) + + header := m.header.View(m.width) + headerHeight := m.header.Height() + + footer := m.footer() + footerHeight := lipgloss.Height(footer) + + // To avoid layer overflow or clipping, the label heights are calculated and subtracted from the available height before calculating the list heights to avoid layout overflow or clipping. + contentHeight := m.height - headerHeight - footerHeight + + mainLabel := styles.LabelStyle.Render("Display issues") + closedLabel := styles.LabelStyle.Render("Closed issues") + labelHeight := lipgloss.Height(mainLabel) + lipgloss.Height(closedLabel) + availableForLists := contentHeight - labelHeight + halfHeight := availableForLists / 2 + if halfHeight < 1 { + halfHeight = 1 + } + + totalContentWidth := m.width - 1 + listWidth := totalContentWidth * styles.ListViewRatio / 100 + detailWidth := totalContentWidth - listWidth + + m.issueList.SetSize(listWidth, halfHeight) + m.closedIssueList.SetSize(listWidth, halfHeight) + m.issueDetail.SetSize(detailWidth, contentHeight) + + listView := m.issueList.View() + closedListView := m.closedIssueList.View() + detailView := m.issueDetail.View() + + if m.focusedWindow == 0 { + mainLabel = lipgloss.NewStyle().Foreground(styles.Primary).Bold(true).Render("Display issues ▶") + } else { + closedLabel = lipgloss.NewStyle().Foreground(styles.Primary).Bold(true).Render("Closed issues ▶") + } + leftColumn := lipgloss.JoinVertical(lipgloss.Left, + mainLabel, listView, + closedLabel, closedListView, + ) + content := lipgloss.JoinHorizontal(lipgloss.Left, leftColumn, detailView) + + mainView := lipgloss.JoinVertical(lipgloss.Left, header, content, footer) + + if m.editingTitle { + editBoxWidth := min(60, m.width-4) + m.titleInput.Width = editBoxWidth - 2 + editContent := lipgloss.JoinVertical(lipgloss.Left, + styles.LabelStyle.Render("Edit title (Enter to save, Esc to cancel):"), + m.titleInput.View(), + ) + editBox := styles.ContainerStyle. + Width(editBoxWidth). + BorderForeground(styles.PrimaryBorder). + Render(editContent) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, editBox) + } + + if m.editingDescription { + editBoxWidth := min(60, m.width-4) + m.descriptionInput.SetWidth(editBoxWidth - 2) + m.descriptionInput.SetHeight(10) + editContent := lipgloss.JoinVertical(lipgloss.Left, + styles.LabelStyle.Render("Edit description (Ctrl+S to save, Esc to cancel):"), + m.descriptionInput.View(), + ) + editBox := styles.ContainerStyle. + Width(editBoxWidth). + BorderForeground(styles.PrimaryBorder). + Render(editContent) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, editBox) + } + + if m.creatingIssue { + createBoxWidth := min(60, m.width-4) + m.createTitleInput.Width = createBoxWidth - 2 + createContent := lipgloss.JoinVertical(lipgloss.Left, + styles.LabelStyle.Render("New issue (Enter to create, Esc to cancel):"), + m.createTitleInput.View(), + ) + createBox := styles.ContainerStyle. + Width(createBoxWidth). + BorderForeground(styles.PrimaryBorder). + Render(createContent) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, createBox) + } + + if m.confirmingDelete { + confirmContent := lipgloss.JoinVertical(lipgloss.Left, + styles.LabelStyle.Render("Delete issue "+m.deleteConfirmID+"?"), + lipgloss.NewStyle().Foreground(styles.FaintText).Render("Press y to delete, n or Esc to cancel"), + ) + confirmBoxWidth := min(50, m.width-4) + confirmBox := styles.ContainerStyle. + Width(confirmBoxWidth). + BorderForeground(styles.PrimaryBorder). + Render(confirmContent) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, confirmBox) + } + + if m.choosingStatus { + statusContent := lipgloss.JoinVertical(lipgloss.Left, + styles.LabelStyle.Render("Change status for "+m.statusIssueID+":"), + lipgloss.NewStyle().Foreground(styles.FaintText).Render("o = open i = in_progress c = closed"), + lipgloss.NewStyle().Foreground(styles.FaintText).Render("Esc = cancel"), + ) + statusBoxWidth := min(50, m.width-4) + statusBox := styles.ContainerStyle. + Width(statusBoxWidth). + BorderForeground(styles.PrimaryBorder). + Render(statusContent) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, statusBox) + } + + if m.choosingPriority { + priorityContent := lipgloss.JoinVertical(lipgloss.Left, + styles.LabelStyle.Render("Change priority for "+m.priorityIssueID+":"), + lipgloss.NewStyle().Foreground(styles.FaintText).Render("0 = irrelevant 1 = low 2 = normal 3 = high 4 = critical"), + lipgloss.NewStyle().Foreground(styles.FaintText).Render("Esc = cancel"), + ) + priorityBoxWidth := min(60, m.width-4) + priorityBox := styles.ContainerStyle. + Width(priorityBoxWidth). + BorderForeground(styles.PrimaryBorder). + Render(priorityContent) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, priorityBox) + } + + if m.choosingType { + typeContent := lipgloss.JoinVertical(lipgloss.Left, + styles.LabelStyle.Render("Change type for "+m.typeIssueID+":"), + lipgloss.NewStyle().Foreground(styles.FaintText).Render("b = bug f = feature t = task e = epic c = chore"), + lipgloss.NewStyle().Foreground(styles.FaintText).Render("Esc = cancel"), + ) + typeBoxWidth := min(65, m.width-4) + typeBox := styles.ContainerStyle. + Width(typeBoxWidth). + BorderForeground(styles.PrimaryBorder). + Render(typeContent) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, typeBox) + } + + return mainView + +} + +func (m *Model) footer() string { + feedbackStatus := m.currentFeedback.Message + + if feedbackStatus == "" { + return m.helpBar.View() + } + + m.helpBar.SetWidth(m.width - lipgloss.Width(feedbackStatus)) + return lipgloss.JoinHorizontal(lipgloss.Left, m.helpBar.View(), feedbackStatus) +} diff --git a/pkg/tui/views/root.go b/pkg/tui/views/root.go new file mode 100644 index 0000000..98c2af4 --- /dev/null +++ b/pkg/tui/views/root.go @@ -0,0 +1,50 @@ +package views + +import ( + "github.com/LazyBachelor/LazyPM/internal/app" + "github.com/LazyBachelor/LazyPM/internal/models" + "github.com/LazyBachelor/LazyPM/pkg/tui/msgs" + "github.com/LazyBachelor/LazyPM/pkg/tui/views/dashboard" + "github.com/LazyBachelor/LazyPM/pkg/tui/views/dashboard2" + tea "github.com/charmbracelet/bubbletea" +) + +type RootModel struct { + currentView tea.Model + app *app.App + feedbackChan chan models.ValidationFeedback + quitChan chan bool +} + +func NewRootView(app *app.App, feedbackChan chan models.ValidationFeedback, quitChan chan bool) *RootModel { + initialView := dashboard.NewDashboard(app, feedbackChan, quitChan) + return &RootModel{ + currentView: initialView, + app: app, + feedbackChan: feedbackChan, + quitChan: quitChan, + } +} + +func (r *RootModel) Init() tea.Cmd { + return r.currentView.Init() +} + +func (r *RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg.(type) { + case msgs.SwitchToDashboard2Msg: + r.currentView = dashboard2.NewDashboard(r.app, r.feedbackChan, r.quitChan) + return r, r.currentView.Init() + case msgs.SwitchToDashboardMsg: + r.currentView = dashboard.NewDashboard(r.app, r.feedbackChan, r.quitChan) + return r, r.currentView.Init() + } + + var cmd tea.Cmd + r.currentView, cmd = r.currentView.Update(msg) + return r, cmd +} + +func (r *RootModel) View() string { + return r.currentView.View() +}