From 82b843b38019eb8f627ab38586c572fe451a0b02 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Sun, 31 May 2026 06:45:44 -0500 Subject: [PATCH] tui: render task edit form as a centered modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When editing a task from detail view (press e), the form now renders as a compact, centered modal sized to its content instead of a full-screen form whose body sprawled to fill the whole screen. All fields (project, title, details, attachments, type, executor) are visible at once. The body textarea autogrows within bounds (6–14 lines) and is capped to fit the screen, so short tasks get a tight modal and long ones get a scrollable editing area. The "Discard changes?" confirmation now appears at the top of the modal (right under the header) when escaping with unsaved changes, so it's immediately visible. Only the edit form is modal; the new-task form keeps its full-screen layout. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/ui/form.go | 99 +++++++++++++++++++++++++++++++++++++--- internal/ui/form_test.go | 63 +++++++++++++++++++++++++ 2 files changed, 155 insertions(+), 7 deletions(-) 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