Files
LazyPM/pkg/tui/modal/textinput.go
Robin Olsen 7facfb6afe 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.
2026-03-20 16:28:10 +01:00

180 lines
3.7 KiB
Go

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