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:
215
pkg/tui/modal/select.go
Normal file
215
pkg/tui/modal/select.go
Normal file
@@ -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"},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user