diff --git a/internal/editor/editor.go b/internal/editor/editor.go index 1671da2..60acf5b 100644 --- a/internal/editor/editor.go +++ b/internal/editor/editor.go @@ -651,6 +651,11 @@ func (m *Model) resizeTextareas() { } m.textareas[i].SetHeight(m.textareas[i].VisualLineCount()) } + if m.kanban != nil && m.active >= 0 && m.active < len(m.blocks) && + m.blocks[m.active].Type == block.Kanban && m.kanban.edit { + m.kanban.editTA.SetWidth(m.kanbanCardEditWidth()) + m.kanban.editTA.SetHeight(m.kanban.editTA.VisualLineCount()) + } // Update viewport dimensions, reserving space for the header and status bar // (which may wrap to multiple lines on narrow terminals). h := m.height - m.headerHeight() - m.statusBarHeight() diff --git a/internal/editor/kanban.go b/internal/editor/kanban.go index 97b3e44..b71bb1c 100644 --- a/internal/editor/kanban.go +++ b/internal/editor/kanban.go @@ -17,7 +17,7 @@ import ( // own View() can clip top lines once it has scrolled internally; rendering // from Value() + cursor position avoids that and matches the rest of the // block editor's pattern. -func renderEditingCardText(ta *textarea.Model, contentWidth int) string { +func renderEditingCardText(ta *textarea.Model, contentWidth int, wordWrap bool) string { li := ta.LineInfo() cursorRawRow := ta.Line() cursorColInWrap := li.ColumnOffset @@ -33,18 +33,23 @@ func renderEditingCardText(ta *textarea.Model, contentWidth int) string { rawLines := strings.Split(content, "\n") var visualLines []string for rawIdx, raw := range rawLines { - segs := textarea.Wrap([]rune(raw), contentWidth) + segs := [][]rune{[]rune(raw)} + if wordWrap { + segs = textarea.Wrap([]rune(raw), contentWidth) + } if len(segs) == 0 { segs = [][]rune{{}} } for wIdx, seg := range segs { line := strings.TrimSuffix(string(seg), " ") + cursorCol := cursorColInWrap if rawIdx == cursorRawRow && wIdx == cursorWrapRow { runes := []rune(line) col := cursorColInWrap if col > len(runes) { col = len(runes) } + cursorCol = col before := string(runes[:col]) curChar := " " after := "" @@ -55,6 +60,9 @@ func renderEditingCardText(ta *textarea.Model, contentWidth int) string { ta.CursorSetChar(curChar) line = before + ta.CursorView() + after } + if !wordWrap { + line = scrollOrTruncate(line, contentWidth, cursorCol, rawIdx == cursorRawRow) + } visualLines = append(visualLines, line) } } @@ -443,8 +451,8 @@ func (ks *kanbanState) sortByPriority() { } // startEdit opens an inline textarea seeded with the selected card's text. -// The textarea wraps at the card's inner width and grows in height as the -// user types or inserts newlines (Shift+Enter). +// The caller supplies either the card's inner width (wrap mode) or the broad +// no-wrap width, and the textarea grows in height as visual lines change. func (ks *kanbanState) startEdit(width int) { c := ks.selectedCard() if c == nil { @@ -523,9 +531,9 @@ func (m Model) selectedCardLineRange() (top, bottom int) { } bodyLine := 0 for i := 0; i < m.kanban.card; i++ { - bodyLine += cardRenderHeight(cards[i], contentWidth) + bodyLine += cardRenderHeight(cards[i], contentWidth, m.wordWrap) } - height := cardRenderHeight(cards[m.kanban.card], contentWidth) + height := cardRenderHeight(cards[m.kanban.card], contentWidth, m.wordWrap) if m.kanban.edit { // Editing: textarea visual line count drives the height. taLines := m.kanban.editTA.VisualLineCount() @@ -593,13 +601,16 @@ func (m Model) kanbanCardOuterWidth() int { // cardRenderHeight predicts the rendered line count of a single card box // given its content width. Mirrors the math in renderKanbanCard. -func cardRenderHeight(card block.KanbanCard, contentWidth int) int { +func cardRenderHeight(card block.KanbanCard, contentWidth int, wordWrap bool) int { textLines := 1 if card.Text != "" { - // Account for explicit newlines in card text and word-wrapping at - // contentWidth (matches wrapPlain). - wrapped := wrapPlain(card.Text, contentWidth) - textLines = strings.Count(wrapped, "\n") + 1 + text := card.Text + if wordWrap { + // Account for explicit newlines in card text and word-wrapping at + // contentWidth (matches wrapPlain). + text = wrapPlain(card.Text, contentWidth) + } + textLines = strings.Count(text, "\n") + 1 } extra := 2 // top + bottom border if card.Priority != block.PriorityNone || card.Tag != block.KanbanTagNone { @@ -620,7 +631,7 @@ func (m Model) kanbanCardRenderHeight(card block.KanbanCard, ci, cardI, contentW } return taLines + extra } - return cardRenderHeight(card, contentWidth) + return cardRenderHeight(card, contentWidth, m.wordWrap) } // kanbanCardChromeWidth is the visual width consumed by a card's border @@ -630,7 +641,7 @@ const kanbanCardChromeWidth = 4 // renderKanbanCard renders one card box. outerWidth is the total visible // width of the card box (including border and padding); the actual text // content area is outerWidth - kanbanCardChromeWidth wide. -func renderKanbanCard(card block.KanbanCard, outerWidth int, selected, editing bool, editView string, th theme.Theme) string { +func renderKanbanCard(card block.KanbanCard, outerWidth int, selected, editing bool, editView string, th theme.Theme, wordWrap bool) string { border := lipgloss.RoundedBorder() borderColor := th.Border if selected { @@ -658,9 +669,17 @@ func renderKanbanCard(card block.KanbanCard, outerWidth int, selected, editing b case card.Text == "": text = lipgloss.NewStyle().Faint(true).Render("(empty)") default: - // Wrap the plain text first (wrap uses visual width and would - // miscount ANSI escapes), then apply inline markdown formatting. - text = format.RenderInlineMarkdown(wrapPlain(card.Text, contentWidth)) + if wordWrap { + // Wrap the plain text first (wrap uses visual width and would + // miscount ANSI escapes), then apply inline markdown formatting. + text = format.RenderInlineMarkdown(wrapPlain(card.Text, contentWidth)) + } else { + lines := strings.Split(format.RenderInlineMarkdown(card.Text), "\n") + for i, l := range lines { + lines[i] = scrollOrTruncate(l, contentWidth, 0, false) + } + text = strings.Join(lines, "\n") + } } // Metadata badges on their own short header line. @@ -838,9 +857,9 @@ func (m Model) renderKanbanColumnBodyLines(ci, cardOuterWidth int, th theme.Them editing := isSel && m.kanban.edit editView := "" if editing { - editView = renderEditingCardText(&m.kanban.editTA, cardOuterWidth-kanbanCardChromeWidth) + editView = renderEditingCardText(&m.kanban.editTA, cardOuterWidth-kanbanCardChromeWidth, m.wordWrap) } - lines = append(lines, strings.Split(renderKanbanCard(card, cardOuterWidth, isSel, editing, editView, th), "\n")...) + lines = append(lines, strings.Split(renderKanbanCard(card, cardOuterWidth, isSel, editing, editView, th, m.wordWrap), "\n")...) } return lines } @@ -1020,10 +1039,12 @@ func cloneKanbanCols(in []block.KanbanColumn) []block.KanbanColumn { return out } -// kanbanCardEditWidth returns the available textarea width for the inline -// edit textarea: matches the card's inner content width so the textarea -// wraps at exactly the same column the rendered box does. +// kanbanCardEditWidth returns the textarea width for the inline card editor. +// In no-wrap mode, use the same broad textarea width as regular blocks. func (m Model) kanbanCardEditWidth() int { + if !m.wordWrap { + return noWrapWidth + } return m.kanbanCardOuterWidth() - kanbanCardChromeWidth } @@ -1292,7 +1313,7 @@ func (m Model) renderActiveKanban(idx int, b block.Block) string { // only difference is no card is highlighted as selected. The window // offset is taken from `colOffset` — callers can drive scroll in view // mode by adjusting it. -func renderInactiveKanbanBoard(content string, width, colOffset int, th theme.Theme) string { +func renderInactiveKanbanBoard(content string, width, colOffset int, th theme.Theme, wordWrap bool) string { cols := block.ParseKanban(content) if len(cols) == 0 { return lipgloss.NewStyle().Faint(true).Render("(empty kanban — focus and press n to add a card)") @@ -1339,7 +1360,7 @@ func renderInactiveKanbanBoard(content string, width, colOffset int, th theme.Th } for _, card := range col.Cards { - cardLines = append(cardLines, renderKanbanCard(card, colWidth, false, false, "", th)) + cardLines = append(cardLines, renderKanbanCard(card, colWidth, false, false, "", th, wordWrap)) } rendered = append(rendered, colStyle.Render(strings.Join(cardLines, "\n"))) diff --git a/internal/editor/kanban_test.go b/internal/editor/kanban_test.go index 0dfa099..3614c92 100644 --- a/internal/editor/kanban_test.go +++ b/internal/editor/kanban_test.go @@ -534,6 +534,58 @@ func TestKanbanTallColumnCameraPadsSelectedCard(t *testing.T) { } } +func TestKanbanNoWrapKeepsLongCardsSingleLine(t *testing.T) { + noWrap := false + longText := "Alpha beta gamma delta epsilon zeta eta theta iota kappa lambda" + md := "```kanban\n## Todo\n- " + longText + "\n```" + m := New(Config{Title: "k", Content: md, WordWrap: &noWrap, Save: func(string) error { return nil }}) + out, _ := m.Update(tea.WindowSizeMsg{Width: 50, Height: 20}) + m = out.(Model) + if m.kanban == nil { + t.Fatalf("kanban not initialized") + } + + const cardOuterWidth = 18 + lines := m.renderKanbanColumnBodyLines(0, cardOuterWidth, theme.Current()) + if got, want := len(lines), 3; got != want { + t.Fatalf("no-wrap card should render as border + one text line + border, got %d lines:\n%s", + got, stripANSI(strings.Join(lines, "\n"))) + } + if plain := stripANSI(strings.Join(lines, "\n")); !strings.Contains(plain, "→") { + t.Fatalf("no-wrap card should truncate overflowing text with a scroll marker:\n%s", plain) + } + + contentWidth := cardOuterWidth - kanbanCardChromeWidth + if got, want := cardRenderHeight(m.kanban.cols[0].Cards[0], contentWidth, m.wordWrap), 3; got != want { + t.Fatalf("no-wrap card height = %d, want %d", got, want) + } +} + +func TestKanbanNoWrapInlineEditDoesNotSoftWrap(t *testing.T) { + noWrap := false + longText := "Alpha beta gamma delta epsilon zeta eta theta iota kappa lambda" + md := "```kanban\n## Todo\n- " + longText + "\n```" + m := New(Config{Title: "k", Content: md, WordWrap: &noWrap, Save: func(string) error { return nil }}) + out, _ := m.Update(tea.WindowSizeMsg{Width: 50, Height: 20}) + m = out.(Model) + + m = pressKey(m, "enter") + if !m.kanban.edit { + t.Fatalf("enter should put card in edit mode") + } + if got, want := m.kanban.editTA.VisualLineCount(), 1; got != want { + t.Fatalf("no-wrap edit textarea visual lines = %d, want %d", got, want) + } + + editView := renderEditingCardText(&m.kanban.editTA, 14, m.wordWrap) + if strings.Contains(editView, "\n") { + t.Fatalf("no-wrap edit view should stay on one visual line, got:\n%s", stripANSI(editView)) + } + if plain := stripANSI(editView); !strings.Contains(plain, "←") { + t.Fatalf("no-wrap edit view should scroll to the cursor near the line end, got %q", plain) + } +} + func TestKanbanEnteringFromBelowKeepsHeaderVisible(t *testing.T) { md := "above\n\n```kanban\n" + "## Todo\n" + diff --git a/internal/editor/render.go b/internal/editor/render.go index 1d13b50..d2a5304 100644 --- a/internal/editor/render.go +++ b/internal/editor/render.go @@ -6,19 +6,18 @@ import ( neturl "net/url" "strings" + "charm.land/bubbles/v2/textarea" + "charm.land/lipgloss/v2" "github.com/alecthomas/chroma/v2" "github.com/alecthomas/chroma/v2/formatters" "github.com/alecthomas/chroma/v2/lexers" "github.com/alecthomas/chroma/v2/styles" - "charm.land/bubbles/v2/textarea" - "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" "github.com/oobagi/notebook-cli/internal/block" "github.com/oobagi/notebook-cli/internal/format" "github.com/oobagi/notebook-cli/internal/theme" ) - // calloutVariantColor returns the hex color for a given callout variant. func calloutVariantColor(cs theme.CalloutStyle, v block.CalloutVariant) string { switch v { @@ -523,7 +522,6 @@ func linkStyles() (icon, title, host lipgloss.Style) { const linkIcon = "↗ " const linkSep = " " - func renderLinkCard(content string, width int, hovered, wordWrap bool) string { iconStyle, titleStyle, hostStyle := linkStyles() if hovered { @@ -1044,7 +1042,7 @@ func renderInactiveBlock(b block.Block, content string, width int, wordWrap bool if boardWidth < 30 { boardWidth = 30 } - rendered = renderInactiveKanbanBoard(content, boardWidth, 0, th) + rendered = renderInactiveKanbanBoard(content, boardWidth, 0, th, wordWrap) default: rendered = wrapped @@ -1263,7 +1261,7 @@ func renderViewBlock(b block.Block, content string, width int, wordWrap bool, bl rendered = renderTableGrid(content, contentWidth, th.Border, th.Blocks.Table.HeaderBold, true) case block.Kanban: - rendered = renderInactiveKanbanBoard(content, contentWidth, kanbanOffset, th) + rendered = renderInactiveKanbanBoard(content, contentWidth, kanbanOffset, th, wordWrap) default: rendered = wrapped