Merge pull request #73 from LazyBachelor/LPM-138

LPM-138 Refactor Tui with composable modals and use canvas

Refactors the TUI (dashboard + kanban) to use a shared, composable modal system with canvas-based overlay rendering, while consolidating styling into internal/style and expanding issue interactions (e.g., comments).

Changes:

Introduces a new pkg/tui/modal system (manager/stack + multiple modal types) and overlays modals using Lipgloss compositor layers.
Refactors dashboard/kanban views and input handling to use the modal manager + focus manager instead of per-modal boolean state.
Consolidates TUI styling by moving from pkg/tui/styles to internal/style and updates components to use the new style package; adds a shared footer renderer.
This commit is contained in:
Robin Olsen
2026-03-20 08:28:10 -07:00
committed by GitHub
parent 9a47acc49a
commit 7facfb6afe
27 changed files with 2469 additions and 1752 deletions

View File

@@ -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()
}