diff --git a/internal/ui/form.go b/internal/ui/form.go index c5693104..9cdce64c 100644 --- a/internal/ui/form.go +++ b/internal/ui/form.go @@ -94,6 +94,10 @@ type FormModel struct { // Progressive disclosure: hide advanced fields for simpler first experience showAdvanced bool + + // modal renders the form as a centered, content-sized modal overlay + // (used when editing from the detail view) rather than a full-screen form. + modal bool } // Autocomplete message types for async LLM suggestions @@ -208,6 +212,7 @@ func NewEditFormModel(database *db.DB, task *db.Task, width, height int, availab taskRefAutocomplete: NewTaskRefAutocompleteModel(database, width-24), attachmentCursor: -1, showAdvanced: true, // Always show all fields when editing + modal: true, // Edit form is shown as a centered modal } // Load task types from database @@ -277,6 +282,9 @@ func NewEditFormModel(database *db.DB, task *db.Task, width, height int, availab m.attachmentsInput.Cursor.SetMode(cursor.CursorStatic) m.attachmentsInput.Width = width - 24 + // Apply modal-aware input widths and body height now that modal is set. + m.SetSize(width, height) + return m } @@ -1488,6 +1496,16 @@ func (m *FormModel) View() string { b.WriteString(header) b.WriteString("\n\n") + // Discard confirmation warning. In modal mode it appears at the top of the + // modal (right under the header) so it's immediately visible. + confirmStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("214")) // Orange/warning color + if m.modal && m.showCancelConfirm { + b.WriteString(" " + confirmStyle.Render("Discard changes? (y/n)")) + b.WriteString("\n\n") + } + // Ghost text style for autocomplete suggestions ghostStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Italic(true) @@ -1712,11 +1730,9 @@ func (m *FormModel) View() string { b.WriteString("\n\n") } - // Cancel confirmation message - if m.showCancelConfirm { - confirmStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("214")) // Orange/warning color + // Cancel confirmation message. In modal mode this is rendered at the top + // (see above); in full-screen mode it appears here near the help text. + if m.showCancelConfirm && !m.modal { b.WriteString(" " + confirmStyle.Render("Discard changes? (y/n)") + "\n") } @@ -1729,7 +1745,23 @@ func (m *FormModel) View() string { } b.WriteString(" " + dimStyle.Render(helpText)) - // Wrap in box - use full height (subtract 2 for border) + // Modal mode: wrap in a content-sized box and center it on screen so the + // whole form (all fields) is visible at once, floating like a modal. + if m.modal { + modalBox := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(ColorPrimary). + Padding(1, 2). + Width(m.modalBoxWidth()) + + return lipgloss.NewStyle(). + Width(m.width). + Height(m.height). + Align(lipgloss.Center, lipgloss.Center). + Render(modalBox.Render(b.String())) + } + + // Full-screen mode: wrap in box using full height (subtract 2 for border) box := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(ColorPrimary). @@ -1799,8 +1831,17 @@ func (m *FormModel) SetQueue(queue bool) { func (m *FormModel) SetSize(width, height int) { m.width = width m.height = height - // Update input widths + // Update input widths. In modal mode the form floats in a narrower + // centered box, so inputs are sized to the modal width instead of the + // full screen width. inputWidth := width - 24 + if m.modal { + // Modal content area = box width - padding(4) - label/cursor prefix(~18) + inputWidth = m.modalBoxWidth() - 22 + } + if inputWidth < 10 { + inputWidth = 10 + } m.titleInput.Width = inputWidth m.bodyInput.SetWidth(inputWidth) m.attachmentsInput.Width = inputWidth @@ -1808,6 +1849,20 @@ func (m *FormModel) SetSize(width, height int) { m.updateBodyHeight() } +// modalBoxWidth returns the outer width of the floating edit modal. +// It is capped so the modal stays readable on very wide terminals and +// shrinks gracefully on narrow ones. +func (m *FormModel) modalBoxWidth() int { + w := m.width - 8 + if w > 90 { + w = 90 + } + if w < 40 { + w = 40 + } + return w +} + // calculateBodyHeight calculates the appropriate height for the body textarea. // The body expands to fill all available screen space after accounting for other form elements. func (m *FormModel) calculateBodyHeight() int { @@ -1832,6 +1887,36 @@ func (m *FormModel) calculateBodyHeight() int { totalOverhead := boxChrome + commonOverhead + modeOverhead availableHeight := m.height - totalOverhead + + if m.modal { + // In modal mode the form floats centered with margin around it, so + // size the body to its content within sensible bounds rather than + // stretching to fill the whole screen. This keeps every field visible + // at once while still giving long bodies a scrollable editing area. + const modalMinBody = 6 + const modalMaxBody = 14 + lines := m.bodyInput.LineCount() + switch { + case lines < modalMinBody: + availableHeight = modalMinBody + case lines > modalMaxBody: + availableHeight = modalMaxBody + default: + availableHeight = lines + } + // Never let the modal grow past what fits on screen. On large screens + // the content-sized body is well under this cap, which naturally + // leaves margin around the floating modal. + maxFit := m.height - totalOverhead + if maxFit < 3 { + maxFit = 3 + } + if availableHeight > maxFit { + availableHeight = maxFit + } + return availableHeight + } + if availableHeight < minHeight { availableHeight = minHeight } diff --git a/internal/ui/form_test.go b/internal/ui/form_test.go index a31c01cb..c6c33ad3 100644 --- a/internal/ui/form_test.go +++ b/internal/ui/form_test.go @@ -108,6 +108,69 @@ func TestBodyHeightFillsAvailableSpace(t *testing.T) { } } +func TestEditFormIsModal(t *testing.T) { + task := &db.Task{Title: "Test task", Body: "Some body", Project: "proj"} + m := NewEditFormModel(nil, task, 120, 40, []string{"claude"}) + + if !m.modal { + t.Fatal("expected edit form to be rendered as a modal") + } + + view := m.View() + + // A centered modal floats within the screen, so the first line should be + // blank padding rather than the top border of a full-screen box. + firstLine := strings.SplitN(view, "\n", 2)[0] + if strings.Contains(firstLine, "╭") { + t.Errorf("expected modal to be centered with top margin, but first line is the border: %q", firstLine) + } + + // The modal box should be narrower than the full screen width. + if !strings.Contains(view, " ╭") { + t.Error("expected modal box to be indented (narrower than full width)") + } +} + +func TestEditFormDiscardWarningAtTop(t *testing.T) { + task := &db.Task{Title: "Test task", Body: "Some body", Project: "proj"} + m := NewEditFormModel(nil, task, 120, 40, []string{"claude"}) + m.showCancelConfirm = true + + view := m.View() + + discardIdx := strings.Index(view, "Discard changes?") + if discardIdx == -1 { + t.Fatal("expected discard warning to be rendered") + } + + // In modal mode the warning must appear above the editable fields + // (Title/Details), i.e. at the top of the modal. + titleIdx := strings.Index(view, "Title") + if titleIdx == -1 { + t.Fatal("expected Title field to be rendered") + } + if discardIdx > titleIdx { + t.Errorf("expected discard warning (pos %d) to appear before Title field (pos %d)", discardIdx, titleIdx) + } +} + +func TestEditFormModalBodyHeightBounded(t *testing.T) { + task := &db.Task{Title: "Test", Body: "one line"} + + // Short body on a tall screen should stay compact (not fill the screen). + m := NewEditFormModel(nil, task, 120, 80, []string{"claude"}) + if h := m.calculateBodyHeight(); h > 14 { + t.Errorf("modal body height %d should be bounded to <= 14 for a short body", h) + } + + // A long body grows but is still capped so every field stays visible. + task.Body = strings.Repeat("line\n", 100) + big := NewEditFormModel(nil, task, 120, 80, []string{"claude"}) + if h := big.calculateBodyHeight(); h > 14 { + t.Errorf("modal body height %d should be capped at 14 even for long bodies", h) + } +} + func TestRenderBodyScrollbar(t *testing.T) { tests := []struct { name string