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.
216 lines
5.2 KiB
Go
216 lines
5.2 KiB
Go
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"},
|
|
}
|
|
}
|