Files
LazyPM/pkg/tui/modal/modal.go
Robin Olsen d900a5fcd3 Merge pull request #75 from LazyBachelor/LPM-140
LPM-140 The Sprinting Update
Adds sprint support across the web UI (board + issue detail), TUI kanban, CLI/REPL, and the Beads-backed storage layer.

Changes:

Introduces sprint entities in storage and exposes sprint operations via IssueService.
Updates web board to support backlog vs. sprint columns, sprint selection, and sprint creation routes.
Updates TUI kanban to show backlog + sprint columns and adds sprint selection/creation actions.
2026-03-23 19:26:04 +01:00

170 lines
4.1 KiB
Go

// 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 (
ModalConfirmExit = "confirm-exit"
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"
ModalSelectSprint = "select-sprint"
)
// 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...)
}