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

102
pkg/tui/modal/base.go Normal file
View File

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

141
pkg/tui/modal/confirm.go Normal file
View File

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

143
pkg/tui/modal/focus.go Normal file
View File

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

256
pkg/tui/modal/manager.go Normal file
View File

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

167
pkg/tui/modal/modal.go Normal file
View File

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

215
pkg/tui/modal/select.go Normal file
View 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"},
}
}

177
pkg/tui/modal/textarea.go Normal file
View File

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

179
pkg/tui/modal/textinput.go Normal file
View File

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