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:
177
pkg/tui/modal/textarea.go
Normal file
177
pkg/tui/modal/textarea.go
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user