diff --git a/internal/style/styles.go b/internal/style/styles.go index 5e56d2f..a79d65e 100644 --- a/internal/style/styles.go +++ b/internal/style/styles.go @@ -2,30 +2,98 @@ package style import ( "charm.land/lipgloss/v2" -) - -// Color palette -var ( - PrimaryColor = lipgloss.Color("6") - SecondaryColor = lipgloss.Color("2") - AccentColor = lipgloss.Color("7") - TextColor = lipgloss.Color("15") - - BorderColor = lipgloss.Color("8") + "charm.land/lipgloss/v2/compat" ) var ( - AppStyle = lipgloss.NewStyle().Padding(1, 2).Foreground(TextColor) + Primary = compat.AdaptiveColor{Light: lipgloss.Color("#5A56E0"), Dark: lipgloss.Color("#7571F9")} + Secondary = compat.AdaptiveColor{Light: lipgloss.Color("#02BA84"), Dark: lipgloss.Color("#02BF87")} + + Success = compat.AdaptiveColor{Light: lipgloss.Color("#02BA84"), Dark: lipgloss.Color("#02BF87")} + Warning = compat.AdaptiveColor{Light: lipgloss.Color("#F59E0B"), Dark: lipgloss.Color("#F59E0B")} + Error = compat.AdaptiveColor{Light: lipgloss.Color("#FE5F86"), Dark: lipgloss.Color("#FE5F86")} + + PrimaryText = compat.AdaptiveColor{Light: lipgloss.Color("#1A1A1A"), Dark: lipgloss.Color("#E0E0E0")} + SecondaryText = compat.AdaptiveColor{Light: lipgloss.Color("#666666"), Dark: lipgloss.Color("#999999")} + FaintText = compat.AdaptiveColor{Light: lipgloss.Color("#999999"), Dark: lipgloss.Color("#666666")} + + PrimaryBorder = compat.AdaptiveColor{Light: lipgloss.Color("#5A56E0"), Dark: lipgloss.Color("#7571F9")} + SecondaryBorder = compat.AdaptiveColor{Light: lipgloss.Color("#CCCCCC"), Dark: lipgloss.Color("#444444")} + + SelectedBackground = compat.AdaptiveColor{Light: lipgloss.Color("#E8E8E8"), Dark: lipgloss.Color("#333333")} +) + +const ( + ListViewRatio = 52 // Percentage of total width allocated to the list view + LabelWidth = 14 + MarginBottomSmall = 1 +) + +var DefaultBorder = lipgloss.ThickBorder() + +var ( + HeaderStyle = lipgloss.NewStyle().Foreground(Primary).Padding(0, 1).Bold(true) + HeaderTitleStyle = lipgloss.NewStyle().Foreground(Primary).Bold(true).Padding(0) +) + +var ContainerStyle = lipgloss.NewStyle(). + Border(DefaultBorder, true, false, false, false). + BorderForeground(SecondaryBorder). + Padding(1) + +var ModalContainerStyle = lipgloss.NewStyle(). + Border(DefaultBorder). + BorderForeground(PrimaryBorder). + Padding(2, 3) + +var DetailsContainerStyle = lipgloss.NewStyle(). + Border(DefaultBorder, true, false, false, true). + BorderForeground(SecondaryBorder). + Padding(1) + +var ( + RowStyle = lipgloss.NewStyle().MarginBottom(MarginBottomSmall) + TitleStyle = lipgloss.NewStyle().Foreground(Primary).Bold(true) + LabelStyle = lipgloss.NewStyle().Foreground(SecondaryText) + ValueStyle = lipgloss.NewStyle().Foreground(PrimaryText) + IssueTypeStyle = lipgloss.NewStyle().Foreground(Primary).Bold(true) ) var ( - DefaultBorder = lipgloss.NormalBorder() - BorderStyle = lipgloss.NewStyle().Border(DefaultBorder).BorderForeground(BorderColor) + FilterStyle = lipgloss.NewStyle().Foreground(Primary).Bold(true).Padding(0, 1) + FilterInputStyle = lipgloss.NewStyle().Foreground(PrimaryText).Padding(0, 1) + FilterPromptStyle = lipgloss.NewStyle().Foreground(Primary).Bold(true) ) +func StatusStyle(status string) lipgloss.Style { + style := lipgloss.NewStyle().Bold(true) + switch status { + case "open": + return style.Foreground(Secondary) + case "closed": + return style.Foreground(FaintText) + case "in_progress": + return style.Foreground(Warning) + case "blocked": + return style.Foreground(Error) + default: + return style.Foreground(SecondaryText) + } +} + +func HighlightKey(key string) string { + return lipgloss.NewStyle(). + Foreground(Primary). + Bold(true). + Padding(0, 1). + Render(key) +} + var ( - TitleStyle = lipgloss.NewStyle().Foreground(PrimaryColor).Bold(true) - TextStyle = lipgloss.NewStyle().Foreground(TextColor) - HelpStyle = lipgloss.NewStyle().Align(lipgloss.Center).Foreground(AccentColor) - ErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true) // Red color for errors + TextStyle = lipgloss.NewStyle().Foreground(PrimaryText) + BorderStyle = lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(SecondaryBorder) + ErrorStyle = lipgloss.NewStyle().Foreground(Error).Bold(true) + HelpStyle = lipgloss.NewStyle().Align(lipgloss.Center).Foreground(Secondary) ) + +var SecondaryColor = lipgloss.Color("#02BA84") diff --git a/internal/utils/truncate/trunc.go b/internal/utils/truncate/trunc.go new file mode 100644 index 0000000..9b34f11 --- /dev/null +++ b/internal/utils/truncate/trunc.go @@ -0,0 +1,43 @@ +package truncate + +import "charm.land/lipgloss/v2" + +// TruncateToWidth trims the given text so that its rendered width does not +// exceed maxWidth. If truncation occurs and there is room, an ellipsis is +// appended to indicate that the text was shortened. +func TruncateToWidth(text string, maxWidth int) string { + if maxWidth <= 0 { + return "" + } + + if lipgloss.Width(text) <= maxWidth { + return text + } + + const ellipsis = "…" + ellipsisWidth := lipgloss.Width(ellipsis) + + if ellipsisWidth > maxWidth { + runes := []rune(text) + if len(runes) > 0 { + firstChar := string(runes[0]) + if lipgloss.Width(firstChar) <= maxWidth { + return firstChar + } + } + return "" + } + + runes := []rune(text) + lastSafe := 0 + for i := range runes { + candidate := string(runes[:i+1]) + if lipgloss.Width(candidate)+ellipsisWidth > maxWidth { + break + } + lastSafe = i + 1 + } + + current := string(runes[:lastSafe]) + return current + ellipsis +} diff --git a/internal/utils/user/user.go b/internal/utils/user/user.go new file mode 100644 index 0000000..16047f2 --- /dev/null +++ b/internal/utils/user/user.go @@ -0,0 +1,19 @@ +package user + +import ( + "os" + "os/user" +) + +func GetOsUsername() string { + if u, err := user.Current(); err == nil && u.Username != "" { + return u.Username + } + if s := os.Getenv("USER"); s != "" { + return s + } + if s := os.Getenv("USERNAME"); s != "" { + return s + } + return "user" +} diff --git a/pkg/tui/components/footer.go b/pkg/tui/components/footer.go new file mode 100644 index 0000000..10cde57 --- /dev/null +++ b/pkg/tui/components/footer.go @@ -0,0 +1,48 @@ +package components + +import ( + "charm.land/lipgloss/v2" + "github.com/LazyBachelor/LazyPM/internal/models" + "github.com/LazyBachelor/LazyPM/internal/style" + "github.com/LazyBachelor/LazyPM/internal/utils/truncate" +) + +// RenderFooter renders the shared footer with the help bar and optional +// validation feedback message. +func RenderFooter(width int, helpBar *HelpBar, feedback models.ValidationFeedback) string { + feedbackStatus := feedback.Message + + // Ensure the feedback message does not exceed the total available width. + if feedback.Message != "" { + // Allocate at least 30% of width for feedback, but not more than 60% + feedbackWidth := max(width*3/10, min(width/2, 50)) + styledFeedback := style.ErrorStyle.Render(feedbackStatus + " [Press '?' for details]") + feedbackStatus = truncate.TruncateToWidth(styledFeedback, feedbackWidth) + + if helpBar.IsExpanded() && feedbackStatus != "" { + for _, check := range feedback.Checks { + var prefix string + if check.Valid { + prefix = "✅ " + } else { + prefix = "❌ " + } + + remainingWidth := max(width/2, 10) + styledCheckMsg := style.TextStyle.Render(check.Message) + truncatedMsg := truncate.TruncateToWidth(styledCheckMsg, remainingWidth) + + feedbackStatus += "\n" + prefix + truncatedMsg + } + } + } + + if feedbackStatus == "" { + return helpBar.View() + } + + helpWidth := max(width-lipgloss.Width(feedbackStatus), 0) + + helpBar.SetWidth(helpWidth) + return lipgloss.JoinHorizontal(lipgloss.Left, helpBar.View(), feedbackStatus) +} diff --git a/pkg/tui/components/header.go b/pkg/tui/components/header.go index c5d625b..6c646d2 100644 --- a/pkg/tui/components/header.go +++ b/pkg/tui/components/header.go @@ -2,7 +2,7 @@ package components import ( "charm.land/lipgloss/v2" - "github.com/LazyBachelor/LazyPM/pkg/tui/styles" + "github.com/LazyBachelor/LazyPM/internal/style" ) type Header struct { @@ -14,7 +14,7 @@ func NewHeader(title string) Header { } func (h Header) View(width int) string { - title := styles.HeaderTitleStyle.Render(h.Title) + title := style.HeaderTitleStyle.Render(h.Title) return lipgloss.PlaceHorizontal( width, diff --git a/pkg/tui/components/helpbar.go b/pkg/tui/components/helpbar.go index 85d004a..27ec2a7 100644 --- a/pkg/tui/components/helpbar.go +++ b/pkg/tui/components/helpbar.go @@ -4,7 +4,7 @@ import ( "strings" "charm.land/lipgloss/v2" - "github.com/LazyBachelor/LazyPM/pkg/tui/styles" + "github.com/LazyBachelor/LazyPM/internal/style" ) type ViewKind int @@ -66,7 +66,7 @@ func (h HelpBar) shortHelp() string { for _, item := range h.config.ShortItems { items = append( items, - styles.HighlightKey(item.Key)+item.Desc+" ", + style.HighlightKey(item.Key)+item.Desc+" ", ) } @@ -74,7 +74,7 @@ func (h HelpBar) shortHelp() string { return lipgloss.NewStyle(). Border(lipgloss.Border{Top: "─"}, true, false, false, false). - BorderForeground(styles.SecondaryBorder). + BorderForeground(style.SecondaryBorder). Padding(0, 1). Width(h.width). Render(content) @@ -91,7 +91,7 @@ func (h HelpBar) fullHelp() string { renderItem := func(item HelpItem) string { return cellStyle.Render( - keyStyle.Render(styles.HighlightKey(item.Key)) + " " + descStyle.Render(item.Desc), + keyStyle.Render(style.HighlightKey(item.Key)) + " " + descStyle.Render(item.Desc), ) } @@ -115,7 +115,7 @@ func (h HelpBar) fullHelp() string { content := lipgloss.JoinVertical(lipgloss.Left, result...) return lipgloss.NewStyle(). Border(lipgloss.Border{Top: "─"}, true, false, false, false). - BorderForeground(styles.SecondaryBorder). + BorderForeground(style.SecondaryBorder). Padding(0, 1). Width(h.width). Render(content) diff --git a/pkg/tui/components/issue_detail.go b/pkg/tui/components/issue_detail.go index abc7022..7e1ba92 100644 --- a/pkg/tui/components/issue_detail.go +++ b/pkg/tui/components/issue_detail.go @@ -6,7 +6,7 @@ import ( "charm.land/bubbles/v2/viewport" "charm.land/lipgloss/v2" "github.com/LazyBachelor/LazyPM/internal/models" - "github.com/LazyBachelor/LazyPM/pkg/tui/styles" + "github.com/LazyBachelor/LazyPM/internal/style" ) type IssueDetail struct { @@ -46,20 +46,20 @@ func (i *IssueDetail) SetFocused(focused bool) { func (i *IssueDetail) refreshContent() { contentWidth := max(i.viewport.Width()-2, 1) - titleRow := styles.RowStyle.Render( - styles.TitleStyle.Render(i.issue.Title), + titleRow := style.RowStyle.Render( + style.TitleStyle.Render(i.issue.Title), ) - idRow := styles.RowStyle.Render( - styles.LabelStyle.Render("ID:") + styles.ValueStyle.Render(i.issue.ID), + idRow := style.RowStyle.Render( + style.LabelStyle.Render("ID:") + style.ValueStyle.Render(i.issue.ID), ) - typeRow := styles.RowStyle.Render( - styles.LabelStyle.Render("Type:") + styles.ValueStyle.Render(string(i.issue.IssueType)), + typeRow := style.RowStyle.Render( + style.LabelStyle.Render("Type:") + style.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)), + statusRow := style.RowStyle.Render( + style.LabelStyle.Render("Status:") + style.StatusStyle(string(i.issue.Status)).Render(string(i.issue.Status)), ) var closingReasonRow string @@ -70,23 +70,23 @@ func (i *IssueDetail) refreshContent() { } else { closingReason = string(i.issue.CloseReason) } - closingReasonRow = styles.RowStyle.Render( - styles.LabelStyle.Render("Close reason: ") + styles.ValueStyle.Render(closingReason)) + closingReasonRow = style.RowStyle.Render( + style.LabelStyle.Render("Close reason: ") + style.ValueStyle.Render(closingReason)) } - priorityRow := styles.RowStyle.Render( - styles.LabelStyle.Render("Priority:") + styles.ValueStyle.Render(PriorityCodeName(i.issue.Priority)), + priorityRow := style.RowStyle.Render( + style.LabelStyle.Render("Priority:") + style.ValueStyle.Render(PriorityCodeName(i.issue.Priority)), ) - assigneeRow := styles.RowStyle.Render( - styles.LabelStyle.Render("Assignee:") + styles.ValueStyle.Render(i.issue.Assignee), + assigneeRow := style.RowStyle.Render( + style.LabelStyle.Render("Assignee:") + style.ValueStyle.Render(i.issue.Assignee), ) - descLabel := styles.LabelStyle.Render("Description:") - descStyle := styles.ValueStyle.Width(contentWidth) + descLabel := style.LabelStyle.Render("Description:") + descStyle := style.ValueStyle.Width(contentWidth) descContent := descStyle.Render(i.issue.Description) - commentsLabel := styles.LabelStyle.MarginTop(1).Render("Comments:") + commentsLabel := style.LabelStyle.MarginTop(1).Render("Comments:") var parts []string parts = append(parts, titleRow, idRow, typeRow, statusRow, closingReasonRow, priorityRow, assigneeRow, descLabel, descContent, commentsLabel) @@ -106,14 +106,14 @@ func (i IssueDetail) View() string { vpHeight := i.viewport.Height() if i.focused { - return styles.DetailsContainerStyle. - BorderForeground(styles.PrimaryBorder). + return style.DetailsContainerStyle. + BorderForeground(style.PrimaryBorder). Width(vpWidth). Height(vpHeight). MaxHeight(vpHeight). Render(content) } - return styles.DetailsContainerStyle. + return style.DetailsContainerStyle. Width(vpWidth). Height(vpHeight). MaxHeight(vpHeight). @@ -133,12 +133,12 @@ func (i *IssueDetail) renderComments() []string { var parts []string if len(i.comments) == 0 { - parts = append(parts, styles.ValueStyle.Render("No comments yet.")) + parts = append(parts, style.ValueStyle.Render("No comments yet.")) } else { for _, c := range i.comments { - authorDate := lipgloss.NewStyle().Foreground(styles.Primary).Render(c.Author) + " " + - lipgloss.NewStyle().Foreground(styles.FaintText).Render(formatCommentTime(c.CreatedAt)) - commentTextStyle := styles.ValueStyle.Width(contentWidth) + authorDate := lipgloss.NewStyle().Foreground(style.Primary).Render(c.Author) + " " + + lipgloss.NewStyle().Foreground(style.FaintText).Render(formatCommentTime(c.CreatedAt)) + commentTextStyle := style.ValueStyle.Width(contentWidth) commentRow := lipgloss.JoinVertical(lipgloss.Left, authorDate, commentTextStyle.MarginLeft(1).Render(c.Text), diff --git a/pkg/tui/components/issue_list.go b/pkg/tui/components/issue_list.go index d82a16a..e306b4f 100644 --- a/pkg/tui/components/issue_list.go +++ b/pkg/tui/components/issue_list.go @@ -13,7 +13,7 @@ import ( "charm.land/lipgloss/v2" "github.com/LazyBachelor/LazyPM/internal/app" "github.com/LazyBachelor/LazyPM/internal/models" - "github.com/LazyBachelor/LazyPM/pkg/tui/styles" + "github.com/LazyBachelor/LazyPM/internal/style" "github.com/muesli/reflow/truncate" ) @@ -116,7 +116,7 @@ var priorityCodeNames = map[int]string{ func renderHeaders(cols []tableColumn) string { var parts []string - headerStyle := lipgloss.NewStyle().Foreground(styles.FaintText).Bold(true) + headerStyle := lipgloss.NewStyle().Foreground(style.FaintText).Bold(true) for _, col := range cols { colWidth := col.width @@ -302,14 +302,14 @@ func (l IssueList) renderResponsive() string { if l.list.FilterState() == list.Filtering { filterText := l.list.FilterInput.Value() - filterView := styles.FilterStyle.Render("🔍 " + filterText) + filterView := style.FilterStyle.Render("🔍 " + filterText) content = append(content, filterView) } itemsView := l.renderFilteredItems() content = append(content, header, itemsView) - return styles.ContainerStyle. + return style.ContainerStyle. Width(l.width). MaxWidth(l.width). MaxHeight(l.height). @@ -409,16 +409,16 @@ func renderRow(issue ListIssue, isSelected bool, cols []tableColumn) string { colWidth = 1 } - style := lipgloss.NewStyle().Width(int(colWidth)) + cellStyle := lipgloss.NewStyle().Width(int(colWidth)) if isSelected { - style = style.Background(styles.SelectedBackground).Bold(true) + cellStyle = cellStyle.Background(style.SelectedBackground).Bold(true) } if issue.Status == models.StatusClosed { - style = style.Strikethrough(true).Foreground(styles.FaintText) + cellStyle = cellStyle.Strikethrough(true).Foreground(style.FaintText) } truncated := truncate.StringWithTail(value, colWidth, "...") - parts = append(parts, style.Render(truncated)) + parts = append(parts, cellStyle.Render(truncated)) } return lipgloss.JoinHorizontal(lipgloss.Left, parts...) diff --git a/pkg/tui/components/modals.go b/pkg/tui/components/modals.go deleted file mode 100644 index b204001..0000000 --- a/pkg/tui/components/modals.go +++ /dev/null @@ -1,273 +0,0 @@ -package components - -import ( - "charm.land/lipgloss/v2" - "github.com/LazyBachelor/LazyPM/internal/models" - "github.com/LazyBachelor/LazyPM/internal/style" - "github.com/LazyBachelor/LazyPM/pkg/tui/styles" -) - -// modalBoxWidth returns a clamped width for modal content. Never returns a value < 1. -func modalBoxWidth(maxWidth, width int) int { - if width < 5 { - return 1 - } - w := min(maxWidth, width-4) - if w < 1 { - return 1 - } - return w -} - -// components contains reusable TUI modal renderers for issue actions. - -func RenderEditTitle(width, height int, inputView string) string { - if width < 5 || height < 5 { - return "" - } - editBoxWidth := modalBoxWidth(60, width) - editContent := lipgloss.JoinVertical(lipgloss.Left, - styles.LabelStyle.Render("Edit title (Enter to save, Esc to cancel):"), - inputView, - ) - editBox := styles.ContainerStyle. - Width(editBoxWidth). - BorderForeground(styles.PrimaryBorder). - Render(editContent) - return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, editBox) -} - -func RenderEditDescription(width, height int, inputView string) string { - if width < 5 || height < 5 { - return "" - } - editBoxWidth := modalBoxWidth(60, width) - editContent := lipgloss.JoinVertical(lipgloss.Left, - styles.LabelStyle.Render("Edit description (Ctrl+S to save, Esc to cancel):"), - inputView, - ) - editBox := styles.ContainerStyle. - Width(editBoxWidth). - BorderForeground(styles.PrimaryBorder). - Render(editContent) - return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, editBox) -} - -func RenderCreateIssue(width, height int, inputView string) string { - if width < 5 || height < 5 { - return "" - } - createBoxWidth := modalBoxWidth(60, width) - createContent := lipgloss.JoinVertical(lipgloss.Left, - styles.LabelStyle.Render("New issue (Enter to create, Esc to cancel):"), - inputView, - ) - createBox := styles.ContainerStyle. - Width(createBoxWidth). - BorderForeground(styles.PrimaryBorder). - Render(createContent) - return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, createBox) -} - -func RenderConfirmDelete(width, height int, issueID string) string { - if width < 5 || height < 5 { - return "" - } - confirmContent := lipgloss.JoinVertical(lipgloss.Left, - styles.LabelStyle.Render("Delete issue "+issueID+"?"), - lipgloss.NewStyle().Foreground(styles.FaintText).Render("Press y to delete, n or Esc to cancel"), - ) - confirmBoxWidth := modalBoxWidth(50, width) - confirmBox := styles.ContainerStyle. - Width(confirmBoxWidth). - BorderForeground(styles.PrimaryBorder). - Render(confirmContent) - return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, confirmBox) -} - -func RenderChooseStatus(width, height int, issueID string) string { - if width < 5 || height < 5 { - return "" - } - statusContent := lipgloss.JoinVertical(lipgloss.Left, - styles.LabelStyle.Render("Change status for "+issueID+":"), - lipgloss.NewStyle().Foreground(styles.FaintText).Render("o = open i = in_progress b = blocked r = ready_to_sprint c = closed"), - lipgloss.NewStyle().Foreground(styles.FaintText).Render("Esc = cancel"), - ) - statusBoxWidth := modalBoxWidth(50, width) - statusBox := styles.ContainerStyle. - Width(statusBoxWidth). - BorderForeground(styles.PrimaryBorder). - Render(statusContent) - return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, statusBox) -} - -func RenderChoosePriority(width, height int, issueID string) string { - if width < 5 || height < 5 { - return "" - } - priorityContent := lipgloss.JoinVertical(lipgloss.Left, - styles.LabelStyle.Render("Change priority for "+issueID+":"), - 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 := modalBoxWidth(60, width) - priorityBox := styles.ContainerStyle. - Width(priorityBoxWidth). - BorderForeground(styles.PrimaryBorder). - Render(priorityContent) - return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, priorityBox) -} - -func RenderEditAssignee(width, height int, inputView string) string { - if width < 5 || height < 5 { - return "" - } - editBoxWidth := modalBoxWidth(60, width) - editContent := lipgloss.JoinVertical(lipgloss.Left, - styles.LabelStyle.Render("Edit assignee (Enter to save, Esc to cancel):"), - inputView, - ) - editBox := styles.ContainerStyle. - Width(editBoxWidth). - BorderForeground(styles.PrimaryBorder). - Render(editContent) - return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, editBox) -} - -func RenderChooseType(width, height int, issueID string) string { - if width < 5 || height < 5 { - return "" - } - typeContent := lipgloss.JoinVertical(lipgloss.Left, - styles.LabelStyle.Render("Change type for "+issueID+":"), - 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 := modalBoxWidth(65, width) - typeBox := styles.ContainerStyle. - Width(typeBoxWidth). - BorderForeground(styles.PrimaryBorder). - Render(typeContent) - return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, typeBox) -} - -// RenderModals wraps the common modal overlay logic used by different views. -// It returns either one of the modal overlays (edit title/description, create, -// confirm delete, choose status/priority/type) or the provided main view if -// no modal is active. -func RenderModals( - width, height int, - editingTitle bool, titleInputView string, - editingDescription bool, descriptionInputView string, - creatingIssue bool, createTitleInputView string, - confirmingDelete bool, deleteIssueID string, - choosingStatus bool, statusIssueID string, - choosingPriority bool, priorityIssueID string, - choosingType bool, typeIssueID string, - editingAssignee bool, assigneeInputView string, - mainView string, -) string { - if editingTitle { - return RenderEditTitle(width, height, titleInputView) - } - - if editingDescription { - return RenderEditDescription(width, height, descriptionInputView) - } - - if creatingIssue { - return RenderCreateIssue(width, height, createTitleInputView) - } - - if confirmingDelete { - return RenderConfirmDelete(width, height, deleteIssueID) - } - - if choosingStatus { - return RenderChooseStatus(width, height, statusIssueID) - } - - if choosingPriority { - return RenderChoosePriority(width, height, priorityIssueID) - } - - if choosingType { - return RenderChooseType(width, height, typeIssueID) - } - - if editingAssignee { - return RenderEditAssignee(width, height, assigneeInputView) - } - - return mainView -} - -// truncateToWidth trims the given text so that its rendered width does not -// exceed maxWidth. If truncation occurs and there is room, an ellipsis is -// appended to indicate that the text was shortened. -func truncateToWidth(text string, maxWidth int) string { - if maxWidth <= 0 { - return "" - } - - if lipgloss.Width(text) <= maxWidth { - return text - } - - const ellipsis = "…" - ellipsisWidth := lipgloss.Width(ellipsis) - if ellipsisWidth > maxWidth { - // Not enough space even for an ellipsis; return empty. - return "" - } - - runes := []rune(text) - current := "" - for _, r := range runes { - next := current + string(r) - if lipgloss.Width(next)+ellipsisWidth > maxWidth { - break - } - current = next - } - - return current + ellipsis -} - -// RenderFooter renders the shared footer with the help bar and optional -// validation feedback message. -func RenderFooter(width int, helpBar *HelpBar, feedback models.ValidationFeedback) string { - feedbackStatus := feedback.Message - - // Ensure the feedback message does not exceed the total available width. - if feedback.Message != "" { - feedbackStatus = truncateToWidth(style.ErrorStyle.Render(feedbackStatus+" [Press '?' for details]"), width) - - if helpBar.IsExpanded() && feedbackStatus != "" { - for _, check := range feedback.Checks { - var prefix string - if check.Valid { - prefix = "✅ " - } else { - prefix = "❌ " - } - - // Ensure each check line does not exceed the available width. - remainingWidth := max(width-lipgloss.Width(prefix), 0) - truncatedMsg := truncateToWidth(check.Message, remainingWidth) - - feedbackStatus += "\n" + prefix + truncatedMsg - } - } - } - - if feedbackStatus == "" { - return helpBar.View() - } - - helpWidth := max(width-lipgloss.Width(feedbackStatus), 0) - - helpBar.SetWidth(helpWidth) - return lipgloss.JoinHorizontal(lipgloss.Left, helpBar.View(), feedbackStatus) -} diff --git a/pkg/tui/modal/base.go b/pkg/tui/modal/base.go new file mode 100644 index 0000000..6efbe99 --- /dev/null +++ b/pkg/tui/modal/base.go @@ -0,0 +1,102 @@ +package modal + +import ( + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/LazyBachelor/LazyPM/internal/style" +) + +// BaseModal provides common functionality for all modals. +// Embed this struct to get default implementations of the Modal interface. +type BaseModal struct { + id string + modType ModalType + active bool + width int + height int +} + +// NewBaseModal creates a new base modal with the given properties +func NewBaseModal(id string, modType ModalType) BaseModal { + return BaseModal{ + id: id, + modType: modType, + width: 80, + height: 20, + } +} + +// ID returns the modal's unique identifier +func (b *BaseModal) ID() string { + return b.id +} + +// Type returns the modal type +func (b *BaseModal) Type() ModalType { + return b.modType +} + +// IsActive returns true if the modal is currently active +func (b *BaseModal) IsActive() bool { + return b.active +} + +// SetSize updates the modal dimensions +func (b *BaseModal) SetSize(width, height int) { + if width < 1 { + width = 1 + } + if height < 1 { + height = 1 + } + b.width = width + b.height = height +} + +// activate marks the modal as active +func (b *BaseModal) activate() tea.Cmd { + b.active = true + return nil +} + +// deactivate marks the modal as inactive +func (b *BaseModal) deactivate() { + b.active = false +} + +// Width returns the modal width +func (b *BaseModal) Width() int { + return b.width +} + +// Height returns the modal height +func (b *BaseModal) Height() int { + return b.height +} + +// ModalFrame renders content within a standard modal frame without full-screen placement +func ModalFrame(content string, width int) string { + if width < 5 { + return "" + } + + boxWidth := max(min(80, width-4), 1) + + return style.ModalContainerStyle. + Width(boxWidth). + Render(content) +} + +// ModalWithLabel renders a modal with a label and content +func ModalWithLabel(label, content string, width int) string { + if width < 5 { + return "" + } + + fullContent := lipgloss.JoinVertical(lipgloss.Left, + style.LabelStyle.Render(label), + content, + ) + + return ModalFrame(fullContent, width) +} diff --git a/pkg/tui/modal/confirm.go b/pkg/tui/modal/confirm.go new file mode 100644 index 0000000..20e9a8b --- /dev/null +++ b/pkg/tui/modal/confirm.go @@ -0,0 +1,141 @@ +package modal + +import ( + "slices" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/LazyBachelor/LazyPM/internal/style" +) + +// ConfirmResult is returned when a confirm modal completes +type ConfirmResult struct { + Confirmed bool +} + +// ConfirmModal is a modal for yes/no confirmations +// Suitable for: delete confirmations, discard changes, etc. +type ConfirmModal struct { + BaseModal + message string + yesKeys []string + noKeys []string + issueID string + width int + height int +} + +// ConfirmConfig configures a confirmation modal +type ConfirmConfig struct { + ID string + Message string + YesKeys []string + NoKeys []string + IssueID string + Width int + Height int +} + +// NewConfirmModal creates a new confirmation modal +func NewConfirmModal(cfg ConfirmConfig) *ConfirmModal { + if cfg.YesKeys == nil { + cfg.YesKeys = []string{"y", "Y"} + } + if cfg.NoKeys == nil { + cfg.NoKeys = []string{"n", "N", "esc"} + } + + mod := &ConfirmModal{ + BaseModal: NewBaseModal(cfg.ID, TypeConfirm), + message: cfg.Message, + yesKeys: cfg.YesKeys, + noKeys: cfg.NoKeys, + issueID: cfg.IssueID, + width: cfg.Width, + height: cfg.Height, + } + + if mod.width == 0 { + mod.width = 50 + } + if mod.height == 0 { + mod.height = 20 + } + + return mod +} + +// Activate prepares the modal +func (c *ConfirmModal) Activate() tea.Cmd { + c.BaseModal.activate() + return nil +} + +// Deactivate cleans up the modal +func (c *ConfirmModal) Deactivate() { + c.BaseModal.deactivate() +} + +// IssueID returns the associated issue ID +func (c *ConfirmModal) IssueID() string { + return c.issueID +} + +// Update handles input when the modal is active +func (c *ConfirmModal) Update(msg tea.Msg) (tea.Cmd, bool) { + if !c.IsActive() { + return nil, false + } + + switch msg := msg.(type) { + case tea.KeyPressMsg: + s := msg.String() + + // Check yes keys + if slices.Contains(c.yesKeys, s) { + c.Deactivate() + return func() tea.Msg { + return ModalCompletedMsg{ + ModalID: c.ID(), + Value: ConfirmResult{Confirmed: true}, + } + }, true + } + + // Check no/cancel keys + if slices.Contains(c.noKeys, s) { + c.Deactivate() + return func() tea.Msg { + return ModalCancelledMsg{ModalID: c.ID()} + }, true + } + } + + return nil, true +} + +// View renders the modal +func (c *ConfirmModal) View() string { + if c.width < 5 { + return "" + } + + boxWidth := max(min(50, c.width-4), 1) + + content := lipgloss.JoinVertical(lipgloss.Left, + style.LabelStyle.Render(c.message), + lipgloss.NewStyle().Foreground(style.FaintText). + Render("Press y to confirm, n or Esc to cancel"), + ) + + return style.ModalContainerStyle. + Width(boxWidth). + Render(content) +} + +// SetSize updates the modal dimensions +func (c *ConfirmModal) SetSize(width, height int) { + c.BaseModal.SetSize(width, height) + c.width = width + c.height = height +} diff --git a/pkg/tui/modal/focus.go b/pkg/tui/modal/focus.go new file mode 100644 index 0000000..bb35e1f --- /dev/null +++ b/pkg/tui/modal/focus.go @@ -0,0 +1,143 @@ +package modal + +// FocusArea represents a distinct focusable area in the UI +type FocusArea int + +const ( + FocusNone FocusArea = iota + FocusList + FocusDetail + FocusColumn1 + FocusColumn2 + FocusColumn3 + FocusColumn4 +) + +// FocusManager handles focus state across different UI areas. +// It provides a clean separation of focus concerns from modal state. +type FocusManager struct { + currentArea FocusArea + areas map[FocusArea]bool +} + +// NewFocusManager creates a new focus manager with all areas disabled by default +func NewFocusManager() *FocusManager { + return &FocusManager{ + currentArea: FocusNone, + areas: make(map[FocusArea]bool), + } +} + +// SetCurrent sets the currently focused area +func (f *FocusManager) SetCurrent(area FocusArea) { + f.currentArea = area +} + +// Current returns the currently focused area +func (f *FocusManager) Current() FocusArea { + return f.currentArea +} + +// IsFocused returns true if the given area is currently focused +func (f *FocusManager) IsFocused(area FocusArea) bool { + return f.currentArea == area +} + +// IsListFocused returns true if any list area is focused +func (f *FocusManager) IsListFocused() bool { + return f.currentArea == FocusList || + f.currentArea == FocusColumn1 || + f.currentArea == FocusColumn2 || + f.currentArea == FocusColumn3 || + f.currentArea == FocusColumn4 +} + +// IsDetailFocused returns true if the detail area is focused +func (f *FocusManager) IsDetailFocused() bool { + return f.currentArea == FocusDetail +} + +// EnableArea marks an area as available for focus +func (f *FocusManager) EnableArea(area FocusArea) { + f.areas[area] = true +} + +// DisableArea marks an area as unavailable for focus +func (f *FocusManager) DisableArea(area FocusArea) { + f.areas[area] = false + if f.currentArea == area { + f.currentArea = FocusNone + } +} + +// IsAreaEnabled returns true if the area is enabled +func (f *FocusManager) IsAreaEnabled(area FocusArea) bool { + return f.areas[area] +} + +// Next moves focus to the next enabled area +func (f *FocusManager) Next() { + areas := []FocusArea{FocusList, FocusDetail} + f.cycleFocus(areas) +} + +// Previous moves focus to the previous enabled area +func (f *FocusManager) Previous() { + areas := []FocusArea{FocusDetail, FocusList} + f.cycleFocus(areas) +} + +// NextColumn moves focus to the next column (for kanban) +func (f *FocusManager) NextColumn() { + areas := []FocusArea{FocusColumn1, FocusColumn2, FocusColumn3, FocusColumn4} + f.cycleFocus(areas) +} + +// PreviousColumn moves focus to the previous column (for kanban) +func (f *FocusManager) PreviousColumn() { + areas := []FocusArea{FocusColumn4, FocusColumn3, FocusColumn2, FocusColumn1} + f.cycleFocus(areas) +} + +// cycleFocus finds the next enabled area in the given order +func (f *FocusManager) cycleFocus(areas []FocusArea) { + // Find current position + startIdx := -1 + for i, area := range areas { + if area == f.currentArea { + startIdx = i + break + } + } + + // Search for next enabled area + for i := 1; i <= len(areas); i++ { + idx := (startIdx + i) % len(areas) + if idx < 0 { + idx += len(areas) + } + if f.areas[areas[idx]] { + f.currentArea = areas[idx] + return + } + } +} + +// ToggleDetail toggles between list and detail focus +func (f *FocusManager) ToggleDetail() { + if f.currentArea == FocusDetail { + f.currentArea = FocusList + } else { + f.currentArea = FocusDetail + } +} + +// Reset clears the current focus +func (f *FocusManager) Reset() { + f.currentArea = FocusNone +} + +// CanHandleKey returns true if the current focus area can handle keyboard input +func (f *FocusManager) CanHandleKey() bool { + return f.currentArea != FocusNone +} diff --git a/pkg/tui/modal/manager.go b/pkg/tui/modal/manager.go new file mode 100644 index 0000000..3250677 --- /dev/null +++ b/pkg/tui/modal/manager.go @@ -0,0 +1,256 @@ +package modal + +import ( + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +// Manager provides a clean API for managing modals in views. +// Views should embed this struct to get modal management capabilities. +type Manager struct { + stack *ModalStack + width int + height int +} + +// NewManager creates a new modal manager +func NewManager() *Manager { + return &Manager{ + stack: NewModalStack(), + } +} + +// SetSize updates the dimensions for modal rendering +func (m *Manager) SetSize(width, height int) { + m.width = width + m.height = height +} + +// ShowModal activates a pre-registered modal by ID +func (m *Manager) ShowModal(id string) tea.Cmd { + for _, modal := range m.stack.modals { + if modal.ID() == id { + return modal.Activate() + } + } + return nil +} + +// RegisterModal adds a modal to the manager's registry +func (m *Manager) RegisterModal(modal Modal) { + m.stack.Register(modal) +} + +// PushModal adds a modal to the stack and activates it +func (m *Manager) PushModal(modal Modal) tea.Cmd { + m.stack.Push(modal) + return modal.Activate() +} + +// PopModal removes the top modal from the stack +func (m *Manager) PopModal() Modal { + return m.stack.Pop() +} + +// CloseAll closes all active modals +func (m *Manager) CloseAll() { + m.stack.Clear() +} + +// IsModalActive returns true if any modal is currently active +func (m *Manager) IsModalActive() bool { + return m.stack.HasActiveModal() +} + +// ActiveModal returns the currently active modal +func (m *Manager) ActiveModal() Modal { + return m.stack.ActiveModal() +} + +// Update handles messages and routes them to the active modal +// Returns: (command, handled) - if handled is true, the view should stop processing this message +func (m *Manager) Update(msg tea.Msg) (tea.Cmd, bool) { + return m.stack.Update(msg) +} + +// View returns the rendered modal content (just the modal, not positioned) +func (m *Manager) View() string { + active := m.stack.ActiveModal() + if active == nil { + return "" + } + active.SetSize(m.width, m.height) + return active.View() +} + +// GetTextInputModal retrieves a TextInputModal by ID (if registered) +func (m *Manager) GetTextInputModal(id string) *TextInputModal { + for _, modal := range m.stack.modals { + if modal.ID() == id && modal.Type() == TypeTextInput { + if tim, ok := modal.(*TextInputModal); ok { + return tim + } + } + } + return nil +} + +// GetTextAreaModal retrieves a TextAreaModal by ID (if registered) +func (m *Manager) GetTextAreaModal(id string) *TextAreaModal { + for _, modal := range m.stack.modals { + if modal.ID() == id && modal.Type() == TypeTextArea { + if tam, ok := modal.(*TextAreaModal); ok { + return tam + } + } + } + return nil +} + +// GetConfirmModal retrieves a ConfirmModal by ID (if registered) +func (m *Manager) GetConfirmModal(id string) *ConfirmModal { + for _, modal := range m.stack.modals { + if modal.ID() == id && modal.Type() == TypeConfirm { + if cm, ok := modal.(*ConfirmModal); ok { + return cm + } + } + } + return nil +} + +// GetSelectModal retrieves a SelectModal by ID (if registered) +func (m *Manager) GetSelectModal(id string) *SelectModal { + for _, modal := range m.stack.modals { + if modal.ID() == id && modal.Type() == TypeSelect { + if sm, ok := modal.(*SelectModal); ok { + return sm + } + } + } + return nil +} + +// RenderWithMainView renders the main view with an overlaid modal using Canvas +// This allows the modal to appear on top without clearing the background +func (m *Manager) RenderWithMainView(mainView string) string { + if !m.IsModalActive() { + return mainView + } + + modalContent := m.View() + if modalContent == "" { + return mainView + } + + // Calculate centered position for the modal + modalWidth := lipgloss.Width(modalContent) + modalHeight := lipgloss.Height(modalContent) + + // Center the modal + x := max((m.width-modalWidth)/2, 0) + y := max((m.height-modalHeight)/2, 0) + + // Create layers: main view as base, modal on top with Z-index + mainLayer := lipgloss.NewLayer(mainView).X(0).Y(0).Z(0) + modalLayer := lipgloss.NewLayer(modalContent).X(x).Y(y).Z(1) + + // Create compositor with layers and render + compositor := lipgloss.NewCompositor(mainLayer, modalLayer) + return compositor.Render() +} + +// RegisterCommonModals registers the standard set of modals used across views. +// This helper reduces duplication between dashboard and kanban views. +func RegisterCommonModals(m *Manager) { + // Edit Title Modal + m.RegisterModal(NewTextInputModal(TextInputConfig{ + ID: ModalEditTitle, + Label: "Edit title (Enter to save, Esc to cancel):", + Placeholder: "Issue title...", + SaveKeys: []string{"enter"}, + CharLimit: 256, + InitialValue: "", + })) + + // Create Issue Modal + m.RegisterModal(NewTextInputModal(TextInputConfig{ + ID: ModalCreateIssue, + Label: "New issue (Enter to create, Esc to cancel):", + Placeholder: "New issue title...", + SaveKeys: []string{"enter"}, + CharLimit: 256, + })) + + // Edit Assignee Modal + m.RegisterModal(NewTextInputModal(TextInputConfig{ + ID: ModalEditAssignee, + Label: "Edit assignee (Enter to save, Esc to cancel):", + Placeholder: "Assignee name...", + SaveKeys: []string{"enter"}, + CharLimit: 64, + })) + + // Edit Description Modal + m.RegisterModal(NewTextAreaModal(TextAreaConfig{ + ID: ModalEditDescription, + Label: "Edit description (Ctrl+S to save, Esc to cancel):", + Placeholder: "Issue description...", + SaveKeys: []string{"ctrl+s"}, + InputHeight: 10, + })) + + // Add Comment Modal + m.RegisterModal(NewTextAreaModal(TextAreaConfig{ + ID: ModalAddComment, + Label: "Add comment (Ctrl+S to save, Esc to cancel):", + Placeholder: "Write your comment...", + SaveKeys: []string{"ctrl+s"}, + InputHeight: 8, + })) + + // Close Reason TextArea Modal + m.RegisterModal(NewTextAreaModal(TextAreaConfig{ + ID: ModalCloseReason, + Label: "Enter closing reason (Ctrl+S to save, Esc to cancel):", + Placeholder: "Enter closing reason...", + SaveKeys: []string{"ctrl+s"}, + InputHeight: 4, + })) + + // Delete Confirm Modal + m.RegisterModal(NewConfirmModal(ConfirmConfig{ + ID: ModalConfirmDelete, + Message: "Delete issue?", + YesKeys: []string{"y", "Y"}, + NoKeys: []string{"n", "N", "esc"}, + })) + + // Status Select Modal + m.RegisterModal(NewSelectModal(SelectConfig{ + ID: ModalSelectStatus, + Label: "Change status:", + Options: StatusOptions(), + })) + + // Close Reason Select Modal + m.RegisterModal(NewSelectModal(SelectConfig{ + ID: ModalSelectCloseReason, + Label: "Choose closing reason:", + Options: CloseReasonOptions(), + })) + + // Priority Select Modal + m.RegisterModal(NewSelectModal(SelectConfig{ + ID: ModalSelectPriority, + Label: "Change priority:", + Options: PriorityOptions(), + })) + + // Type Select Modal + m.RegisterModal(NewSelectModal(SelectConfig{ + ID: ModalSelectType, + Label: "Change type:", + Options: TypeOptions(), + })) +} diff --git a/pkg/tui/modal/modal.go b/pkg/tui/modal/modal.go new file mode 100644 index 0000000..0e3e3d6 --- /dev/null +++ b/pkg/tui/modal/modal.go @@ -0,0 +1,167 @@ +// Package modal provides a composable, interface-based modal system for TUI views. +// It enables separation of concerns between modal rendering, input handling, and focus management. +package modal + +import ( + tea "charm.land/bubbletea/v2" +) + +// Modal is the core interface that all modals must implement. +// It defines the contract for modal lifecycle, rendering, and input handling. +type Modal interface { + ID() string + Type() ModalType + IsActive() bool + Activate() tea.Cmd + Deactivate() + Update(msg tea.Msg) (tea.Cmd, bool) + View() string + SetSize(width, height int) +} + +// ModalType categorizes different modal behaviors +type ModalType int + +const ( + TypeTextInput ModalType = iota + TypeConfirm + TypeSelect + TypeTextArea + TypeCustom +) + +// ModalResult carries the output from a completed modal +type ModalResult struct { + ModalID string + Value interface{} // Type depends on the modal implementation + Err error +} + +// ModalCompletedMsg is sent when a modal completes successfully +type ModalCompletedMsg struct { + ModalID string + Value interface{} +} + +// ModalCancelledMsg is sent when a modal is cancelled +type ModalCancelledMsg struct { + ModalID string +} + +// Modal IDs used across the application +const ( + ModalEditTitle = "edit-title" + ModalCreateIssue = "create-issue" + ModalEditAssignee = "edit-assignee" + ModalEditDescription = "edit-description" + ModalAddComment = "add-comment" + ModalCloseReason = "close-reason-other" + ModalConfirmDelete = "confirm-delete" + ModalSelectStatus = "select-status" + ModalSelectCloseReason = "select-close-reason" + ModalSelectPriority = "select-priority" + ModalSelectType = "select-type" +) + +// ModalStack manages a stack of active modals with priority handling +type ModalStack struct { + modals []Modal +} + +// NewModalStack creates an empty modal stack +func NewModalStack() *ModalStack { + return &ModalStack{ + modals: make([]Modal, 0), + } +} + +// Push adds a modal to the top of the stack +func (s *ModalStack) Push(m Modal) { + s.modals = append(s.modals, m) +} + +// Pop removes and returns the top modal +func (s *ModalStack) Pop() Modal { + if len(s.modals) == 0 { + return nil + } + m := s.modals[len(s.modals)-1] + s.modals = s.modals[:len(s.modals)-1] + return m +} + +// Peek returns the top modal without removing it +func (s *ModalStack) Peek() Modal { + if len(s.modals) == 0 { + return nil + } + return s.modals[len(s.modals)-1] +} + +// ActiveModal returns the currently active modal (top of stack if active) +func (s *ModalStack) ActiveModal() Modal { + for i := len(s.modals) - 1; i >= 0; i-- { + if s.modals[i].IsActive() { + return s.modals[i] + } + } + return nil +} + +// HasActiveModal returns true if any modal in the stack is active +func (s *ModalStack) HasActiveModal() bool { + return s.ActiveModal() != nil +} + +// Clear deactivates and removes all modals +func (s *ModalStack) Clear() { + for _, m := range s.modals { + m.Deactivate() + } + s.modals = s.modals[:0] +} + +// Update routes messages to the active modal +// Returns handled=false for global messages like WindowSizeMsg so they pass through to the view +func (s *ModalStack) Update(msg tea.Msg) (tea.Cmd, bool) { + active := s.ActiveModal() + if active == nil { + return nil, false + } + + // Allow global quit key even when modal is active + if key, ok := msg.(tea.KeyPressMsg); ok { + if key.String() == "ctrl+c" { + return nil, false + } + } + + // WindowSizeMsg should pass through to the view even when modal is active + // This allows both the modal and the underlying view to resize + if _, ok := msg.(tea.WindowSizeMsg); ok { + cmd, _ := active.Update(msg) + return cmd, false + } + + return active.Update(msg) +} + +// View renders the active modal with proper framing +func (s *ModalStack) View(width, height int) string { + active := s.ActiveModal() + if active == nil { + return "" + } + active.SetSize(width, height) + return active.View() +} + +// Len returns the number of modals in the stack +func (s *ModalStack) Len() int { + return len(s.modals) +} + +// Register allows pre-registering modals for use +func (s *ModalStack) Register(modals ...Modal) { + s.modals = append(s.modals, modals...) +} diff --git a/pkg/tui/modal/select.go b/pkg/tui/modal/select.go new file mode 100644 index 0000000..7ad2e6a --- /dev/null +++ b/pkg/tui/modal/select.go @@ -0,0 +1,215 @@ +package modal + +import ( + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/LazyBachelor/LazyPM/internal/style" +) + +// SelectResult is returned when a select modal completes +type SelectResult struct { + SelectedKey string + SelectedValue string +} + +// SelectOption represents a selectable option +type SelectOption struct { + Key string // The key to press + Label string // Display label + Value string // The actual value to return +} + +// SelectModal is a modal for selecting from a list of options +// Suitable for: status selection, priority selection, type selection, etc. +type SelectModal struct { + BaseModal + label string + options []SelectOption + helpText string + cancelKey string + issueID string + width int + height int +} + +// SelectConfig configures a select modal +type SelectConfig struct { + ID string + Label string + Options []SelectOption + CancelKey string + IssueID string + Width int + Height int +} + +// NewSelectModal creates a new select modal +func NewSelectModal(cfg SelectConfig) *SelectModal { + if cfg.CancelKey == "" { + cfg.CancelKey = "esc" + } + + mod := &SelectModal{ + BaseModal: NewBaseModal(cfg.ID, TypeSelect), + label: cfg.Label, + options: cfg.Options, + cancelKey: cfg.CancelKey, + issueID: cfg.IssueID, + width: cfg.Width, + height: cfg.Height, + } + + // Build help text from options with styled keys in vertical layout + var parts []string + for _, opt := range cfg.Options { + styledKey := lipgloss.NewStyle().Foreground(style.Primary).Bold(true).Render(opt.Key) + styledLabel := lipgloss.NewStyle().Foreground(style.SecondaryText).Render(opt.Label) + option := lipgloss.NewStyle().Foreground(style.FaintText).Render(" ") + styledKey + lipgloss.NewStyle().Foreground(style.FaintText).Render(" → ") + styledLabel + parts = append(parts, option) + } + mod.helpText = lipgloss.JoinVertical(lipgloss.Left, parts...) + + if mod.width == 0 { + mod.width = 70 + } + if mod.height == 0 { + mod.height = 20 + } + + return mod +} + +// Activate prepares the modal +func (s *SelectModal) Activate() tea.Cmd { + s.BaseModal.activate() + return nil +} + +// Deactivate cleans up the modal +func (s *SelectModal) Deactivate() { + s.BaseModal.deactivate() +} + +// IssueID returns the associated issue ID +func (s *SelectModal) IssueID() string { + return s.issueID +} + +// Options returns the available options +func (s *SelectModal) Options() []SelectOption { + return s.options +} + +// Update handles input when the modal is active +func (s *SelectModal) Update(msg tea.Msg) (tea.Cmd, bool) { + if !s.IsActive() { + return nil, false + } + + switch msg := msg.(type) { + case tea.KeyPressMsg: + key := msg.String() + + // Check cancel key + if key == s.cancelKey { + s.Deactivate() + return func() tea.Msg { + return ModalCancelledMsg{ModalID: s.ID()} + }, true + } + + // Check option keys + for _, opt := range s.options { + if key == opt.Key { + s.Deactivate() + return func() tea.Msg { + return ModalCompletedMsg{ + ModalID: s.ID(), + Value: SelectResult{SelectedKey: opt.Key, SelectedValue: opt.Value}, + } + }, true + } + } + } + + // Consume all keys when modal is active to prevent leakage to underlying components + return nil, true +} + +// View renders the modal +func (s *SelectModal) View() string { + if s.width < 5 { + return "" + } + + boxWidth := max(min(70, s.width-4), 1) + + cancelText := lipgloss.NewStyle(). + Foreground(style.FaintText). + Render(s.cancelKey + " = cancel") + + content := lipgloss.JoinVertical(lipgloss.Left, + style.ValueStyle.Render(s.label), + "", + s.helpText, + "", + cancelText, + ) + + return style.ModalContainerStyle. + Width(boxWidth). + Render(content) +} + +// SetSize updates the modal dimensions +func (s *SelectModal) SetSize(width, height int) { + s.BaseModal.SetSize(width, height) + s.width = width + s.height = height +} + +// Predefined option sets for common use cases + +// StatusOptions returns options for status selection +func StatusOptions() []SelectOption { + return []SelectOption{ + {Key: "o", Label: "open", Value: "open"}, + {Key: "i", Label: "in_progress", Value: "in_progress"}, + {Key: "b", Label: "blocked", Value: "blocked"}, + {Key: "r", Label: "ready_to_sprint", Value: "ready_to_sprint"}, + {Key: "c", Label: "closing", Value: "closing"}, + } +} + +// PriorityOptions returns options for priority selection +func PriorityOptions() []SelectOption { + return []SelectOption{ + {Key: "0", Label: "irrelevant", Value: "0"}, + {Key: "1", Label: "low", Value: "1"}, + {Key: "2", Label: "normal", Value: "2"}, + {Key: "3", Label: "high", Value: "3"}, + {Key: "4", Label: "critical", Value: "4"}, + } +} + +// TypeOptions returns options for issue type selection +func TypeOptions() []SelectOption { + return []SelectOption{ + {Key: "b", Label: "bug", Value: "bug"}, + {Key: "f", Label: "feature", Value: "feature"}, + {Key: "t", Label: "task", Value: "task"}, + {Key: "e", Label: "epic", Value: "epic"}, + {Key: "c", Label: "chore", Value: "chore"}, + } +} + +// CloseReasonOptions returns options for close reason selection +func CloseReasonOptions() []SelectOption { + return []SelectOption{ + {Key: "c", Label: "Done", Value: "Done"}, + {Key: "d", Label: "Duplicate", Value: "Duplicate issue"}, + {Key: "w", Label: "Won't fix", Value: "Won't fix"}, + {Key: "o", Label: "Obsolete", Value: "Obsolete"}, + {Key: "h", Label: "Other", Value: "other"}, + } +} diff --git a/pkg/tui/modal/textarea.go b/pkg/tui/modal/textarea.go new file mode 100644 index 0000000..bb01d33 --- /dev/null +++ b/pkg/tui/modal/textarea.go @@ -0,0 +1,177 @@ +package modal + +import ( + "slices" + + "charm.land/bubbles/v2/textarea" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/LazyBachelor/LazyPM/internal/style" +) + +// TextAreaResult is returned when a text area modal completes +type TextAreaResult struct { + Value string +} + +// TextAreaModal is a modal for multi-line text input +// Suitable for: editing descriptions, adding comments, custom close reasons, etc. +type TextAreaModal struct { + BaseModal + input textarea.Model + label string + saveKeys []string + placeholder string + width int + height int + inputHeight int + issueID string +} + +// TextAreaConfig configures a text area modal +type TextAreaConfig struct { + ID string + Label string + Placeholder string + SaveKeys []string + InitialValue string + IssueID string + Width int + Height int + InputHeight int // Height of the textarea itself +} + +// NewTextAreaModal creates a new text area modal +func NewTextAreaModal(cfg TextAreaConfig) *TextAreaModal { + if cfg.SaveKeys == nil { + cfg.SaveKeys = []string{"ctrl+s"} + } + if cfg.InputHeight == 0 { + cfg.InputHeight = 8 + } + + ta := textarea.New() + ta.Placeholder = cfg.Placeholder + ta.SetValue(cfg.InitialValue) + + mod := &TextAreaModal{ + BaseModal: NewBaseModal(cfg.ID, TypeTextArea), + input: ta, + label: cfg.Label, + saveKeys: cfg.SaveKeys, + placeholder: cfg.Placeholder, + width: cfg.Width, + height: cfg.Height, + inputHeight: cfg.InputHeight, + issueID: cfg.IssueID, + } + + if mod.width == 0 { + mod.width = 60 + } + if mod.height == 0 { + mod.height = 20 + } + + return mod +} + +// Activate prepares the modal for input +func (t *TextAreaModal) Activate() tea.Cmd { + t.BaseModal.activate() + return t.input.Focus() +} + +// Deactivate cleans up the modal +func (t *TextAreaModal) Deactivate() { + t.BaseModal.deactivate() + t.input.Blur() +} + +// SetValue updates the textarea value +func (t *TextAreaModal) SetValue(value string) { + t.input.SetValue(value) +} + +// Value returns the current textarea value +func (t *TextAreaModal) Value() string { + return t.input.Value() +} + +// IssueID returns the associated issue ID +func (t *TextAreaModal) IssueID() string { + return t.issueID +} + +// Update handles input when the modal is active +func (t *TextAreaModal) Update(msg tea.Msg) (tea.Cmd, bool) { + if !t.IsActive() { + return nil, false + } + + switch msg := msg.(type) { + case tea.KeyPressMsg: + s := msg.String() + + // Check save keys + if slices.Contains(t.saveKeys, s) { + value := t.input.Value() + t.Deactivate() + return func() tea.Msg { + return ModalCompletedMsg{ + ModalID: t.ID(), + Value: TextAreaResult{Value: value}, + } + }, true + } + + // Cancel on escape + if s == "esc" { + t.Deactivate() + t.input.Blur() + t.input.Reset() + return func() tea.Msg { + return ModalCancelledMsg{ModalID: t.ID()} + }, true + } + } + + // Let the textarea handle the message + var cmd tea.Cmd + t.input, cmd = t.input.Update(msg) + // Always handle the message when modal is active to prevent keys leaking to list + return cmd, true +} + +// View renders the modal +func (t *TextAreaModal) View() string { + if t.width < 5 { + return "" + } + + boxWidth := max(min(60, t.width-4), 1) + + t.input.SetWidth(boxWidth - 2) + t.input.SetHeight(t.inputHeight) + + content := lipgloss.JoinVertical(lipgloss.Left, + style.LabelStyle.Render(t.label), + t.input.View(), + ) + + return style.ModalContainerStyle. + Width(boxWidth). + Render(content) +} + +// SetSize updates the modal dimensions +func (t *TextAreaModal) SetSize(width, height int) { + t.BaseModal.SetSize(width, height) + t.width = width + t.height = height +} + +// Reset clears the textarea +func (t *TextAreaModal) Reset() { + t.input.Reset() +} diff --git a/pkg/tui/modal/textinput.go b/pkg/tui/modal/textinput.go new file mode 100644 index 0000000..d5fc6d0 --- /dev/null +++ b/pkg/tui/modal/textinput.go @@ -0,0 +1,179 @@ +package modal + +import ( + "slices" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/LazyBachelor/LazyPM/internal/style" +) + +// TextInputResult is returned when a text input modal completes +type TextInputResult struct { + Value string +} + +// TextInputModal is a modal for single-line text input +// Suitable for: editing titles, editing assignees, creating issues +type TextInputModal struct { + BaseModal + input textinput.Model + label string + saveKeys []string + placeholder string + charLimit int + width int + height int + issueID string // Optional: for context +} + +// TextInputConfig configures a text input modal +type TextInputConfig struct { + ID string + Label string + Placeholder string + SaveKeys []string + CharLimit int + InitialValue string + IssueID string + Width int + Height int +} + +// NewTextInputModal creates a new text input modal +func NewTextInputModal(cfg TextInputConfig) *TextInputModal { + if cfg.SaveKeys == nil { + cfg.SaveKeys = []string{"enter"} + } + if cfg.CharLimit == 0 { + cfg.CharLimit = 256 + } + + ti := textinput.New() + ti.Placeholder = cfg.Placeholder + ti.CharLimit = cfg.CharLimit + ti.SetValue(cfg.InitialValue) + + mod := &TextInputModal{ + BaseModal: NewBaseModal(cfg.ID, TypeTextInput), + input: ti, + label: cfg.Label, + saveKeys: cfg.SaveKeys, + placeholder: cfg.Placeholder, + charLimit: cfg.CharLimit, + width: cfg.Width, + height: cfg.Height, + issueID: cfg.IssueID, + } + + if mod.width == 0 { + mod.width = 60 + } + if mod.height == 0 { + mod.height = 20 + } + + return mod +} + +// Activate prepares the modal for input +func (t *TextInputModal) Activate() tea.Cmd { + t.BaseModal.activate() + return t.input.Focus() +} + +// Deactivate cleans up the modal +func (t *TextInputModal) Deactivate() { + t.BaseModal.deactivate() + t.input.Blur() +} + +// SetValue updates the input value +func (t *TextInputModal) SetValue(value string) { + t.input.SetValue(value) +} + +// Value returns the current input value +func (t *TextInputModal) Value() string { + return t.input.Value() +} + +// IssueID returns the associated issue ID +func (t *TextInputModal) IssueID() string { + return t.issueID +} + +// Update handles input when the modal is active +func (t *TextInputModal) Update(msg tea.Msg) (tea.Cmd, bool) { + if !t.IsActive() { + return nil, false + } + + switch msg := msg.(type) { + case tea.KeyPressMsg: + s := msg.String() + + // Check save keys + if slices.Contains(t.saveKeys, s) { + value := t.input.Value() + t.Deactivate() + return func() tea.Msg { + return ModalCompletedMsg{ + ModalID: t.ID(), + Value: TextInputResult{Value: value}, + } + }, true + } + + // Cancel on escape + if s == "esc" { + t.Deactivate() + return func() tea.Msg { + return ModalCancelledMsg{ModalID: t.ID()} + }, true + } + } + + // Let the text input handle the message + var cmd tea.Cmd + t.input, cmd = t.input.Update(msg) + // Always handle the message when modal is active to prevent keys leaking to list + return cmd, true +} + +// View renders the modal +func (t *TextInputModal) View() string { + if t.width < 5 { + return "" + } + + boxWidth := min(60, t.width-4) + t.input.SetWidth(boxWidth - 2) + + content := lipgloss.JoinVertical(lipgloss.Left, + style.LabelStyle.Render(t.label), + t.input.View(), + ) + + return style.ModalContainerStyle. + Width(boxWidth). + Render(content) +} + +// SetSize updates the modal dimensions +func (t *TextInputModal) SetSize(width, height int) { + t.BaseModal.SetSize(width, height) + t.width = width + t.height = height +} + +// CursorEnd moves the cursor to the end of the input +func (t *TextInputModal) CursorEnd() { + t.input.CursorEnd() +} + +// Reset clears the input value +func (t *TextInputModal) Reset() { + t.input.Reset() +} diff --git a/pkg/tui/msgs/msgs.go b/pkg/tui/msgs/msgs.go index 40e1169..a241d98 100644 --- a/pkg/tui/msgs/msgs.go +++ b/pkg/tui/msgs/msgs.go @@ -10,35 +10,47 @@ import ( // Msg types used by both dashboard and kanban TUI views. type ( + SwitchToDashboardMsg struct{} + + SwitchToKanbanBoardMsg struct{} + + SelectIssueMsg struct{ IssueID string } + TitleUpdatedMsg struct { IssueID string Err error } + DescriptionUpdatedMsg struct { IssueID string Err error } + StatusUpdatedMsg struct { IssueID string Err error } + PriorityUpdatedMsg struct { IssueID string Err error } + TypeUpdatedMsg struct { IssueID string Err error } + AssigneeUpdatedMsg struct { IssueID string Err error } - SelectIssueMsg struct{ IssueID string } - CreatedMsg struct { + + CreatedMsg struct { Issue *models.Issue Err error } + DeletedMsg struct { IssueID string Err error @@ -50,11 +62,14 @@ type ( Err error } - // SwitchToDashboardMsg signals to switch to the main dashboard view. - SwitchToDashboardMsg struct{} + ModalCompletedMsg struct { + ModalID string + Result interface{} + } - // SwitchToKanbanBoardMsg signals to switch to the kanban board view. - SwitchToKanbanBoardMsg struct{} + ModalCancelledMsg struct { + ModalID string + } ) // UpdateIssueTitleCmd returns a command that updates an issue's title. diff --git a/pkg/tui/styles/styles.go b/pkg/tui/styles/styles.go deleted file mode 100644 index 4f2af0f..0000000 --- a/pkg/tui/styles/styles.go +++ /dev/null @@ -1,85 +0,0 @@ -package styles - -import ( - "charm.land/lipgloss/v2" - "charm.land/lipgloss/v2/compat" -) - -var ( - Primary = compat.AdaptiveColor{Light: lipgloss.Color("#5A56E0"), Dark: lipgloss.Color("#7571F9")} - Secondary = compat.AdaptiveColor{Light: lipgloss.Color("#02BA84"), Dark: lipgloss.Color("#02BF87")} - - Success = compat.AdaptiveColor{Light: lipgloss.Color("#02BA84"), Dark: lipgloss.Color("#02BF87")} - Warning = compat.AdaptiveColor{Light: lipgloss.Color("#F59E0B"), Dark: lipgloss.Color("#F59E0B")} - Error = compat.AdaptiveColor{Light: lipgloss.Color("#FE5F86"), Dark: lipgloss.Color("#FE5F86")} - - PrimaryText = compat.AdaptiveColor{Light: lipgloss.Color("#1A1A1A"), Dark: lipgloss.Color("#E0E0E0")} - SecondaryText = compat.AdaptiveColor{Light: lipgloss.Color("#666666"), Dark: lipgloss.Color("#999999")} - FaintText = compat.AdaptiveColor{Light: lipgloss.Color("#999999"), Dark: lipgloss.Color("#666666")} - - PrimaryBorder = compat.AdaptiveColor{Light: lipgloss.Color("#5A56E0"), Dark: lipgloss.Color("#7571F9")} - SecondaryBorder = compat.AdaptiveColor{Light: lipgloss.Color("#CCCCCC"), Dark: lipgloss.Color("#444444")} - - SelectedBackground = compat.AdaptiveColor{Light: lipgloss.Color("#E8E8E8"), Dark: lipgloss.Color("#333333")} -) - -const ( - ListViewRatio = 52 // Percentage of total width allocated to the list view - LabelWidth = 14 - MarginBottomSmall = 1 -) - -var DefaultBorder = lipgloss.ThickBorder() - -var ( - HeaderStyle = lipgloss.NewStyle().Foreground(Primary).Padding(0, 1).Bold(true) - HeaderTitleStyle = lipgloss.NewStyle().Foreground(Primary).Bold(true).Padding(0) -) - -var ContainerStyle = lipgloss.NewStyle(). - Border(DefaultBorder, true, false, false, false). - BorderForeground(SecondaryBorder). - Padding(1) - -var DetailsContainerStyle = lipgloss.NewStyle(). - Border(DefaultBorder, true, false, false, true). - BorderForeground(SecondaryBorder). - Padding(1) - -var ( - RowStyle = lipgloss.NewStyle().MarginBottom(MarginBottomSmall) - TitleStyle = lipgloss.NewStyle().Foreground(Primary).Bold(true) - LabelStyle = lipgloss.NewStyle().Foreground(SecondaryText) - ValueStyle = lipgloss.NewStyle().Foreground(PrimaryText) - IssueTypeStyle = lipgloss.NewStyle().Foreground(Primary).Bold(true) -) - -var ( - FilterStyle = lipgloss.NewStyle().Foreground(Primary).Bold(true).Padding(0, 1) - FilterInputStyle = lipgloss.NewStyle().Foreground(PrimaryText).Padding(0, 1) - FilterPromptStyle = lipgloss.NewStyle().Foreground(Primary).Bold(true) -) - -func StatusStyle(status string) lipgloss.Style { - style := lipgloss.NewStyle().Bold(true) - switch status { - case "open": - return style.Foreground(Secondary) - case "closed": - return style.Foreground(FaintText) - case "in_progress": - return style.Foreground(Warning) - case "blocked": - return style.Foreground(Error) - default: - return style.Foreground(SecondaryText) - } -} - -func HighlightKey(key string) string { - return lipgloss.NewStyle(). - Foreground(Primary). - Bold(true). - Padding(0, 1). - Render(key) -} diff --git a/pkg/tui/views/dashboard/keys.go b/pkg/tui/views/dashboard/keys.go index 367a3ea..8cce7cc 100644 --- a/pkg/tui/views/dashboard/keys.go +++ b/pkg/tui/views/dashboard/keys.go @@ -7,7 +7,7 @@ import ( "github.com/LazyBachelor/LazyPM/pkg/tui/msgs" ) -type DashboardKeyMap struct { +type KeyMap struct { components.CommonKeyMap SwitchToKanbanBoard key.Binding Quit key.Binding @@ -26,7 +26,7 @@ type DashboardKeyMap struct { DeleteIssue key.Binding } -var defaultDashboardKeyMap = DashboardKeyMap{ +var defaultDashboardKeyMap = KeyMap{ CommonKeyMap: components.DefaultCommonKeyMap(), SwitchToKanbanBoard: key.NewBinding( key.WithKeys("v"), @@ -69,73 +69,85 @@ var defaultDashboardKeyMap = DashboardKeyMap{ ), } -func (d *Model) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { +func (m *Model) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { var cmd tea.Cmd switch { - case key.Matches(msg, d.keyMap.Help): - d.helpBar.ToggleHelp() - d.logAction("tui toggled help") - case key.Matches(msg, d.keyMap.Quit): - d.logAction("tui quit requested") + case m.notInModalMsgWithKey(msg, m.keyMap.Help): + m.helpBar.ToggleHelp() + m.logAction("tui toggled help") + + case m.notInModalMsgWithKey(msg, m.keyMap.Quit): + m.logAction("tui quit requested") return tea.Quit - case !d.IsInModal() && key.Matches(msg, d.keyMap.SwitchToKanbanBoard): + + case m.notInModalMsgWithKey(msg, m.keyMap.SwitchToKanbanBoard): return func() tea.Msg { return msgs.SwitchToKanbanBoardMsg{} } - case d.IsFocusedOnDetail() && key.Matches(msg, d.keyMap.ScrollUp): - d.issueDetail.ScrollUp(1) - d.logAction("tui scrolled issue detail up") - case d.IsFocusedOnDetail() && key.Matches(msg, d.keyMap.ScrollDown): - d.issueDetail.ScrollDown(1) - d.logAction("tui scrolled issue detail down") - case !d.IsInModal() && !d.addingComment && key.Matches(msg, d.keyMap.EditTitle): - if selected := d.issueList.SelectedItem(); selected.ID != "" { - d.startEditTitle(selected) - cmd = d.titleInput.Focus() - d.logAction("tui started editing issue title") + + case m.notInModalMsgWithKey(msg, m.keyMap.ScrollUp): + m.issueDetail.ScrollUp(1) + m.logAction("tui scrolled issue detail up") + + case m.notInModalMsgWithKey(msg, m.keyMap.ScrollDown): + m.issueDetail.ScrollDown(1) + m.logAction("tui scrolled issue detail down") + + case m.notInModalMsgWithKey(msg, m.keyMap.EditTitle): + if selected := m.issueList.SelectedItem(); selected.ID != "" { + cmd = m.startEditTitle(selected) + m.logAction("tui started editing issue title") } - case !d.IsInModal() && !d.addingComment && key.Matches(msg, d.keyMap.EditDescription): - if selected := d.issueList.SelectedItem(); selected.ID != "" { - d.startEditDescription(selected) - cmd = d.descriptionInput.Focus() - d.logAction("tui started editing issue description") + + case m.notInModalMsgWithKey(msg, m.keyMap.EditDescription): + if selected := m.issueList.SelectedItem(); selected.ID != "" { + cmd = m.startEditDescription(selected) + m.logAction("tui started editing issue description") } - case !d.IsInModal() && !d.addingComment && key.Matches(msg, d.keyMap.ChangeStatus): - if selected := d.issueList.SelectedItem(); selected.ID != "" { - d.startChooseStatus(selected) - d.logAction("tui opened status picker") + + case m.notInModalMsgWithKey(msg, m.keyMap.ChangeStatus): + if selected := m.issueList.SelectedItem(); selected.ID != "" { + cmd = m.startChooseStatus(selected) + m.logAction("tui opened status picker") } - case !d.IsInModal() && !d.addingComment && key.Matches(msg, d.keyMap.ChangePriority): - if selected := d.issueList.SelectedItem(); selected.ID != "" { - d.startChoosePriority(selected) - d.logAction("tui opened priority picker") + + case m.notInModalMsgWithKey(msg, m.keyMap.ChangePriority): + if selected := m.issueList.SelectedItem(); selected.ID != "" { + cmd = m.startChoosePriority(selected) + m.logAction("tui opened priority picker") } - case !d.IsInModal() && !d.addingComment && key.Matches(msg, d.keyMap.ChangeType): - if selected := d.issueList.SelectedItem(); selected.ID != "" { - d.startChooseType(selected) - d.logAction("tui opened type picker") + + case m.notInModalMsgWithKey(msg, m.keyMap.ChangeType): + if selected := m.issueList.SelectedItem(); selected.ID != "" { + cmd = m.startChooseType(selected) + m.logAction("tui opened type picker") } - case !d.IsInModal() && !d.addingComment && key.Matches(msg, d.keyMap.ChangeAssignee): - if selected := d.issueList.SelectedItem(); selected.ID != "" { - d.startEditAssignee(selected) - cmd = d.assigneeInput.Focus() - d.logAction("tui started editing assignee") + + case m.notInModalMsgWithKey(msg, m.keyMap.ChangeAssignee): + if selected := m.issueList.SelectedItem(); selected.ID != "" { + cmd = m.startEditAssignee(selected) + m.logAction("tui started editing assignee") } - case !d.IsInModal() && !d.addingComment && key.Matches(msg, d.keyMap.AddComment): - if selected := d.issueList.SelectedItem(); selected.ID != "" { - d.startAddComment(selected) - cmd = d.commentInput.Focus() + + case m.notInModalMsgWithKey(msg, m.keyMap.AddComment): + if selected := m.issueList.SelectedItem(); selected.ID != "" { + cmd = m.startAddComment(selected) } - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.editingAssignee && !d.addingComment && key.Matches(msg, d.keyMap.AddIssue): - d.startCreateIssue() - cmd = d.createTitleInput.Focus() - d.logAction("tui started creating issue") - case !d.IsInModal() && !d.addingComment && key.Matches(msg, d.keyMap.DeleteIssue): - fl := d.issueList + + case m.notInModalMsgWithKey(msg, m.keyMap.AddIssue): + cmd = m.startCreateIssue() + m.logAction("tui started creating issue") + + case m.notInModalMsgWithKey(msg, m.keyMap.DeleteIssue): + fl := m.issueList if selected := fl.SelectedItem(); selected.ID != "" { - d.startConfirmDelete(selected.ID, fl.Index()) - d.logAction("tui opened delete confirmation") + cmd = m.startConfirmDelete(selected.ID, fl.Index()) + m.logAction("tui opened delete confirmation") } } return cmd } + +func (m *Model) notInModalMsgWithKey(msg tea.KeyPressMsg, keyBinding key.Binding) bool { + return !m.IsInModal() && key.Matches(msg, keyBinding) +} diff --git a/pkg/tui/views/dashboard/model.go b/pkg/tui/views/dashboard/model.go index a1bce80..4ac5f07 100644 --- a/pkg/tui/views/dashboard/model.go +++ b/pkg/tui/views/dashboard/model.go @@ -3,12 +3,11 @@ package dashboard import ( "context" - "charm.land/bubbles/v2/textarea" - "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "github.com/LazyBachelor/LazyPM/internal/app" "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/pkg/tui/components" + "github.com/LazyBachelor/LazyPM/pkg/tui/modal" ) // Use shared types from components for consistency. @@ -20,102 +19,80 @@ type ( ) type Model struct { - header Header - issueList IssueList - issueDetail IssueDetail - closedIssueList IssueList - helpBar components.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 + header Header + issueList IssueList + issueDetail IssueDetail + closedIssueList IssueList + helpBar components.HelpBar + keyMap KeyMap + app *app.App + width int + height int - editingDescription bool // true while editing a description - descriptionInput textarea.Model - editingDescIssueID string - creatingIssue bool // true while creating a new issue - createTitleInput textinput.Model + // Modal and Focus management + modalManager *modal.Manager + focusManager *modal.FocusManager - confirmingDelete bool // true while confirming a delete - deleteConfirmID string - deleteConfirmIndex int + // Current issue being operated on + currentIssueID string + deleteIndex int - choosingStatus bool - statusIssueID string - choosingPriority bool - priorityIssueID string - choosingType bool - typeIssueID string - editingAssignee bool - assigneeInput textinput.Model - assigneeIssueID string - addingComment bool - commentInput textarea.Model - commentIssueID string - choosingCloseReason bool - closeReasonIssueID string - closingOtherReason bool - closeReasonInput textarea.Model - feedbackChan chan models.ValidationFeedback - quitChan chan bool - currentFeedback models.ValidationFeedback - showComplete bool - submitChan chan<- struct{} + feedbackChan chan models.ValidationFeedback + quitChan chan bool + currentFeedback models.ValidationFeedback + showComplete bool + submitChan chan<- struct{} } func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, quitChan chan bool, submitChan chan<- struct{}) *Model { m := &Model{ - header: components.NewHeader("Project Manager Dashboard"), - keyMap: defaultDashboardKeyMap, - app: app, - width: 80, - height: 24, - focusedWindow: 0, - focusedPaneMain: 0, - focusedPaneClosed: 0, - feedbackChan: feedbackChan, - quitChan: quitChan, - submitChan: submitChan, + header: components.NewHeader("Project Manager Dashboard"), + keyMap: defaultDashboardKeyMap, + app: app, + width: 80, + height: 24, + feedbackChan: feedbackChan, + quitChan: quitChan, + submitChan: submitChan, + modalManager: modal.NewManager(), + focusManager: modal.NewFocusManager(), + deleteIndex: -1, } + // Setup lists allIssues, _ := app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) m.issueList = components.NewIssueListFromIssues(app, components.SortedIssues(allIssues), 0, 0) m.issueDetail = components.NewIssueDetail() m.helpBar = components.NewHelpBar(components.ViewIssues) - inputs := components.NewIssueInputs() - m.titleInput = inputs.Title - m.createTitleInput = inputs.CreateTitle - m.descriptionInput = inputs.Description - m.assigneeInput = inputs.Assignee + // Setup focus + m.focusManager.EnableArea(modal.FocusList) + m.focusManager.EnableArea(modal.FocusDetail) + m.focusManager.SetCurrent(modal.FocusList) - closeReasonTa := textarea.New() - closeReasonTa.Placeholder = "Enter closing reason..." - closeReasonTa.SetWidth(56) - closeReasonTa.SetHeight(4) - m.closeReasonInput = closeReasonTa - - commentTa := textarea.New() - commentTa.Placeholder = "Write your comment..." - commentTa.SetWidth(56) - commentTa.SetHeight(6) - m.commentInput = commentTa + // Register modals + m.registerModals() if selected := m.issueList.SelectedItem(); selected.ID != "" { m.setDetailIssueWithComments(selected.Issue) - } else if selected := m.closedIssueList.SelectedItem(); selected.ID != "" { - m.setDetailIssueWithComments(selected.Issue) } return m } +func (m *Model) Init() tea.Cmd { + if m.submitChan != nil { + m.submitChan <- struct{}{} + m.logAction("tui submitted validation") + } + return components.ListenForValidation(m.feedbackChan) +} + +// registerModals sets up all modals using the common registration helper +func (m *Model) registerModals() { + modal.RegisterCommonModals(m.modalManager) +} + // setDetailIssueWithComments sets the issue in the detail pane and loads its comments. func (m *Model) setDetailIssueWithComments(issue models.Issue) { m.issueDetail.SetIssue(issue) @@ -127,13 +104,6 @@ func (m *Model) setDetailIssueWithComments(issue models.Issue) { m.issueDetail.SetComments(comments) } -func (m *Model) startAddComment(selected ListIssue) { - m.addingComment = true - m.commentIssueID = selected.ID - m.commentInput.SetValue("") - m.commentInput.Reset() -} - func (m *Model) logAction(action string) { if m.app != nil { m.app.LogAction(models.EncodeActionEvent(models.ActionEvent{ @@ -144,7 +114,6 @@ func (m *Model) logAction(action string) { } // submitValidation sends a validation request to the submit channel. -// Call this after every successful user action that modifies issues. func (m *Model) submitValidation() { if m.submitChan != nil { select { @@ -155,80 +124,15 @@ func (m *Model) submitValidation() { } } -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) startEditAssignee(selected ListIssue) { - m.editingAssignee = true - m.assigneeIssueID = selected.ID - m.assigneeInput.SetValue(selected.Assignee) - m.assigneeInput.CursorEnd() -} - -func (m *Model) Init() tea.Cmd { - if m.submitChan != nil { - m.submitChan <- struct{}{} - m.logAction("tui submitted validation") - } - return components.ListenForValidation(m.feedbackChan) -} - -// IsInModal returns true when a modal (edit, create, delete confirm, choose status/priority/type) is active. +// IsInModal returns true when a modal is active func (m *Model) IsInModal() bool { - return m.editingTitle || m.creatingIssue || m.editingDescription || - m.choosingStatus || m.choosingPriority || m.confirmingDelete || - m.choosingType || m.editingAssignee || - m.choosingCloseReason || m.closingOtherReason + return m.modalManager.IsModalActive() } func (m *Model) IsFocusedOnList() bool { - if m.focusedWindow == 0 { - return m.focusedPaneMain == 0 - } - return m.focusedPaneClosed == 0 + return m.focusManager.IsListFocused() } func (m *Model) IsFocusedOnDetail() bool { - if m.focusedWindow == 0 { - return m.focusedPaneMain == 1 - } - return m.focusedPaneClosed == 1 + return m.focusManager.IsDetailFocused() } diff --git a/pkg/tui/views/dashboard/operations.go b/pkg/tui/views/dashboard/operations.go index 22eaa31..b27d26d 100644 --- a/pkg/tui/views/dashboard/operations.go +++ b/pkg/tui/views/dashboard/operations.go @@ -2,33 +2,18 @@ package dashboard import ( "context" - "os" - "os/user" + "strconv" "charm.land/bubbles/v2/list" tea "charm.land/bubbletea/v2" "github.com/LazyBachelor/LazyPM/internal/models" + "github.com/LazyBachelor/LazyPM/internal/utils/user" "github.com/LazyBachelor/LazyPM/pkg/tui/components" + "github.com/LazyBachelor/LazyPM/pkg/tui/modal" "github.com/LazyBachelor/LazyPM/pkg/tui/msgs" ) -func defaultCommentAuthor() string { - if u, err := user.Current(); err == nil && u.Username != "" { - return u.Username - } - if s := os.Getenv("USER"); s != "" { - return s - } - if s := os.Getenv("USERNAME"); s != "" { - return s - } - return "user" -} - 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. - */ allIssues, err := m.app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) if err != nil { return nil @@ -40,24 +25,228 @@ func (m *Model) refreshIssueListsAndSelectIssue(issueID string) tea.Cmd { break } } - return tea.Sequence(setItemsCmd, func() tea.Msg { return msgs.SelectIssueMsg{IssueID: issueID} }) } -// refreshAndSubmit refreshes the issue lists and submits validation. -// This is a wrapper that should be used after any successful user action. func (m *Model) refreshAndSubmit(issueID string) tea.Cmd { refreshCmd := m.refreshIssueListsAndSelectIssue(issueID) m.submitValidation() return refreshCmd } +func (m *Model) startEditTitle(selected ListIssue) tea.Cmd { + m.currentIssueID = selected.ID + titleModal := m.modalManager.GetTextInputModal(modal.ModalEditTitle) + if titleModal != nil { + titleModal.SetValue(selected.Issue.Title) + titleModal.CursorEnd() + return m.modalManager.ShowModal(modal.ModalEditTitle) + } + return nil +} + +func (m *Model) startEditDescription(selected ListIssue) tea.Cmd { + m.currentIssueID = selected.ID + descModal := m.modalManager.GetTextAreaModal(modal.ModalEditDescription) + if descModal != nil { + descModal.SetValue(selected.Issue.Description) + return m.modalManager.ShowModal(modal.ModalEditDescription) + } + return nil +} + +func (m *Model) startCreateIssue() tea.Cmd { + createModal := m.modalManager.GetTextInputModal(modal.ModalCreateIssue) + if createModal != nil { + createModal.Reset() + return m.modalManager.ShowModal(modal.ModalCreateIssue) + } + return nil +} + +func (m *Model) startConfirmDelete(issueID string, index int) tea.Cmd { + m.currentIssueID = issueID + m.deleteIndex = index + deleteModal := m.modalManager.GetConfirmModal(modal.ModalConfirmDelete) + if deleteModal != nil { + return m.modalManager.ShowModal(modal.ModalConfirmDelete) + } + return nil +} + +func (m *Model) startChooseStatus(selected ListIssue) tea.Cmd { + m.currentIssueID = selected.ID + return m.modalManager.ShowModal(modal.ModalSelectStatus) +} + +func (m *Model) startChoosePriority(selected ListIssue) tea.Cmd { + m.currentIssueID = selected.ID + return m.modalManager.ShowModal(modal.ModalSelectPriority) +} + +func (m *Model) startChooseType(selected ListIssue) tea.Cmd { + m.currentIssueID = selected.ID + return m.modalManager.ShowModal(modal.ModalSelectType) +} + +func (m *Model) startEditAssignee(selected ListIssue) tea.Cmd { + m.currentIssueID = selected.ID + assigneeModal := m.modalManager.GetTextInputModal(modal.ModalEditAssignee) + if assigneeModal != nil { + assigneeModal.SetValue(selected.Assignee) + assigneeModal.CursorEnd() + return m.modalManager.ShowModal(modal.ModalEditAssignee) + } + return nil +} + +func (m *Model) startAddComment(selected ListIssue) tea.Cmd { + m.currentIssueID = selected.ID + commentModal := m.modalManager.GetTextAreaModal(modal.ModalAddComment) + if commentModal != nil { + commentModal.Reset() + return m.modalManager.ShowModal(modal.ModalAddComment) + } + return nil +} + +// handleModalCompleted handles all modal completion messages +func (m *Model) handleModalCompleted(msg modal.ModalCompletedMsg) tea.Cmd { + switch msg.ModalID { + case modal.ModalEditTitle: + if r, ok := msg.Value.(modal.TextInputResult); ok { + m.logAction("tui submitted issue title edit") + cmd := msgs.UpdateIssueTitleCmd(m.app, m.currentIssueID, r.Value) + return func() tea.Msg { return cmd() } + } + case modal.ModalCreateIssue: + if r, ok := msg.Value.(modal.TextInputResult); ok && r.Value != "" { + m.logAction("tui submitted new issue") + cmd := msgs.CreateIssueCmd(m.app, r.Value) + return func() tea.Msg { return cmd() } + } + case modal.ModalEditAssignee: + if r, ok := msg.Value.(modal.TextInputResult); ok { + m.logAction("tui submitted assignee edit") + cmd := msgs.UpdateIssueAssigneeCmd(m.app, m.currentIssueID, r.Value) + return func() tea.Msg { return cmd() } + } + case modal.ModalEditDescription: + if r, ok := msg.Value.(modal.TextAreaResult); ok { + m.logAction("tui submitted issue description edit") + cmd := msgs.UpdateIssueDescriptionCmd(m.app, m.currentIssueID, r.Value) + return func() tea.Msg { return cmd() } + } + case modal.ModalAddComment: + if r, ok := msg.Value.(modal.TextAreaResult); ok && r.Value != "" { + cmd := msgs.AddIssueCommentCmd(m.app, m.currentIssueID, user.GetOsUsername(), r.Value) + return func() tea.Msg { return cmd() } + } + case modal.ModalCloseReason: + if r, ok := msg.Value.(modal.TextAreaResult); ok && r.Value != "" { + m.logAction("tui submitted custom close reason") + cmd := msgs.CloseIssueCmd(m.app, m.currentIssueID, r.Value) + return func() tea.Msg { return cmd() } + } + case modal.ModalConfirmDelete: + if r, ok := msg.Value.(modal.ConfirmResult); ok && r.Confirmed { + m.logAction("tui confirmed issue deletion") + idx := m.deleteIndex + issueID := m.currentIssueID + m.deleteIndex = -1 + m.currentIssueID = "" + cmd := msgs.DeleteIssueCmd(m.app, issueID, idx) + return func() tea.Msg { return cmd() } + } + case modal.ModalSelectStatus: + if r, ok := msg.Value.(modal.SelectResult); ok { + m.logAction("tui selected issue status") + if r.SelectedValue == "closing" { + return m.modalManager.ShowModal(modal.ModalSelectCloseReason) + } + cmd := msgs.UpdateIssueStatusCmd(m.app, m.currentIssueID, r.SelectedValue) + return func() tea.Msg { return cmd() } + } + case modal.ModalSelectCloseReason: + if r, ok := msg.Value.(modal.SelectResult); ok { + if r.SelectedValue == "other" { + return m.modalManager.ShowModal(modal.ModalCloseReason) + } + cmd := msgs.CloseIssueCmd(m.app, m.currentIssueID, r.SelectedValue) + return func() tea.Msg { return cmd() } + } + case modal.ModalSelectPriority: + if r, ok := msg.Value.(modal.SelectResult); ok { + m.logAction("tui selected issue priority") + priority, _ := strconv.Atoi(r.SelectedValue) + cmd := msgs.UpdateIssuePriorityCmd(m.app, m.currentIssueID, priority) + return func() tea.Msg { return cmd() } + } + case modal.ModalSelectType: + if r, ok := msg.Value.(modal.SelectResult); ok { + m.logAction("tui selected issue type") + issueType := models.IssueType(r.SelectedValue) + cmd := msgs.UpdateIssueTypeCmd(m.app, m.currentIssueID, issueType) + return func() tea.Msg { return cmd() } + } + } + return nil +} + +// handleModalCancelled handles all modal cancellation messages +func (m *Model) handleModalCancelled(msg modal.ModalCancelledMsg) { + switch msg.ModalID { + case modal.ModalEditTitle: + m.currentIssueID = "" + m.logAction("tui canceled issue title edit") + case modal.ModalCreateIssue: + m.logAction("tui canceled issue creation") + case modal.ModalEditAssignee: + m.currentIssueID = "" + m.logAction("tui canceled assignee edit") + case modal.ModalEditDescription: + m.currentIssueID = "" + m.logAction("tui canceled issue description edit") + case modal.ModalAddComment: + m.currentIssueID = "" + case modal.ModalCloseReason: + m.currentIssueID = "" + m.logAction("tui canceled close reason") + case modal.ModalConfirmDelete: + m.deleteIndex = -1 + m.currentIssueID = "" + m.logAction("tui canceled issue deletion") + case modal.ModalSelectStatus: + m.currentIssueID = "" + m.logAction("tui canceled status picker") + case modal.ModalSelectCloseReason: + m.currentIssueID = "" + m.logAction("tui canceled close reason") + case modal.ModalSelectPriority: + m.currentIssueID = "" + m.logAction("tui canceled priority picker") + case modal.ModalSelectType: + m.currentIssueID = "" + m.logAction("tui canceled type picker") + } +} + func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if cmd, handled := m.modalManager.Update(msg); handled { + return m, cmd + } + switch msg := msg.(type) { + case modal.ModalCompletedMsg: + return m, m.handleModalCompleted(msg) + + case modal.ModalCancelledMsg: + m.handleModalCancelled(msg) + return m, nil + case msgs.TitleUpdatedMsg: - m.editingTitle = false - m.editingIssueID = "" - m.titleInput.Blur() + m.modalManager.GetTextInputModal(modal.ModalEditTitle).Reset() + m.currentIssueID = "" if msg.Err != nil { m.logAction("tui failed to update issue title") return m, nil @@ -66,9 +255,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.refreshAndSubmit(msg.IssueID) case msgs.DescriptionUpdatedMsg: - m.editingDescription = false - m.editingDescIssueID = "" - m.descriptionInput.Blur() + m.modalManager.GetTextAreaModal(modal.ModalEditDescription).Reset() + m.currentIssueID = "" if msg.Err != nil { m.logAction("tui failed to update issue description") return m, nil @@ -77,8 +265,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.refreshAndSubmit(msg.IssueID) case msgs.StatusUpdatedMsg: - m.choosingStatus = false - m.statusIssueID = "" + m.currentIssueID = "" if msg.Err != nil { m.logAction("tui failed to update issue status") return m, nil @@ -87,8 +274,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.refreshAndSubmit(msg.IssueID) case msgs.PriorityUpdatedMsg: - m.choosingPriority = false - m.priorityIssueID = "" + m.currentIssueID = "" if msg.Err != nil { m.logAction("tui failed to update issue priority") return m, nil @@ -97,8 +283,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.refreshAndSubmit(msg.IssueID) case msgs.TypeUpdatedMsg: - m.choosingType = false - m.typeIssueID = "" + m.currentIssueID = "" if msg.Err != nil { m.logAction("tui failed to update issue type") return m, nil @@ -107,9 +292,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.refreshAndSubmit(msg.IssueID) case msgs.AssigneeUpdatedMsg: - m.editingAssignee = false - m.assigneeIssueID = "" - m.assigneeInput.Blur() + m.modalManager.GetTextInputModal(modal.ModalEditAssignee).Reset() + m.currentIssueID = "" if msg.Err != nil { m.logAction("tui failed to update issue assignee") return m, nil @@ -123,9 +307,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case msgs.CreatedMsg: - m.creatingIssue = false - m.createTitleInput.Blur() - m.createTitleInput.Reset() + m.modalManager.GetTextInputModal(modal.ModalCreateIssue).Reset() if msg.Err != nil || msg.Issue == nil { m.logAction("tui failed to create issue") return m, nil @@ -136,11 +318,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } setItemsCmd := m.issueList.SetIssues(components.SortedIssues(allIssues)) - // 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 allIssues { - // 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 @@ -152,19 +332,19 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.logAction("tui created issue") m.submitValidation() return m, tea.Sequence(setItemsCmd, func() tea.Msg { return msgs.SelectIssueMsg{IssueID: selectedIssue.ID} }) + case msgs.IssueCommentAddedMsg: - m.addingComment = false - m.commentIssueID = "" - m.commentInput.Blur() - m.commentInput.Reset() + m.modalManager.GetTextAreaModal(modal.ModalAddComment).Reset() + m.currentIssueID = "" if msg.Err != nil { return m, nil } m.submitValidation() return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) + case msgs.DeletedMsg: - m.confirmingDelete = false - m.deleteConfirmID = "" + m.deleteIndex = -1 + m.currentIssueID = "" if msg.Err != nil { m.logAction("tui failed to delete issue") return m, nil @@ -174,297 +354,11 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) case tea.KeyPressMsg: - if m.confirmingDelete { - switch msg.String() { - case "y", "Y": - m.logAction("tui confirmed issue deletion") - issueID := m.deleteConfirmID - idx := m.deleteConfirmIndex - m.confirmingDelete = false - m.deleteConfirmID = "" - return m, msgs.DeleteIssueCmd(m.app, issueID, idx) - case "n", "N", "esc": - m.logAction("tui canceled issue deletion") - m.confirmingDelete = false - m.deleteConfirmID = "" - return m, nil - } - } - - if m.choosingStatus { - switch msg.String() { - case "o": - m.logAction("tui selected issue status open") - issueID := m.statusIssueID - m.choosingStatus = false - m.statusIssueID = "" - return m, msgs.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusOpen)) - case "i": - m.logAction("tui selected issue status in_progress") - issueID := m.statusIssueID - m.choosingStatus = false - m.statusIssueID = "" - return m, msgs.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusInProgress)) - case "b": - m.logAction("tui selected issue status blocked") - issueID := m.statusIssueID - m.choosingStatus = false - m.statusIssueID = "" - return m, msgs.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusBlocked)) - case "r": - m.logAction("tui selected issue status ready_to_sprint") - issueID := m.statusIssueID - m.choosingStatus = false - m.statusIssueID = "" - return m, msgs.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusReadyToSprint)) - case "c": - m.logAction("tui selected issue status closing") - issueID := m.statusIssueID - m.choosingStatus = false - m.statusIssueID = "" - m.choosingCloseReason = true - m.closeReasonIssueID = issueID - return m, nil - case "esc": - m.logAction("tui canceled status picker") - m.choosingStatus = false - m.statusIssueID = "" - return m, nil - } - } - - if m.choosingCloseReason { - var reason string - switch msg.String() { - case "d": - m.logAction("tui selected close reason done") - reason = "Done" - case "u": - m.logAction("tui selected close reason duplicate issue") - reason = "Duplicate issue" - case "w": - m.logAction("tui selected close reason won't fix") - reason = "Won't fix" - case "o": - m.logAction("tui selected close reason obsolete") - reason = "Obsolete" - case "h": - m.logAction("tui selected close reason other") - m.choosingCloseReason = false - m.closingOtherReason = true - m.closeReasonInput.SetValue("") - m.closeReasonInput.Focus() - return m, nil - case "esc": - m.logAction("tui canceled close reason picker") - m.choosingCloseReason = false - m.closeReasonIssueID = "" - return m, nil - } - - if reason != "" { - issueID := m.closeReasonIssueID - m.choosingCloseReason = false - m.closeReasonIssueID = "" - return m, msgs.CloseIssueCmd(m.app, issueID, reason) - } - } - - if m.closingOtherReason { - switch msg.String() { - case "enter", "ctrl+s": - reason := m.closeReasonInput.Value() - if reason != "" { - m.logAction("tui submitted custom close reason") - issueID := m.closeReasonIssueID - m.closingOtherReason = false - m.closeReasonIssueID = "" - m.closeReasonInput.Blur() - return m, msgs.CloseIssueCmd(m.app, issueID, reason) - } - case "esc": - m.logAction("tui canceled custom close reason") - m.closingOtherReason = false - m.closeReasonIssueID = "" - m.closeReasonInput.Blur() - return m, nil - } - var cmd tea.Cmd - m.closeReasonInput, cmd = m.closeReasonInput.Update(msg) - return m, cmd - } - - if m.choosingPriority { - switch msg.String() { - case "0", "1", "2", "3", "4": - m.logAction("tui selected issue priority") - issueID := m.priorityIssueID - priority := int(msg.String()[0] - '0') - m.choosingPriority = false - m.priorityIssueID = "" - return m, msgs.UpdateIssuePriorityCmd(m.app, issueID, priority) - case "esc": - m.logAction("tui canceled priority picker") - m.choosingPriority = false - m.priorityIssueID = "" - return m, nil - default: - return m, nil - } - } - - if m.choosingType { - switch msg.String() { - case "b": - m.logAction("tui selected issue type bug") - issueID := m.typeIssueID - m.choosingType = false - m.typeIssueID = "" - return m, msgs.UpdateIssueTypeCmd(m.app, issueID, models.TypeBug) - case "f": - m.logAction("tui selected issue type feature") - issueID := m.typeIssueID - m.choosingType = false - m.typeIssueID = "" - return m, msgs.UpdateIssueTypeCmd(m.app, issueID, models.TypeFeature) - case "t": - m.logAction("tui selected issue type task") - issueID := m.typeIssueID - m.choosingType = false - m.typeIssueID = "" - return m, msgs.UpdateIssueTypeCmd(m.app, issueID, models.TypeTask) - case "e": - m.logAction("tui selected issue type epic") - issueID := m.typeIssueID - m.choosingType = false - m.typeIssueID = "" - return m, msgs.UpdateIssueTypeCmd(m.app, issueID, models.TypeEpic) - case "c": - m.logAction("tui selected issue type chore") - issueID := m.typeIssueID - m.choosingType = false - m.typeIssueID = "" - return m, msgs.UpdateIssueTypeCmd(m.app, issueID, models.TypeChore) - case "esc": - m.logAction("tui canceled type picker") - 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 != "" { - m.logAction("tui submitted new issue") - return m, msgs.CreateIssueCmd(m.app, title) - } - } - if msg.String() == "esc" { - m.logAction("tui canceled issue creation") - 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.editingAssignee { - if msg.String() == "enter" { - assignee := m.assigneeInput.Value() - m.logAction("tui submitted assignee edit") - return m, msgs.UpdateIssueAssigneeCmd(m.app, m.assigneeIssueID, assignee) - } - if msg.String() == "esc" { - m.logAction("tui canceled assignee edit") - m.editingAssignee = false - m.assigneeIssueID = "" - m.assigneeInput.Blur() - return m, nil - } - var cmd tea.Cmd - m.assigneeInput, cmd = m.assigneeInput.Update(msg) - return m, cmd - } - - if m.editingTitle { - if msg.String() == "enter" { - newTitle := m.titleInput.Value() - if newTitle != "" { - m.logAction("tui submitted issue title edit") - return m, msgs.UpdateIssueTitleCmd(m.app, m.editingIssueID, newTitle) - } - } - if msg.String() == "esc" { - m.logAction("tui canceled issue title edit") - 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.addingComment { - if msg.String() == "ctrl+s" || msg.String() == "enter" { - text := m.commentInput.Value() - if text != "" { - issueID := m.commentIssueID - m.addingComment = false - m.commentIssueID = "" - m.commentInput.Blur() - m.commentInput.Reset() - return m, msgs.AddIssueCommentCmd(m.app, issueID, defaultCommentAuthor(), text) - } - } - if msg.String() == "esc" { - m.addingComment = false - m.commentIssueID = "" - m.commentInput.Blur() - m.commentInput.Reset() - return m, nil - } - var cmd tea.Cmd - m.commentInput, cmd = m.commentInput.Update(msg) - return m, cmd - } - - if m.editingDescription { - if msg.String() == "ctrl+s" { - m.logAction("tui submitted issue description edit") - issueID := m.editingDescIssueID - newDesc := m.descriptionInput.Value() - m.editingDescription = false - m.editingDescIssueID = "" - m.descriptionInput.Blur() - return m, msgs.UpdateIssueDescriptionCmd(m.app, issueID, newDesc) - } - if msg.String() == "esc" { - m.logAction("tui canceled issue description edit") - 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 - } - if m.issueList.FilterState() == list.Filtering { cmd, _ := m.issueList.Update(msg) return m, cmd } - // On main dashboard, ESC does nothing; only q quits; like in lazybeads. if msg.String() == "esc" { return m, nil } @@ -473,6 +367,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if cmd != nil { return m, cmd } + case components.ValidationFeedbackMsg: m.currentFeedback = msg.Feedback if msg.Feedback.Success { @@ -484,10 +379,11 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height + m.modalManager.SetSize(msg.Width, msg.Height) return m, nil } - if m.focusedWindow == 0 { + if m.focusManager.IsFocused(modal.FocusList) { cmd, changed := m.issueList.Update(msg) if changed { if selected := m.issueList.SelectedItem(); selected.ID != "" { diff --git a/pkg/tui/views/dashboard/view.go b/pkg/tui/views/dashboard/view.go index 539b91e..8c4e15e 100644 --- a/pkg/tui/views/dashboard/view.go +++ b/pkg/tui/views/dashboard/view.go @@ -4,16 +4,17 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/LazyBachelor/LazyPM/pkg/tui/components" - "github.com/LazyBachelor/LazyPM/pkg/tui/styles" + "github.com/LazyBachelor/LazyPM/pkg/tui/modal" + "github.com/LazyBachelor/LazyPM/internal/style" ) func (m *Model) View() tea.View { if m.width == 0 || m.height == 0 { - // if there is no space just print a loading message return tea.NewView("Loading...") } m.helpBar.SetWidth(m.width) + m.modalManager.SetSize(m.width, m.height) header := m.header.View(m.width) headerHeight := m.header.Height() @@ -21,202 +22,27 @@ func (m *Model) View() tea.View { footer := components.RenderFooter(m.width, &m.helpBar, m.currentFeedback) 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 - halfHeight := max(availableForLists / 2, 1) + halfHeight := max(availableForLists/2, 1) totalContentWidth := m.width - 1 - listWidth := totalContentWidth * styles.ListViewRatio / 100 + listWidth := totalContentWidth * style.ListViewRatio / 100 detailWidth := totalContentWidth - listWidth m.issueList.SetSize(listWidth, halfHeight) - //m.closedIssueList.SetSize(listWidth, halfHeight) m.issueDetail.SetSize(detailWidth, contentHeight) - // Only highlight the focused list; unfocused list should not show selection highlight. - m.issueList.SetHighlightSelected(m.focusedWindow == 0 && m.focusedPaneMain == 0) - m.closedIssueList.SetHighlightSelected(m.focusedWindow == 1 && m.focusedPaneClosed == 0) + m.issueList.SetHighlightSelected(m.focusManager.IsFocused(modal.FocusList)) 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, - ) + leftColumn := lipgloss.JoinVertical(lipgloss.Left, listView) content := lipgloss.JoinHorizontal(lipgloss.Left, leftColumn, detailView) mainView := lipgloss.JoinVertical(lipgloss.Left, header, content, footer) - if m.editingAssignee { - editBoxWidth := min(60, m.width-4) - m.assigneeInput.SetWidth(editBoxWidth - 2) - editContent := lipgloss.JoinVertical(lipgloss.Left, - styles.LabelStyle.Render("Edit assignee (Enter to save, Esc to cancel):"), - m.assigneeInput.View(), - ) - editBox := styles.ContainerStyle. - Width(editBoxWidth). - BorderForeground(styles.PrimaryBorder). - Render(editContent) - return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, editBox)) - } - - if m.editingTitle { - editBoxWidth := min(60, m.width-4) - m.titleInput.SetWidth(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 tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, editBox)) - } - - if m.addingComment { - editBoxWidth := min(60, m.width-4) - m.commentInput.SetWidth(editBoxWidth - 2) - m.commentInput.SetHeight(8) - editContent := lipgloss.JoinVertical(lipgloss.Left, - styles.LabelStyle.Render("Add comment for "+m.commentIssueID+" (Ctrl+S or Enter to save, Esc to cancel):"), - m.commentInput.View(), - ) - editBox := styles.ContainerStyle. - Width(editBoxWidth). - BorderForeground(styles.PrimaryBorder). - Render(editContent) - return tea.NewView(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 tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, editBox)) - } - - if m.creatingIssue { - createBoxWidth := min(60, m.width-4) - m.createTitleInput.SetWidth(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 tea.NewView(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 tea.NewView(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 r = ready_to_sprint c = closing (choose reason)"), - 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 tea.NewView(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 tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, priorityBox)) - } - - if m.choosingCloseReason { - reasonContent := lipgloss.JoinVertical(lipgloss.Left, - styles.LabelStyle.Render("Choose closing reason for "+m.closeReasonIssueID+":"), - lipgloss.NewStyle().Foreground(styles.FaintText).Render("d = Done u = Duplicate issue w = Won't fix o = Obsolete h = Other"), - lipgloss.NewStyle().Foreground(styles.FaintText).Render("Esc = cancel"), - ) - reasonBoxWidth := min(70, m.width-4) - reasonBox := styles.ContainerStyle. - Width(reasonBoxWidth). - BorderForeground(styles.PrimaryBorder). - Render(reasonContent) - return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, reasonBox)) - } - - if m.closingOtherReason { - editBoxWidth := min(60, m.width-4) - m.closeReasonInput.SetWidth(editBoxWidth - 2) - m.closeReasonInput.SetHeight(4) - editContent := lipgloss.JoinVertical(lipgloss.Left, - styles.LabelStyle.Render("Enter closing reason for "+m.closeReasonIssueID+" (Enter or Ctrl+S to save, Esc to cancel):"), - m.closeReasonInput.View(), - ) - editBox := styles.ContainerStyle. - Width(editBoxWidth). - BorderForeground(styles.PrimaryBorder). - Render(editContent) - return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, editBox)) - } - - 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 tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, typeBox)) - } - - return tea.NewView(mainView) - + return tea.NewView(m.modalManager.RenderWithMainView(mainView)) } diff --git a/pkg/tui/views/kanban/keys.go b/pkg/tui/views/kanban/keys.go index f146489..85f8be8 100644 --- a/pkg/tui/views/kanban/keys.go +++ b/pkg/tui/views/kanban/keys.go @@ -7,7 +7,7 @@ import ( "github.com/LazyBachelor/LazyPM/pkg/tui/msgs" ) -type KanbanKeyMap struct { +type KeyMap struct { components.CommonKeyMap SwitchToDashboard key.Binding MoveColumnLeft key.Binding @@ -15,17 +15,14 @@ type KanbanKeyMap struct { MoveIssueLeft key.Binding MoveIssueRight key.Binding SubmitValidation key.Binding + AddComment key.Binding } -var defaultKanbanKeyMap = KanbanKeyMap{ +var defaultKanbanKeyMap = KeyMap{ CommonKeyMap: components.DefaultCommonKeyMap(), - SubmitValidation: key.NewBinding( - key.WithKeys("S"), - key.WithHelp("S", "submit validation"), - ), SwitchToDashboard: key.NewBinding( key.WithKeys("v"), - key.WithHelp("v", "dashboard 1"), + key.WithHelp("v", "dashboard"), ), MoveColumnLeft: key.NewBinding( key.WithKeys("h"), @@ -43,81 +40,87 @@ var defaultKanbanKeyMap = KanbanKeyMap{ key.WithKeys("right", "]"), key.WithHelp("→/]", "move issue right"), ), + AddComment: key.NewBinding( + key.WithKeys("c"), + key.WithHelp("c", "add comment"), + ), } -func (d *Model) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { +func (m *Model) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { var cmd tea.Cmd switch { - case key.Matches(msg, d.keyMap.SubmitValidation): - if d.submitChan != nil { - select { - case d.submitChan <- struct{}{}: - default: - } - } - case key.Matches(msg, d.keyMap.Help): - d.helpBar.ToggleHelp() - case key.Matches(msg, d.keyMap.Quit): + case m.notInModalMsgWithKey(msg, m.keyMap.Help): + m.helpBar.ToggleHelp() + + case m.notInModalMsgWithKey(msg, m.keyMap.Quit): return tea.Quit - case !d.IsInModal() && msg.String() == " ": - return func() tea.Msg { return nil } // consume space - case !d.IsInModal() && key.Matches(msg, d.keyMap.SwitchToDashboard): + + case m.notInModalMsgWithKey(msg, m.keyMap.SwitchToDashboard): return func() tea.Msg { return msgs.SwitchToDashboardMsg{} } - case !d.IsInModal() && key.Matches(msg, d.keyMap.MoveColumnLeft): - if d.focusedColumn > 0 { - d.focusedColumn-- - d.updateDetailFromSelection() + + case m.notInModalMsgWithKey(msg, m.keyMap.MoveColumnLeft): + m.focusManager.PreviousColumn() + m.updateDetailFromSelection() + + case m.notInModalMsgWithKey(msg, m.keyMap.MoveColumnRight): + m.focusManager.NextColumn() + m.updateDetailFromSelection() + + case m.notInModalMsgWithKey(msg, m.keyMap.MoveIssueRight): + cmd = m.moveIssue(+1) + + case m.notInModalMsgWithKey(msg, m.keyMap.MoveIssueLeft): + cmd = m.moveIssue(-1) + + case m.IsFocusedOnDetail() && key.Matches(msg, m.keyMap.ScrollUp): + m.issueDetail.ScrollUp(1) + + case m.notInModalMsgWithKey(msg, m.keyMap.ScrollDown): + m.issueDetail.ScrollDown(1) + + case m.notInModalMsgWithKey(msg, m.keyMap.EditTitle): + if selected := m.FocusedIssueList().SelectedItem(); selected.ID != "" { + cmd = m.startEditTitle(selected) } - case !d.IsInModal() && key.Matches(msg, d.keyMap.MoveColumnRight): - if d.focusedColumn < 3 { - d.focusedColumn++ - d.updateDetailFromSelection() + case m.notInModalMsgWithKey(msg, m.keyMap.EditDescription): + if selected := m.FocusedIssueList().SelectedItem(); selected.ID != "" { + cmd = m.startEditDescription(selected) } - case !d.IsInModal() && key.Matches(msg, d.keyMap.MoveIssueRight): - cmd = d.moveIssue(+1) - case !d.IsInModal() && key.Matches(msg, d.keyMap.MoveIssueLeft): - cmd = d.moveIssue(-1) - 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 && !d.editingAssignee && key.Matches(msg, d.keyMap.EditTitle): - if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { - d.startEditTitle(selected) - cmd = d.titleInput.Focus() + case m.notInModalMsgWithKey(msg, m.keyMap.ChangeStatus): + if selected := m.FocusedIssueList().SelectedItem(); selected.ID != "" { + cmd = m.startChooseStatus(selected) } - case !d.IsInModal() && key.Matches(msg, d.keyMap.EditDescription): - if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { - d.startEditDescription(selected) - cmd = d.descriptionInput.Focus() + case m.notInModalMsgWithKey(msg, m.keyMap.ChangePriority): + if selected := m.FocusedIssueList().SelectedItem(); selected.ID != "" { + cmd = m.startChoosePriority(selected) } - 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 m.notInModalMsgWithKey(msg, m.keyMap.ChangeType): + if selected := m.FocusedIssueList().SelectedItem(); selected.ID != "" { + cmd = m.startChooseType(selected) } - case !d.IsInModal() && key.Matches(msg, d.keyMap.ChangePriority): - if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { - d.startChoosePriority(selected) + case m.notInModalMsgWithKey(msg, m.keyMap.ChangeAssignee): + if selected := m.FocusedIssueList().SelectedItem(); selected.ID != "" { + cmd = m.startEditAssignee(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 m.notInModalMsgWithKey(msg, m.keyMap.AddIssue): + cmd = m.startCreateIssue() + + case m.notInModalMsgWithKey(msg, m.keyMap.AddComment): + if selected := m.FocusedIssueList().SelectedItem(); selected.ID != "" { + cmd = m.startAddComment(selected) } - case !d.IsInModal() && key.Matches(msg, d.keyMap.ChangeAssignee): - if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { - d.startEditAssignee(selected) - cmd = d.assigneeInput.Focus() - } - case !d.IsInModal() && key.Matches(msg, d.keyMap.AddIssue): - d.startCreateIssue() - cmd = d.createTitleInput.Focus() - case !d.IsInModal() && key.Matches(msg, d.keyMap.DeleteIssue): - fl := d.FocusedIssueList() + + case m.notInModalMsgWithKey(msg, m.keyMap.DeleteIssue): + fl := m.FocusedIssueList() if selected := fl.SelectedItem(); selected.ID != "" { - d.startConfirmDelete(selected.ID, fl.Index()) + cmd = m.startConfirmDelete(selected.ID, fl.Index()) } } return cmd } + +func (m *Model) notInModalMsgWithKey(msg tea.KeyPressMsg, keyBinding key.Binding) bool { + return !m.IsInModal() && key.Matches(msg, keyBinding) +} diff --git a/pkg/tui/views/kanban/model.go b/pkg/tui/views/kanban/model.go index d1afc1d..5a84bda 100644 --- a/pkg/tui/views/kanban/model.go +++ b/pkg/tui/views/kanban/model.go @@ -3,12 +3,11 @@ package kanban import ( "context" - "charm.land/bubbles/v2/textarea" - "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "github.com/LazyBachelor/LazyPM/internal/app" "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/pkg/tui/components" + "github.com/LazyBachelor/LazyPM/pkg/tui/modal" "github.com/LazyBachelor/LazyPM/pkg/tui/msgs" ) @@ -27,61 +26,42 @@ type Model struct { doneList IssueList issueDetail IssueDetail helpBar components.HelpBar - keyMap KanbanKeyMap + keyMap KeyMap app *app.App width int height int - focusedColumn int // 0 = To Do, 1 = In Progress, 2 = Blocked, 3 = Done - focusOnDetail bool // true when detail pane is focused + // Modal and Focus management + modalManager *modal.Manager + focusManager *modal.FocusManager - editingTitle bool // true while we are editing a title - titleInput textinput.Model - editingIssueID string + // Current issue being operated on + currentIssueID string + deleteIndex int - 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 - editingAssignee bool // true while editing assignee - assigneeInput textinput.Model - assigneeIssueID string - choosingCloseReason bool // true while choosing a close reason - closeReasonIssueID string - closingOtherReason bool // true while entering a custom close reason - closeReasonInput textarea.Model - feedbackChan chan models.ValidationFeedback - quitChan chan bool - submitChan chan<- struct{} - currentFeedback models.ValidationFeedback - showComplete bool + feedbackChan chan models.ValidationFeedback + quitChan chan bool + currentFeedback models.ValidationFeedback + showComplete bool + submitChan chan<- struct{} } func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, quitChan chan bool, submitChan chan<- struct{}) *Model { m := &Model{ - header: components.NewHeader("Kanban Board"), - keyMap: defaultKanbanKeyMap, - app: app, - width: 80, - height: 24, - focusedColumn: 0, - focusOnDetail: false, - feedbackChan: feedbackChan, - quitChan: quitChan, - submitChan: submitChan, + header: components.NewHeader("Kanban Board"), + keyMap: defaultKanbanKeyMap, + app: app, + width: 80, + height: 24, + feedbackChan: feedbackChan, + quitChan: quitChan, + submitChan: submitChan, + modalManager: modal.NewManager(), + focusManager: modal.NewFocusManager(), + deleteIndex: -1, } + + // Setup lists m.issueDetail = components.NewIssueDetail() m.helpBar = components.NewHelpBar(components.ViewKanban) @@ -96,77 +76,33 @@ func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, qui m.blockedList = components.NewIssueListFromIssues(app, blockedIssues, 20, 10) m.doneList = components.NewIssueListFromIssues(app, doneIssues, 20, 10) - inputs := components.NewIssueInputs() - m.titleInput = inputs.Title - m.createTitleInput = inputs.CreateTitle - m.descriptionInput = inputs.Description - m.assigneeInput = inputs.Assignee + // Setup focus areas for kanban columns + m.focusManager.EnableArea(modal.FocusColumn1) + m.focusManager.EnableArea(modal.FocusColumn2) + m.focusManager.EnableArea(modal.FocusColumn3) + m.focusManager.EnableArea(modal.FocusColumn4) + m.focusManager.SetCurrent(modal.FocusColumn1) - closeReasonTa := textarea.New() - closeReasonTa.Placeholder = "Enter closing reason..." - closeReasonTa.SetWidth(56) - closeReasonTa.SetHeight(4) - m.closeReasonInput = closeReasonTa + // Register modals + m.registerModals() if selected := m.todoList.SelectedItem(); selected.ID != "" { - m.issueDetail.SetIssue(selected.Issue) - } else if selected := m.inProgList.SelectedItem(); selected.ID != "" { - m.issueDetail.SetIssue(selected.Issue) - } else if selected := m.blockedList.SelectedItem(); selected.ID != "" { - m.issueDetail.SetIssue(selected.Issue) - } else if selected := m.doneList.SelectedItem(); selected.ID != "" { - m.issueDetail.SetIssue(selected.Issue) + m.setDetailIssueWithComments(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) Init() tea.Cmd { + if m.submitChan != nil { + m.submitChan <- struct{}{} + m.logAction("tui submitted validation") + } + return components.ListenForValidation(m.feedbackChan) } -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) startEditAssignee(selected ListIssue) { - m.editingAssignee = true - m.assigneeIssueID = selected.ID - m.assigneeInput.SetValue(selected.Assignee) - m.assigneeInput.CursorEnd() +func (m *Model) registerModals() { + modal.RegisterCommonModals(m.modalManager) } func (m *Model) logAction(action string) { @@ -178,7 +114,6 @@ func (m *Model) logAction(action string) { } } -// submitValidation sends a validation request to the submit channel. func (m *Model) submitValidation() { if m.submitChan != nil { select { @@ -189,90 +124,76 @@ func (m *Model) submitValidation() { } } -func (m *Model) Init() tea.Cmd { - if m.submitChan != nil { - m.submitChan <- struct{}{} - m.logAction("tui submitted validation") - } - return components.ListenForValidation(m.feedbackChan) -} - -// IsInModal returns true when a modal (edit, create, delete confirm, choose status/priority/type) is active. func (m *Model) IsInModal() bool { - return m.editingTitle || m.creatingIssue || m.editingDescription || - m.choosingStatus || m.choosingPriority || m.confirmingDelete || - m.choosingType || m.editingAssignee || - m.choosingCloseReason || m.closingOtherReason + return m.modalManager.IsModalActive() } func (m *Model) IsFocusedOnList() bool { - return !m.focusOnDetail + return m.focusManager.IsListFocused() } func (m *Model) IsFocusedOnDetail() bool { - return m.focusOnDetail -} - -func (m *Model) FocusList() { - m.focusOnDetail = false - m.issueDetail.SetFocused(false) -} - -func (m *Model) FocusDetail() { - m.focusOnDetail = true - m.issueDetail.SetFocused(true) + return m.focusManager.IsDetailFocused() } func (m *Model) ToggleFocus() { - if m.IsFocusedOnList() { - m.FocusDetail() + if m.focusManager.IsDetailFocused() { + m.focusManager.SetCurrent(modal.FocusColumn1) + m.issueDetail.SetFocused(false) } else { - m.FocusList() + m.focusManager.SetCurrent(modal.FocusDetail) + m.issueDetail.SetFocused(true) } } func (m *Model) FocusedIssueList() *IssueList { - switch m.focusedColumn { - case 0: + switch m.focusManager.Current() { + case modal.FocusColumn1: return &m.todoList - case 1: + case modal.FocusColumn2: return &m.inProgList - case 2: + case modal.FocusColumn3: return &m.blockedList - case 3: + case modal.FocusColumn4: return &m.doneList default: return &m.todoList } } -// updateDetailFromSelection updates the detail pane based on the currently -// focused column's selected issue. func (m *Model) updateDetailFromSelection() { selected := m.FocusedIssueList().SelectedItem() if selected.ID != "" { - m.issueDetail.SetIssue(selected.Issue) + m.setDetailIssueWithComments(selected.Issue) } } -// statusForColumn maps a board column index to a Status. -func statusForColumn(col int) models.Status { +// setDetailIssueWithComments sets the issue in the detail pane and loads its comments. +func (m *Model) setDetailIssueWithComments(issue models.Issue) { + m.issueDetail.SetIssue(issue) + if issue.ID == "" { + m.issueDetail.SetComments(nil) + return + } + comments, _ := m.app.Issues.GetIssueComments(context.Background(), issue.ID) + m.issueDetail.SetComments(comments) +} + +func statusForColumn(col modal.FocusArea) models.Status { switch col { - case 0: + case modal.FocusColumn1: return models.StatusOpen - case 1: + case modal.FocusColumn2: return models.StatusInProgress - case 2: + case modal.FocusColumn3: return models.StatusBlocked - case 3: + case modal.FocusColumn4: return models.StatusClosed default: return models.StatusOpen } } -// moveIssue moves the currently selected issue in the focused column horizontally -// to an adjacent column by updating its status. func (m *Model) moveIssue(delta int) tea.Cmd { fl := m.FocusedIssueList() selected := fl.SelectedItem() @@ -280,8 +201,32 @@ func (m *Model) moveIssue(delta int) tea.Cmd { return nil } - newCol := m.focusedColumn + delta - if newCol < 0 || newCol > 3 { + currentCol := m.focusManager.Current() + var newCol modal.FocusArea + switch currentCol { + case modal.FocusColumn1: + if delta > 0 { + newCol = modal.FocusColumn2 + } + case modal.FocusColumn2: + if delta > 0 { + newCol = modal.FocusColumn3 + } else { + newCol = modal.FocusColumn1 + } + case modal.FocusColumn3: + if delta > 0 { + newCol = modal.FocusColumn4 + } else { + newCol = modal.FocusColumn2 + } + case modal.FocusColumn4: + if delta < 0 { + newCol = modal.FocusColumn3 + } + } + + if newCol == modal.FocusNone { return nil } diff --git a/pkg/tui/views/kanban/operations.go b/pkg/tui/views/kanban/operations.go index 2a9f790..7884f7f 100644 --- a/pkg/tui/views/kanban/operations.go +++ b/pkg/tui/views/kanban/operations.go @@ -2,16 +2,17 @@ package kanban import ( "context" + "strconv" "charm.land/bubbles/v2/list" - "charm.land/bubbletea/v2" + tea "charm.land/bubbletea/v2" "github.com/LazyBachelor/LazyPM/internal/models" + "github.com/LazyBachelor/LazyPM/internal/utils/user" "github.com/LazyBachelor/LazyPM/pkg/tui/components" + "github.com/LazyBachelor/LazyPM/pkg/tui/modal" "github.com/LazyBachelor/LazyPM/pkg/tui/msgs" ) -// 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. func (m *Model) refreshIssueListsAndSelectIssue(issueID string) tea.Cmd { allIssues, err := m.app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) if err != nil { @@ -31,7 +32,7 @@ func (m *Model) refreshIssueListsAndSelectIssue(issueID string) tea.Cmd { var targetStatus models.Status for _, issue := range allIssues { if issue.ID == issueID { - m.issueDetail.SetIssue(*issue) + m.setDetailIssueWithComments(*issue) targetStatus = issue.Status break } @@ -39,85 +40,276 @@ func (m *Model) refreshIssueListsAndSelectIssue(issueID string) tea.Cmd { switch targetStatus { case models.StatusOpen: - m.focusedColumn = 0 + m.focusManager.SetCurrent(modal.FocusColumn1) case models.StatusInProgress: - m.focusedColumn = 1 + m.focusManager.SetCurrent(modal.FocusColumn2) case models.StatusBlocked: - m.focusedColumn = 2 + m.focusManager.SetCurrent(modal.FocusColumn3) case models.StatusClosed: - m.focusedColumn = 3 + m.focusManager.SetCurrent(modal.FocusColumn4) } - // Select the moved issue in its new column immediately so the highlight follows it. - m.todoList.SelectIssueID(issueID) - m.inProgList.SelectIssueID(issueID) - m.blockedList.SelectIssueID(issueID) - m.doneList.SelectIssueID(issueID) - - return tea.Sequence(todoCmd, inProgCmd, blockedCmd, doneCmd) + return tea.Sequence(todoCmd, inProgCmd, blockedCmd, doneCmd, func() tea.Msg { + return msgs.SelectIssueMsg{IssueID: issueID} + }) } -// refreshAndSubmit refreshes the issue lists and submits validation. -// This is a wrapper that should be used after any successful user action. func (m *Model) refreshAndSubmit(issueID string) tea.Cmd { refreshCmd := m.refreshIssueListsAndSelectIssue(issueID) m.submitValidation() return refreshCmd } +// Modal action handlers + +func (m *Model) startEditTitle(selected ListIssue) tea.Cmd { + m.currentIssueID = selected.ID + titleModal := m.modalManager.GetTextInputModal(modal.ModalEditTitle) + if titleModal != nil { + titleModal.SetValue(selected.Issue.Title) + titleModal.CursorEnd() + return m.modalManager.ShowModal(modal.ModalEditTitle) + } + return nil +} + +func (m *Model) startEditDescription(selected ListIssue) tea.Cmd { + m.currentIssueID = selected.ID + descModal := m.modalManager.GetTextAreaModal(modal.ModalEditDescription) + if descModal != nil { + descModal.SetValue(selected.Issue.Description) + return m.modalManager.ShowModal(modal.ModalEditDescription) + } + return nil +} + +func (m *Model) startCreateIssue() tea.Cmd { + createModal := m.modalManager.GetTextInputModal(modal.ModalCreateIssue) + if createModal != nil { + createModal.Reset() + return m.modalManager.ShowModal(modal.ModalCreateIssue) + } + return nil +} + +func (m *Model) startConfirmDelete(issueID string, index int) tea.Cmd { + m.currentIssueID = issueID + m.deleteIndex = index + deleteModal := m.modalManager.GetConfirmModal(modal.ModalConfirmDelete) + if deleteModal != nil { + return m.modalManager.ShowModal(modal.ModalConfirmDelete) + } + return nil +} + +func (m *Model) startChooseStatus(selected ListIssue) tea.Cmd { + m.currentIssueID = selected.ID + return m.modalManager.ShowModal(modal.ModalSelectStatus) +} + +func (m *Model) startChoosePriority(selected ListIssue) tea.Cmd { + m.currentIssueID = selected.ID + return m.modalManager.ShowModal(modal.ModalSelectPriority) +} + +func (m *Model) startChooseType(selected ListIssue) tea.Cmd { + m.currentIssueID = selected.ID + return m.modalManager.ShowModal(modal.ModalSelectType) +} + +func (m *Model) startEditAssignee(selected ListIssue) tea.Cmd { + m.currentIssueID = selected.ID + assigneeModal := m.modalManager.GetTextInputModal(modal.ModalEditAssignee) + if assigneeModal != nil { + assigneeModal.SetValue(selected.Assignee) + assigneeModal.CursorEnd() + return m.modalManager.ShowModal(modal.ModalEditAssignee) + } + return nil +} + +func (m *Model) startAddComment(selected ListIssue) tea.Cmd { + m.currentIssueID = selected.ID + commentModal := m.modalManager.GetTextAreaModal(modal.ModalAddComment) + if commentModal != nil { + commentModal.Reset() + return m.modalManager.ShowModal(modal.ModalAddComment) + } + return nil +} + +// handleModalCompleted handles all modal completion messages +func (m *Model) handleModalCompleted(msg modal.ModalCompletedMsg) tea.Cmd { + switch msg.ModalID { + case modal.ModalEditTitle: + if r, ok := msg.Value.(modal.TextInputResult); ok { + cmd := msgs.UpdateIssueTitleCmd(m.app, m.currentIssueID, r.Value) + return func() tea.Msg { return cmd() } + } + case modal.ModalCreateIssue: + if r, ok := msg.Value.(modal.TextInputResult); ok && r.Value != "" { + cmd := msgs.CreateIssueCmd(m.app, r.Value) + return func() tea.Msg { return cmd() } + } + case modal.ModalEditAssignee: + if r, ok := msg.Value.(modal.TextInputResult); ok { + cmd := msgs.UpdateIssueAssigneeCmd(m.app, m.currentIssueID, r.Value) + return func() tea.Msg { return cmd() } + } + case modal.ModalEditDescription: + if r, ok := msg.Value.(modal.TextAreaResult); ok { + cmd := msgs.UpdateIssueDescriptionCmd(m.app, m.currentIssueID, r.Value) + return func() tea.Msg { return cmd() } + } + case modal.ModalConfirmDelete: + if r, ok := msg.Value.(modal.ConfirmResult); ok && r.Confirmed { + idx := m.deleteIndex + issueID := m.currentIssueID + m.deleteIndex = -1 + m.currentIssueID = "" + cmd := msgs.DeleteIssueCmd(m.app, issueID, idx) + return func() tea.Msg { return cmd() } + } + case modal.ModalSelectStatus: + if r, ok := msg.Value.(modal.SelectResult); ok { + if r.SelectedValue == "closing" { + return m.modalManager.ShowModal(modal.ModalSelectCloseReason) + } + cmd := msgs.UpdateIssueStatusCmd(m.app, m.currentIssueID, r.SelectedValue) + return func() tea.Msg { return cmd() } + } + case modal.ModalSelectCloseReason: + if r, ok := msg.Value.(modal.SelectResult); ok { + if r.SelectedValue == "other" { + return m.modalManager.ShowModal(modal.ModalCloseReason) + } + cmd := msgs.CloseIssueCmd(m.app, m.currentIssueID, r.SelectedValue) + return func() tea.Msg { return cmd() } + } + case modal.ModalSelectPriority: + if r, ok := msg.Value.(modal.SelectResult); ok { + priority, err := strconv.Atoi(r.SelectedValue) + if err != nil { + return nil + } + cmd := msgs.UpdateIssuePriorityCmd(m.app, m.currentIssueID, priority) + return func() tea.Msg { return cmd() } + } + case modal.ModalSelectType: + if r, ok := msg.Value.(modal.SelectResult); ok { + issueType := models.IssueType(r.SelectedValue) + cmd := msgs.UpdateIssueTypeCmd(m.app, m.currentIssueID, issueType) + return func() tea.Msg { return cmd() } + } + case modal.ModalCloseReason: + if r, ok := msg.Value.(modal.TextAreaResult); ok && r.Value != "" { + cmd := msgs.CloseIssueCmd(m.app, m.currentIssueID, r.Value) + return func() tea.Msg { return cmd() } + } + case modal.ModalAddComment: + if r, ok := msg.Value.(modal.TextAreaResult); ok && r.Value != "" { + cmd := msgs.AddIssueCommentCmd(m.app, m.currentIssueID, user.GetOsUsername(), r.Value) + return func() tea.Msg { return cmd() } + } + } + return nil +} + +// handleModalCancelled handles all modal cancellation messages +func (m *Model) handleModalCancelled(msg modal.ModalCancelledMsg) { + switch msg.ModalID { + case modal.ModalEditTitle: + m.currentIssueID = "" + case modal.ModalCreateIssue: + // No cleanup needed + case modal.ModalEditAssignee: + m.currentIssueID = "" + case modal.ModalEditDescription: + m.currentIssueID = "" + case modal.ModalConfirmDelete: + m.deleteIndex = -1 + m.currentIssueID = "" + case modal.ModalSelectStatus: + m.currentIssueID = "" + case modal.ModalSelectCloseReason: + m.currentIssueID = "" + case modal.ModalSelectPriority: + m.currentIssueID = "" + case modal.ModalSelectType: + m.currentIssueID = "" + case modal.ModalCloseReason: + m.currentIssueID = "" + case modal.ModalAddComment: + m.currentIssueID = "" + } +} + func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if cmd, handled := m.modalManager.Update(msg); handled { + return m, cmd + } + switch msg := msg.(type) { + case modal.ModalCompletedMsg: + return m, m.handleModalCompleted(msg) + + case modal.ModalCancelledMsg: + m.handleModalCancelled(msg) + return m, nil case msgs.TitleUpdatedMsg: - m.editingTitle = false - m.editingIssueID = "" - m.titleInput.Blur() + m.modalManager.GetTextInputModal(modal.ModalEditTitle).Reset() + m.currentIssueID = "" if msg.Err != nil { return m, nil } return m, m.refreshAndSubmit(msg.IssueID) case msgs.DescriptionUpdatedMsg: - m.editingDescription = false - m.editingDescIssueID = "" - m.descriptionInput.Blur() + m.modalManager.GetTextAreaModal(modal.ModalEditDescription).Reset() + m.currentIssueID = "" if msg.Err != nil { return m, nil } return m, m.refreshAndSubmit(msg.IssueID) case msgs.StatusUpdatedMsg: - m.choosingStatus = false - m.statusIssueID = "" + m.currentIssueID = "" if msg.Err != nil { return m, nil } return m, m.refreshAndSubmit(msg.IssueID) case msgs.PriorityUpdatedMsg: - m.choosingPriority = false - m.priorityIssueID = "" + m.currentIssueID = "" if msg.Err != nil { return m, nil } return m, m.refreshAndSubmit(msg.IssueID) case msgs.TypeUpdatedMsg: - m.choosingType = false - m.typeIssueID = "" + m.currentIssueID = "" if msg.Err != nil { return m, nil } return m, m.refreshAndSubmit(msg.IssueID) case msgs.AssigneeUpdatedMsg: - m.editingAssignee = false - m.assigneeIssueID = "" - m.assigneeInput.Blur() + m.modalManager.GetTextInputModal(modal.ModalEditAssignee).Reset() + m.currentIssueID = "" if msg.Err != nil { return m, nil } return m, m.refreshAndSubmit(msg.IssueID) + case msgs.IssueCommentAddedMsg: + m.modalManager.GetTextAreaModal(modal.ModalAddComment).Reset() + m.currentIssueID = "" + if msg.Err != nil { + return m, nil + } + m.submitValidation() + return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) + case msgs.SelectIssueMsg: m.todoList.SelectIssueID(msg.IssueID) m.inProgList.SelectIssueID(msg.IssueID) @@ -126,9 +318,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case msgs.CreatedMsg: - m.creatingIssue = false - m.createTitleInput.Blur() - m.createTitleInput.Reset() + m.modalManager.GetTextInputModal(modal.ModalCreateIssue).Reset() if msg.Err != nil || msg.Issue == nil { return m, nil } @@ -147,11 +337,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { blockedCmd := m.blockedList.SetIssues(blockedIssues) doneCmd := m.doneList.SetIssues(doneIssues) - // 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 allIssues { - // 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 @@ -159,12 +347,15 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - m.issueDetail.SetIssue(*selectedIssue) + m.setDetailIssueWithComments(*selectedIssue) m.submitValidation() - return m, tea.Sequence(todoCmd, inProgCmd, blockedCmd, doneCmd, func() tea.Msg { return msgs.SelectIssueMsg{IssueID: selectedIssue.ID} }) + return m, tea.Sequence(todoCmd, inProgCmd, blockedCmd, doneCmd, func() tea.Msg { + return msgs.SelectIssueMsg{IssueID: selectedIssue.ID} + }) + case msgs.DeletedMsg: - m.confirmingDelete = false - m.deleteConfirmID = "" + m.deleteIndex = -1 + m.currentIssueID = "" if msg.Err != nil { return m, nil } @@ -183,77 +374,74 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { blockedCmd := m.blockedList.SetIssues(blockedIssues) doneCmd := m.doneList.SetIssues(doneIssues) - // If there are no issues at all, clear the detail view and return. if len(todoIssues) == 0 && len(inProgIssues) == 0 && len(blockedIssues) == 0 && len(doneIssues) == 0 { - m.issueDetail.SetIssue(models.Issue{}) + m.setDetailIssueWithComments(models.Issue{}) m.submitValidation() return m, tea.Sequence(todoCmd, inProgCmd, blockedCmd, doneCmd) } - // Determine which column to use for the next selection based on the current focus. var targetIssues []*models.Issue - switch m.focusedColumn { - case 0: + switch m.focusManager.Current() { + case modal.FocusColumn1: targetIssues = todoIssues if len(targetIssues) == 0 { if len(inProgIssues) > 0 { targetIssues = inProgIssues - m.focusedColumn = 1 + m.focusManager.SetCurrent(modal.FocusColumn2) } else if len(blockedIssues) > 0 { targetIssues = blockedIssues - m.focusedColumn = 2 + m.focusManager.SetCurrent(modal.FocusColumn3) } else if len(doneIssues) > 0 { targetIssues = doneIssues - m.focusedColumn = 3 + m.focusManager.SetCurrent(modal.FocusColumn4) } } - case 1: + case modal.FocusColumn2: targetIssues = inProgIssues if len(targetIssues) == 0 { if len(todoIssues) > 0 { targetIssues = todoIssues - m.focusedColumn = 0 + m.focusManager.SetCurrent(modal.FocusColumn1) } else if len(blockedIssues) > 0 { targetIssues = blockedIssues - m.focusedColumn = 2 + m.focusManager.SetCurrent(modal.FocusColumn3) } else if len(doneIssues) > 0 { targetIssues = doneIssues - m.focusedColumn = 3 + m.focusManager.SetCurrent(modal.FocusColumn4) } } - case 2: + case modal.FocusColumn3: targetIssues = blockedIssues if len(targetIssues) == 0 { if len(inProgIssues) > 0 { targetIssues = inProgIssues - m.focusedColumn = 1 + m.focusManager.SetCurrent(modal.FocusColumn2) } else if len(todoIssues) > 0 { targetIssues = todoIssues - m.focusedColumn = 0 + m.focusManager.SetCurrent(modal.FocusColumn1) } else if len(doneIssues) > 0 { targetIssues = doneIssues - m.focusedColumn = 3 + m.focusManager.SetCurrent(modal.FocusColumn4) } } - case 3: + case modal.FocusColumn4: targetIssues = doneIssues if len(targetIssues) == 0 { if len(blockedIssues) > 0 { targetIssues = blockedIssues - m.focusedColumn = 2 + m.focusManager.SetCurrent(modal.FocusColumn3) } else if len(inProgIssues) > 0 { targetIssues = inProgIssues - m.focusedColumn = 1 + m.focusManager.SetCurrent(modal.FocusColumn2) } else if len(todoIssues) > 0 { targetIssues = todoIssues - m.focusedColumn = 0 + m.focusManager.SetCurrent(modal.FocusColumn1) } } } - // Safety: if targetIssues is still empty here, just clear detail and return. if len(targetIssues) == 0 { - m.issueDetail.SetIssue(models.Issue{}) + m.setDetailIssueWithComments(models.Issue{}) m.submitValidation() return m, tea.Sequence(todoCmd, inProgCmd, blockedCmd, doneCmd) } @@ -263,249 +451,19 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { newIndex = len(targetIssues) - 1 } selectedIssue := targetIssues[newIndex] - m.issueDetail.SetIssue(*selectedIssue) + m.setDetailIssueWithComments(*selectedIssue) m.submitValidation() return m, tea.Sequence(todoCmd, inProgCmd, blockedCmd, doneCmd, func() tea.Msg { return msgs.SelectIssueMsg{IssueID: selectedIssue.ID} }) case tea.KeyPressMsg: - if m.confirmingDelete { - switch msg.String() { - case "y", "Y": - issueID := m.deleteConfirmID - idx := m.deleteConfirmIndex - m.confirmingDelete = false - m.deleteConfirmID = "" - return m, msgs.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, msgs.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusOpen)) - case "i": - issueID := m.statusIssueID - m.choosingStatus = false - m.statusIssueID = "" - return m, msgs.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusInProgress)) - case "b": - issueID := m.statusIssueID - m.choosingStatus = false - m.statusIssueID = "" - return m, msgs.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusBlocked)) - case "r": - issueID := m.statusIssueID - m.choosingStatus = false - m.statusIssueID = "" - return m, msgs.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusReadyToSprint)) - case "c": - issueID := m.statusIssueID - m.choosingStatus = false - m.statusIssueID = "" - m.choosingCloseReason = true - m.closeReasonIssueID = issueID - return m, nil - case "esc": - m.choosingStatus = false - m.statusIssueID = "" - return m, nil - } - } - - if m.choosingCloseReason { - var reason string - switch msg.String() { - case "d": - reason = "Done" - case "u": - reason = "Duplicate issue" - case "w": - reason = "Won't fix" - case "o": - reason = "Obsolete" - case "h": - m.choosingCloseReason = false - m.closingOtherReason = true - m.closeReasonInput.SetValue("") - m.closeReasonInput.Focus() - return m, nil - case "esc": - m.choosingCloseReason = false - m.closeReasonIssueID = "" - return m, nil - } - - if reason != "" { - issueID := m.closeReasonIssueID - m.choosingCloseReason = false - m.closeReasonIssueID = "" - return m, msgs.CloseIssueCmd(m.app, issueID, reason) - } - } - - if m.closingOtherReason { - switch msg.String() { - case "enter", "ctrl+s": - reason := m.closeReasonInput.Value() - if reason != "" { - issueID := m.closeReasonIssueID - m.closingOtherReason = false - m.closeReasonIssueID = "" - m.closeReasonInput.Blur() - return m, msgs.CloseIssueCmd(m.app, issueID, reason) - } - case "esc": - m.closingOtherReason = false - m.closeReasonIssueID = "" - m.closeReasonInput.Blur() - return m, nil - } - var cmd tea.Cmd - m.closeReasonInput, cmd = m.closeReasonInput.Update(msg) + fl := m.FocusedIssueList() + if fl.FilterState() == list.Filtering { + cmd, _ := fl.Update(msg) return m, cmd } - 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, msgs.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, msgs.UpdateIssueTypeCmd(m.app, issueID, models.TypeBug) - case "f": - issueID := m.typeIssueID - m.choosingType = false - m.typeIssueID = "" - return m, msgs.UpdateIssueTypeCmd(m.app, issueID, models.TypeFeature) - case "t": - issueID := m.typeIssueID - m.choosingType = false - m.typeIssueID = "" - return m, msgs.UpdateIssueTypeCmd(m.app, issueID, models.TypeTask) - case "e": - issueID := m.typeIssueID - m.choosingType = false - m.typeIssueID = "" - return m, msgs.UpdateIssueTypeCmd(m.app, issueID, models.TypeEpic) - case "c": - issueID := m.typeIssueID - m.choosingType = false - m.typeIssueID = "" - return m, msgs.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, msgs.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.editingAssignee { - if msg.String() == "enter" { - assignee := m.assigneeInput.Value() - return m, msgs.UpdateIssueAssigneeCmd(m.app, m.assigneeIssueID, assignee) - } - if msg.String() == "esc" { - m.editingAssignee = false - m.assigneeIssueID = "" - m.assigneeInput.Blur() - return m, nil - } - var cmd tea.Cmd - m.assigneeInput, cmd = m.assigneeInput.Update(msg) - return m, cmd - } - - if m.editingTitle { - if msg.String() == "enter" { - newTitle := m.titleInput.Value() - if newTitle != "" { - return m, msgs.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, msgs.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 } @@ -514,6 +472,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if cmd != nil { return m, cmd } + case components.ValidationFeedbackMsg: m.currentFeedback = msg.Feedback if msg.Feedback.Success { @@ -525,6 +484,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height + m.modalManager.SetSize(msg.Width, msg.Height) return m, nil } @@ -532,8 +492,20 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmd, changed := fl.Update(msg) if changed { if selected := fl.SelectedItem(); selected.ID != "" { - m.issueDetail.SetIssue(selected.Issue) + m.setDetailIssueWithComments(selected.Issue) } } + + // Only propagate non-key messages to other lists (SetItems, etc.) + // Key messages should only affect the focused list + if _, isKeyMsg := msg.(tea.KeyPressMsg); !isKeyMsg { + // Update all lists to ensure they receive commands like SetItems + todoCmd, _ := m.todoList.Update(msg) + inProgCmd, _ := m.inProgList.Update(msg) + blockedCmd, _ := m.blockedList.Update(msg) + doneCmd, _ := m.doneList.Update(msg) + return m, tea.Sequence(cmd, todoCmd, inProgCmd, blockedCmd, doneCmd) + } + return m, cmd } diff --git a/pkg/tui/views/kanban/view.go b/pkg/tui/views/kanban/view.go index 4b0d38e..8303359 100644 --- a/pkg/tui/views/kanban/view.go +++ b/pkg/tui/views/kanban/view.go @@ -1,19 +1,20 @@ package kanban import ( - "charm.land/bubbletea/v2" + tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/LazyBachelor/LazyPM/pkg/tui/components" - "github.com/LazyBachelor/LazyPM/pkg/tui/styles" + "github.com/LazyBachelor/LazyPM/pkg/tui/modal" + "github.com/LazyBachelor/LazyPM/internal/style" ) func (m *Model) View() tea.View { if m.width == 0 || m.height == 0 { - // if there is no space just print a loading message return tea.NewView("Loading...") } m.helpBar.SetWidth(m.width) + m.modalManager.SetSize(m.width, m.height) header := m.header.View(m.width) headerHeight := m.header.Height() @@ -23,45 +24,40 @@ func (m *Model) View() tea.View { contentHeight := m.height - headerHeight - footerHeight totalContentWidth := m.width - 1 - colWidth := totalContentWidth / 4 - if colWidth < 20 { - colWidth = 20 + colWidth := max(totalContentWidth/4, 20) + + // Calculate initial list height (half of content height, minimum 5 rows) + listHeight := contentHeight / 2 + if listHeight < 5 { + listHeight = contentHeight } - // Leave some space for the detail view below the board. - boardHeight := contentHeight / 2 - if boardHeight < 5 { - boardHeight = contentHeight - } + m.todoList.SetSize(colWidth, listHeight-1) + m.inProgList.SetSize(colWidth, listHeight-1) + m.blockedList.SetSize(colWidth, listHeight-1) + m.doneList.SetSize(colWidth, listHeight-1) - m.todoList.SetSize(colWidth, boardHeight-1) - m.inProgList.SetSize(colWidth, boardHeight-1) - m.blockedList.SetSize(colWidth, boardHeight-1) - m.doneList.SetSize(colWidth, boardHeight-1) + // Only highlight the focused column's selected row + currentFocus := m.focusManager.Current() + m.todoList.SetHighlightSelected(currentFocus == modal.FocusColumn1) + m.inProgList.SetHighlightSelected(currentFocus == modal.FocusColumn2) + m.blockedList.SetHighlightSelected(currentFocus == modal.FocusColumn3) + m.doneList.SetHighlightSelected(currentFocus == modal.FocusColumn4) - // Only highlight the selected row in the focused column. - m.todoList.SetHighlightSelected(!m.focusOnDetail && m.focusedColumn == 0) - m.inProgList.SetHighlightSelected(!m.focusOnDetail && m.focusedColumn == 1) - m.blockedList.SetHighlightSelected(!m.focusOnDetail && m.focusedColumn == 2) - m.doneList.SetHighlightSelected(!m.focusOnDetail && m.focusedColumn == 3) + todoLabel := style.LabelStyle.Render("To Do") + inProgLabel := style.LabelStyle.Render("In Progress") + blockedLabel := style.LabelStyle.Render("Blocked") + doneLabel := style.LabelStyle.Render("Done") - // Detail view takes full width below the board. - m.issueDetail.SetSize(totalContentWidth, contentHeight-boardHeight) - - todoLabel := styles.LabelStyle.Render("To Do") - inProgLabel := styles.LabelStyle.Render("In Progress") - blockedLabel := styles.LabelStyle.Render("Blocked") - doneLabel := styles.LabelStyle.Render("Done") - - highlight := lipgloss.NewStyle().Foreground(styles.Primary).Bold(true) - switch m.focusedColumn { - case 0: + highlight := lipgloss.NewStyle().Foreground(style.Primary).Bold(true) + switch currentFocus { + case modal.FocusColumn1: todoLabel = highlight.Render("To Do ▶") - case 1: + case modal.FocusColumn2: inProgLabel = highlight.Render("In Progress ▶") - case 2: + case modal.FocusColumn3: blockedLabel = highlight.Render("Blocked ▶") - case 3: + case modal.FocusColumn4: doneLabel = highlight.Render("Done ▶") } @@ -71,10 +67,13 @@ func (m *Model) View() tea.View { doneCol := lipgloss.JoinVertical(lipgloss.Left, doneLabel, m.doneList.View()) board := lipgloss.JoinHorizontal(lipgloss.Left, todoCol, inProgCol, blockedCol, doneCol) + boardHeight := lipgloss.Height(board) + + detailHeight := max(contentHeight-boardHeight, 5) + m.issueDetail.SetSize(totalContentWidth, detailHeight) + content := lipgloss.JoinVertical(lipgloss.Left, board, m.issueDetail.View()) - // Add spacer to lock footer to bottom of screen when content is shorter than available space - // This is to avoid having the footer floating above the bottom of the screen mainView := lipgloss.JoinVertical(lipgloss.Left, header, content, footer) mainViewHeight := lipgloss.Height(mainView) if mainViewHeight < m.height { @@ -83,60 +82,5 @@ func (m *Model) View() tea.View { mainView = lipgloss.JoinVertical(lipgloss.Left, header, content, spacer, footer) } - if m.choosingCloseReason { - reasonContent := lipgloss.JoinVertical(lipgloss.Left, - styles.LabelStyle.Render("Choose closing reason for "+m.closeReasonIssueID+":"), - lipgloss.NewStyle().Foreground(styles.FaintText).Render("d = Done u = Duplicate issue w = Won't fix o = Obsolete h = Other"), - lipgloss.NewStyle().Foreground(styles.FaintText).Render("Esc = cancel"), - ) - reasonBoxWidth := min(70, m.width-4) - reasonBox := styles.ContainerStyle. - Width(reasonBoxWidth). - BorderForeground(styles.PrimaryBorder). - Render(reasonContent) - return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, reasonBox)) - } - - if m.closingOtherReason { - editBoxWidth := min(60, m.width-4) - m.closeReasonInput.SetWidth(editBoxWidth - 2) - m.closeReasonInput.SetHeight(4) - editContent := lipgloss.JoinVertical(lipgloss.Left, - styles.LabelStyle.Render("Enter closing reason for "+m.closeReasonIssueID+" (Enter or Ctrl+S to save, Esc to cancel):"), - m.closeReasonInput.View(), - ) - editBox := styles.ContainerStyle. - Width(editBoxWidth). - BorderForeground(styles.PrimaryBorder). - Render(editContent) - return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, editBox)) - } - - return tea.NewView(components.RenderModals( - m.width, - m.height, - m.editingTitle, - m.titleInput.View(), - m.editingDescription, - m.descriptionInput.View(), - m.creatingIssue, - m.createTitleInput.View(), - m.confirmingDelete, - m.deleteConfirmID, - m.choosingStatus, - m.statusIssueID, - m.choosingPriority, - m.priorityIssueID, - m.choosingType, - m.typeIssueID, - m.editingAssignee, - m.assigneeInput.View(), - mainView, - )) - -} - -func (m *Model) footer() string { - // Kept for backwards compatibility; delegate to the shared helper. - return components.RenderFooter(m.width, &m.helpBar, m.currentFeedback) + return tea.NewView(m.modalManager.RenderWithMainView(mainView)) }