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:
102
pkg/tui/modal/base.go
Normal file
102
pkg/tui/modal/base.go
Normal 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
141
pkg/tui/modal/confirm.go
Normal 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
143
pkg/tui/modal/focus.go
Normal 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
256
pkg/tui/modal/manager.go
Normal 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
167
pkg/tui/modal/modal.go
Normal 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
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"},
|
||||
}
|
||||
}
|
||||
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()
|
||||
}
|
||||
179
pkg/tui/modal/textinput.go
Normal file
179
pkg/tui/modal/textinput.go
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user