From 6df6f3674d7420c3358ed8360b0424fe88f03574 Mon Sep 17 00:00:00 2001 From: igaboo Date: Mon, 25 May 2026 13:27:55 -0700 Subject: [PATCH] fix: preserve kanban position on re-entry Keep the active kanban column and card selection when leaving and re-entering a board, and render top/bottom edge labels inline with the card borders instead of using the old camera-padding behavior. Tests: - go test ./internal/editor - env HOME=/var/folders/2z/hhv30x310gb6fv8jsq0s7x400000gn/T/tmp.D4P5YTtUle go test ./... - go vet ./... - go build -o /tmp/notebook-ship-build ./cmd/notebook --- internal/editor/editor.go | 85 ++++++++------- internal/editor/kanban.go | 185 ++++++++++++++++++++------------- internal/editor/kanban_test.go | 160 ++++++++++++++++++++-------- internal/editor/render.go | 12 ++- internal/editor/undo.go | 4 +- 5 files changed, 291 insertions(+), 155 deletions(-) diff --git a/internal/editor/editor.go b/internal/editor/editor.go index 60acf5b..af8f2bc 100644 --- a/internal/editor/editor.go +++ b/internal/editor/editor.go @@ -134,6 +134,7 @@ type Model struct { kanban *kanbanState // active kanban board state (non-nil when editing a Kanban block) viewKanbanOffset int // horizontal scroll for kanban blocks in view mode kanbanOffsets map[int]int // saved colOffset per block index, restored when refocusing + kanbanPositions map[int]kanbanPosition kanbanAnchorTop bool // one-shot: next viewport update anchors to kanban title row defPreview defPreviewState // focused preview for a single cross-note definition jumpTarget string // "notebook/note" to open after editor quits; read via JumpTarget() @@ -376,6 +377,7 @@ func New(cfg Config) Model { cascadeChecks: config.BoolVal(cfg.CascadeChecks, true), kanbanSortByPrio: config.BoolVal(cfg.KanbanSortByPrio, false), kanbanOffsets: map[int]int{}, + kanbanPositions: map[int]kanbanPosition{}, } // If first block is a Table, init table state. @@ -696,15 +698,12 @@ func (m *Model) focusBlock(idx int) { m.table = nil } // Leaving a kanban: serialize state back to block content and remember - // the horizontal scroll offset for when we focus this block again. + // the position for when we focus this block again. if m.kanban != nil && m.active >= 0 && m.active < len(m.textareas) { if m.kanban.edit { m.kanban.commitEdit() } - if m.kanbanOffsets == nil { - m.kanbanOffsets = map[int]int{} - } - m.kanbanOffsets[m.active] = m.kanban.colOffset + m.saveKanbanPosition(m.active) serialized := m.kanban.serialize() m.blocks[m.active].Content = serialized m.textareas[m.active].SetValue(serialized) @@ -726,12 +725,10 @@ func (m *Model) focusBlock(idx int) { m.cursorCmd = m.textareas[idx].Focus() } // Entering a kanban: init kanban state from block content and restore - // the saved horizontal scroll offset (if any). + // the saved position (if any). if m.blocks[idx].Type == block.Kanban { m.kanban = newKanbanState(m.blocks[idx].Content) - if m.kanbanOffsets != nil { - m.kanban.colOffset = m.kanbanOffsets[idx] - } + m.restoreKanbanPosition(idx) if m.kanbanSortByPrio { m.kanban.sortByPriority() } @@ -762,11 +759,12 @@ func (m *Model) navigateUp() { m.cursorCmd = ta.Focus() return } - // Entering a kanban from below: land on the last card of the - // leftmost non-empty column. Mirrors table behavior, which always - // enters at column 0 regardless of direction. + // First entry from below lands at the bottom; re-entry restores the + // column/card the user left. if m.kanban != nil { - m.kanban.enterFromBelow() + if !m.hasSavedKanbanPosition(m.active) { + m.kanban.enterFromBelow() + } m.kanbanAnchorTop = true return } @@ -797,10 +795,12 @@ func (m *Model) navigateDown() { m.cursorCmd = ta.Focus() return } - // Entering a kanban from above: land on the first card of the - // leftmost non-empty column. Symmetric with navigateUp's entry. + // First entry from above lands at the top; re-entry restores the + // column/card the user left. if m.kanban != nil { - m.kanban.enterFromAbove() + if !m.hasSavedKanbanPosition(m.active) { + m.kanban.enterFromAbove() + } return } @@ -848,9 +848,9 @@ func (m *Model) insertBlockBefore(idx int, b block.Block) { m.active++ } - // Shift kanbanOffsets keys >= idx forward by one so saved horizontal - // scroll positions stay attached to the right Kanban block. - m.shiftKanbanOffsets(idx, +1) + // Shift saved Kanban positions forward so they stay attached to the + // right block. + m.shiftKanbanState(idx, +1) m.focusBlock(idx) } @@ -877,9 +877,9 @@ func (m *Model) insertBlockAfter(idx int, b block.Block) { newTAs = append(newTAs, m.textareas[insertAt+1:]...) m.textareas = newTAs - // Shift kanbanOffsets keys > insertAt forward by one so saved - // horizontal scroll positions stay attached to the right Kanban. - m.shiftKanbanOffsets(insertAt+1, +1) + // Shift saved Kanban positions forward so they stay attached to the + // right block. + m.shiftKanbanState(insertAt+1, +1) m.focusBlock(insertAt + 1) } @@ -904,7 +904,10 @@ func (m *Model) deleteBlock(idx int) { if m.kanbanOffsets != nil { delete(m.kanbanOffsets, idx) } - m.shiftKanbanOffsets(idx+1, -1) + if m.kanbanPositions != nil { + delete(m.kanbanPositions, idx) + } + m.shiftKanbanState(idx+1, -1) // Adjust active index. if idx >= len(m.blocks) { @@ -922,23 +925,37 @@ func (m *Model) deleteBlock(idx int) { m.initActiveContainerState() } -// shiftKanbanOffsets remaps kanbanOffsets keys at or above `from` by `delta`. +// shiftKanbanState remaps saved Kanban keys at or above `from` by `delta`. // Used by insertBlockBefore/After (delta=+1) and deleteBlock (delta=-1) so -// that per-block horizontal scroll positions stay attached to the right +// that per-block scroll/selection positions stay attached to the right // Kanban as block indexes shift around them. -func (m *Model) shiftKanbanOffsets(from, delta int) { - if len(m.kanbanOffsets) == 0 || delta == 0 { +func (m *Model) shiftKanbanState(from, delta int) { + if delta == 0 { + return + } + if len(m.kanbanOffsets) > 0 { + next := make(map[int]int, len(m.kanbanOffsets)) + for k, v := range m.kanbanOffsets { + if k >= from { + next[k+delta] = v + } else { + next[k] = v + } + } + m.kanbanOffsets = next + } + if len(m.kanbanPositions) == 0 { return } - next := make(map[int]int, len(m.kanbanOffsets)) - for k, v := range m.kanbanOffsets { + nextPositions := make(map[int]kanbanPosition, len(m.kanbanPositions)) + for k, v := range m.kanbanPositions { if k >= from { - next[k+delta] = v + nextPositions[k+delta] = v } else { - next[k] = v + nextPositions[k] = v } } - m.kanbanOffsets = next + m.kanbanPositions = nextPositions } // initActiveContainerState initializes table/kanban state when the active @@ -958,9 +975,7 @@ func (m *Model) initActiveContainerState() { } if bt == block.Kanban && m.kanban == nil { m.kanban = newKanbanState(m.blocks[m.active].Content) - if m.kanbanOffsets != nil { - m.kanban.colOffset = m.kanbanOffsets[m.active] - } + m.restoreKanbanPosition(m.active) if m.kanbanSortByPrio { m.kanban.sortByPriority() } diff --git a/internal/editor/kanban.go b/internal/editor/kanban.go index b71bb1c..040b094 100644 --- a/internal/editor/kanban.go +++ b/internal/editor/kanban.go @@ -86,6 +86,11 @@ type kanbanState struct { addedCardIdx int } +type kanbanPosition struct { + col int + card int +} + // kanbanTargetColWidth is the preferred visible width of a single column // (including borders/padding of its cards). The renderer uses this to // decide how many columns fit on screen at once; the rest stay off-screen @@ -96,8 +101,6 @@ const kanbanTargetColWidth = 32 // indicators on the left/right edges of the board (one cell each). const kanbanIndicatorWidth = 1 -const kanbanColumnCameraPadding = 3 - const kanbanDocumentContextLines = 4 const kanbanMinCameraBoardHeight = 11 @@ -128,7 +131,7 @@ func (ks *kanbanState) ensureRowOffsets() { ks.rowOffsets = next } -func (ks *kanbanState) clampRowOffset(col, bodyLineCount, bodyHeight, bottomOverscroll int) { +func (ks *kanbanState) clampRowOffset(col, bodyLineCount, bodyHeight int) { ks.ensureRowOffsets() if col < 0 || col >= len(ks.rowOffsets) { return @@ -137,9 +140,6 @@ func (ks *kanbanState) clampRowOffset(col, bodyLineCount, bodyHeight, bottomOver if maxOffset < 0 { maxOffset = 0 } - if bodyLineCount > bodyHeight { - maxOffset += bottomOverscroll - } if ks.rowOffsets[col] < 0 { ks.rowOffsets[col] = 0 } @@ -148,31 +148,16 @@ func (ks *kanbanState) clampRowOffset(col, bodyLineCount, bodyHeight, bottomOver } } -func kanbanCameraMargin(selectedHeight, bodyHeight int) int { - if selectedHeight < 1 { - selectedHeight = 1 - } - margin := kanbanColumnCameraPadding - if maxMargin := (bodyHeight - selectedHeight) / 2; margin > maxMargin { - margin = maxMargin - } - if margin < 0 { - margin = 0 - } - return margin -} - func (ks *kanbanState) ensureSelectedCardVisible(top, bottom, bodyHeight int) { ks.ensureRowOffsets() if ks.col < 0 || ks.col >= len(ks.rowOffsets) || bodyHeight <= 0 { return } - margin := kanbanCameraMargin(bottom-top+1, bodyHeight) offset := ks.rowOffsets[ks.col] - if top-margin < offset { - offset = top - margin - } else if bottom+margin >= offset+bodyHeight { - offset = bottom + margin - bodyHeight + 1 + if top < offset { + offset = top + } else if bottom >= offset+bodyHeight { + offset = bottom - bodyHeight + 1 } if offset < 0 { offset = 0 @@ -252,6 +237,44 @@ func (ks *kanbanState) clamp() { } } +func (m *Model) saveKanbanPosition(idx int) { + if m.kanban == nil { + return + } + if m.kanbanOffsets == nil { + m.kanbanOffsets = map[int]int{} + } + if m.kanbanPositions == nil { + m.kanbanPositions = map[int]kanbanPosition{} + } + m.kanbanOffsets[idx] = m.kanban.colOffset + m.kanbanPositions[idx] = kanbanPosition{col: m.kanban.col, card: m.kanban.card} +} + +func (m *Model) hasSavedKanbanPosition(idx int) bool { + if m.kanbanPositions == nil { + return false + } + _, ok := m.kanbanPositions[idx] + return ok +} + +func (m *Model) restoreKanbanPosition(idx int) { + if m.kanban == nil { + return + } + if m.kanbanOffsets != nil { + m.kanban.colOffset = m.kanbanOffsets[idx] + } + if m.kanbanPositions != nil { + if pos, ok := m.kanbanPositions[idx]; ok { + m.kanban.col = pos.col + m.kanban.card = pos.card + m.kanban.clamp() + } + } +} + // clampColOffset shifts the horizontal viewport so the selected column is // visible within a window of `visible` columns. When the selected column // is already in view, colOffset is unchanged (no jitter). @@ -642,6 +665,10 @@ const kanbanCardChromeWidth = 4 // 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, wordWrap bool) string { + return renderKanbanCardWithLabels(card, outerWidth, selected, editing, editView, th, wordWrap, "", "") +} + +func renderKanbanCardWithLabels(card block.KanbanCard, outerWidth int, selected, editing bool, editView string, th theme.Theme, wordWrap bool, topLabel, bottomLabel string) string { border := lipgloss.RoundedBorder() borderColor := th.Border if selected { @@ -703,7 +730,46 @@ func renderKanbanCard(card block.KanbanCard, outerWidth int, selected, editing b header := strings.Join(headerParts, " ") body = header + "\n" + text } - return style.Render(body) + rendered := style.Render(body) + if topLabel == "" && bottomLabel == "" { + return rendered + } + lines := strings.Split(rendered, "\n") + if topLabel != "" && len(lines) > 0 { + lines[0] = renderKanbanBorderLabel(topLabel, outerWidth, true, borderColor, th) + } + if bottomLabel != "" && len(lines) > 0 { + lines[len(lines)-1] = renderKanbanBorderLabel(bottomLabel, outerWidth, false, borderColor, th) + } + return strings.Join(lines, "\n") +} + +func renderKanbanBorderLabel(label string, width int, top bool, borderColor string, th theme.Theme) string { + if width < 2 { + width = 2 + } + innerWidth := width - 2 + label = " " + label + " " + labelWidth := lipgloss.Width(label) + if labelWidth > innerWidth { + label = strings.TrimSpace(label) + labelWidth = lipgloss.Width(label) + } + if labelWidth > innerWidth { + label = "" + labelWidth = 0 + } + left := (innerWidth - labelWidth) / 2 + right := innerWidth - labelWidth - left + leftCorner, rightCorner := "╭", "╮" + if !top { + leftCorner, rightCorner = "╰", "╯" + } + borderStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(borderColor)) + labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(th.Muted)).Faint(true) + return borderStyle.Render(leftCorner+strings.Repeat("─", left)) + + labelStyle.Render(label) + + borderStyle.Render(strings.Repeat("─", right)+rightCorner) } // wrapPlain word-wraps text to the given visual width. Empty preserves @@ -796,51 +862,16 @@ func (m Model) selectedCardBodyLineRange(colWidth int) (top, bottom int) { return top, top + height - 1 } -func (m Model) kanbanCardStartLines(ci, colWidth int) []int { - if m.kanban == nil || ci < 0 || ci >= len(m.kanban.cols) { - return nil - } - contentWidth := colWidth - kanbanCardChromeWidth - if contentWidth < 1 { - contentWidth = 1 - } - starts := make([]int, 0, len(m.kanban.cols[ci].Cards)) - line := 0 - for cardI, card := range m.kanban.cols[ci].Cards { - starts = append(starts, line) - line += m.kanbanCardRenderHeight(card, ci, cardI, contentWidth) - } - return starts -} - -func (m Model) adjustKanbanBottomCameraOffset(ci, colWidth, bodyLineCount, bodyHeight int) int { - offset := m.kanban.rowOffsets[ci] - maxRealOffset := bodyLineCount - bodyHeight - if maxRealOffset < 0 || offset <= maxRealOffset || m.kanban.col != ci { - return offset - } - top, bottom := m.selectedCardBodyLineRange(colWidth) - best := offset - for _, start := range m.kanbanCardStartLines(ci, colWidth) { - if start < offset || start > top || start+bodyHeight <= bottom { - continue - } - if best == offset || start < best { - best = start - } - } - return best -} - func (m Model) renderKanbanColumnBodyLines(ci, cardOuterWidth int, th theme.Theme) []string { if m.kanban == nil || ci < 0 || ci >= len(m.kanban.cols) { return nil } col := m.kanban.cols[ci] + colSelected := m.kanban.col == ci if len(col.Cards) == 0 { placeholderColor := th.Muted placeholderText := "no cards · n to add" - if m.kanban.col == ci { + if colSelected { placeholderColor = th.Accent } placeholder := lipgloss.NewStyle(). @@ -853,13 +884,21 @@ func (m Model) renderKanbanColumnBodyLines(ci, cardOuterWidth int, th theme.Them var lines []string for cardI, card := range col.Cards { - isSel := m.kanban.col == ci && m.kanban.card == cardI + isSel := colSelected && m.kanban.card == cardI editing := isSel && m.kanban.edit editView := "" if editing { editView = renderEditingCardText(&m.kanban.editTA, cardOuterWidth-kanbanCardChromeWidth, m.wordWrap) } - lines = append(lines, strings.Split(renderKanbanCard(card, cardOuterWidth, isSel, editing, editView, th, m.wordWrap), "\n")...) + topLabel, bottomLabel := "", "" + if colSelected && cardI == 0 { + topLabel = "top" + } + if colSelected && cardI == len(col.Cards)-1 { + bottomLabel = "bottom" + } + renderedCard := renderKanbanCardWithLabels(card, cardOuterWidth, isSel, editing, editView, th, m.wordWrap, topLabel, bottomLabel) + lines = append(lines, strings.Split(renderedCard, "\n")...) } return lines } @@ -924,6 +963,12 @@ func (m Model) renderKanbanBoard(blockIdx, width int) string { bodyHeight = 1 } selTop, selBottom := m.selectedCardBodyLineRange(colWidth) + if m.kanban.card == 0 { + selTop = 0 + } + if m.kanban.col >= 0 && m.kanban.col < len(cols) && m.kanban.card == len(cols[m.kanban.col].Cards)-1 { + selBottom = m.kanbanColumnBodyLineCount(m.kanban.col, colWidth) - 1 + } m.kanban.ensureSelectedCardVisible(selTop, selBottom, bodyHeight) colStyle := lipgloss.NewStyle().Width(colWidth) @@ -961,14 +1006,8 @@ func (m Model) renderKanbanBoard(blockIdx, width int) string { cardLines = append(cardLines, under) bodyLines := m.renderKanbanColumnBodyLines(ci, cardOuterWidth, th) - bottomOverscroll := 0 - if colSelected { - selTop, selBottom := m.selectedCardBodyLineRange(colWidth) - bottomOverscroll = kanbanCameraMargin(selBottom-selTop+1, bodyHeight) - } - m.kanban.clampRowOffset(ci, len(bodyLines), bodyHeight, bottomOverscroll) - offset := m.adjustKanbanBottomCameraOffset(ci, colWidth, len(bodyLines), bodyHeight) - m.kanban.rowOffsets[ci] = offset + m.kanban.clampRowOffset(ci, len(bodyLines), bodyHeight) + offset := m.kanban.rowOffsets[ci] end := offset + bodyHeight for row := offset; row < end; row++ { if row >= 0 && row < len(bodyLines) { diff --git a/internal/editor/kanban_test.go b/internal/editor/kanban_test.go index 3614c92..331e1db 100644 --- a/internal/editor/kanban_test.go +++ b/internal/editor/kanban_test.go @@ -450,7 +450,7 @@ func TestKanbanTallColumnScrollsInsideBoard(t *testing.T) { } } -func TestKanbanTallColumnCameraPadsSelectedCard(t *testing.T) { +func TestKanbanTallColumnLabelsCardBordersAtEdges(t *testing.T) { md := "```kanban\n" + "## Todo\n" + "- Task 01\n" + @@ -481,56 +481,43 @@ func TestKanbanTallColumnCameraPadsSelectedCard(t *testing.T) { if len(bodyLines) == 0 { t.Fatalf("expected body lines") } - if bodyLines[0] == "" { - t.Fatalf("top edge should be real card content, not a blank buffer line") + if got := stripANSI(bodyLines[0]); !strings.Contains(got, "top") { + t.Fatalf("first card top border should include the top label, got %q", got) } - if bodyLines[len(bodyLines)-1] == "" { - t.Fatalf("bottom edge should be real card content, not a blank buffer line") + if got := stripANSI(bodyLines[len(bodyLines)-1]); !strings.Contains(got, "bottom") { + t.Fatalf("last card bottom border should include the bottom label, got %q", got) + } + if got := stripANSI(bodyLines[0]); !strings.Contains(got, "── top ──") { + t.Fatalf("top label should be centered inline with the border, got %q", got) + } + if got := stripANSI(bodyLines[len(bodyLines)-1]); !strings.Contains(got, " bottom ") { + t.Fatalf("bottom label should be inline with the border, got %q", got) } + // In the middle of the list, neither edge label is visible because the + // labels scroll with the column content instead of sticking to the frame. m.kanban.card = 4 m.renderKanbanBoard(0, width) - top, bottom := m.selectedCardBodyLineRange(colWidth) - visibleTop := top - m.kanban.rowOffsets[0] - visibleBottom := bottom - m.kanban.rowOffsets[0] - selectedHeight := bottom - top + 1 - wantPadding := min(kanbanColumnCameraPadding, (bodyHeight-selectedHeight)/2) - if wantPadding < 0 { - wantPadding = 0 + offset := m.kanban.rowOffsets[0] + midVisible := strings.Join(bodyLines[offset:min(offset+bodyHeight, len(bodyLines))], "\n") + if strings.Contains(stripANSI(midVisible), "top") || strings.Contains(stripANSI(midVisible), "bottom") { + t.Fatalf("edge labels should not be sticky in the middle; visible:\n%s", stripANSI(midVisible)) } - if visibleTop < wantPadding { - t.Fatalf("selected card should have camera padding above: visibleTop=%d padding=%d offset=%d", - visibleTop, wantPadding, m.kanban.rowOffsets[0]) - } - if below := bodyHeight - 1 - visibleBottom; below < wantPadding { - t.Fatalf("selected card should have camera padding below: below=%d padding=%d offset=%d", - below, wantPadding, m.kanban.rowOffsets[0]) + + m.kanban.card = 0 + m.renderKanbanBoard(0, width) + offset = m.kanban.rowOffsets[0] + topVisible := strings.Join(bodyLines[offset:min(offset+bodyHeight, len(bodyLines))], "\n") + if !strings.Contains(stripANSI(topVisible), "top") { + t.Fatalf("top label should be visible only at the top edge; visible:\n%s", stripANSI(topVisible)) } m.kanban.card = len(m.kanban.cols[0].Cards) - 1 m.renderKanbanBoard(0, width) - top, bottom = m.selectedCardBodyLineRange(colWidth) - visibleBottom = bottom - m.kanban.rowOffsets[0] - selectedHeight = bottom - top + 1 - wantPadding = min(kanbanColumnCameraPadding, (bodyHeight-selectedHeight)/2) - if wantPadding < 0 { - wantPadding = 0 - } - if below := bodyHeight - 1 - visibleBottom; below < wantPadding { - t.Fatalf("bottom card should retain camera padding below: below=%d padding=%d offset=%d", - below, wantPadding, m.kanban.rowOffsets[0]) - } - starts := m.kanbanCardStartLines(0, colWidth) - aligned := false - for _, start := range starts { - if start == m.kanban.rowOffsets[0] { - aligned = true - break - } - } - if !aligned { - t.Fatalf("bottom camera offset should align to a card boundary, got %d starts=%v", - m.kanban.rowOffsets[0], starts) + offset = m.kanban.rowOffsets[0] + bottomVisible := strings.Join(bodyLines[offset:min(offset+bodyHeight, len(bodyLines))], "\n") + if !strings.Contains(stripANSI(bottomVisible), "bottom") { + t.Fatalf("bottom label should be visible only at the bottom edge; visible:\n%s", stripANSI(bottomVisible)) } } @@ -838,7 +825,7 @@ func TestKanbanUpAtTopFirstBlockInsertsCleanParagraph(t *testing.T) { } func TestKanbanEntrySymmetric(t *testing.T) { - // Both entries should land in the leftmost non-empty column, + // First-time entries land in the leftmost non-empty column; // only the card position differs (top → first, bottom → last). md := "para above\n\n" + sampleKanbanMD + "\n\npara below" m := New(Config{Title: "k", Content: md, Save: func(string) error { return nil }}) @@ -860,6 +847,11 @@ func TestKanbanEntrySymmetric(t *testing.T) { t.Errorf("entry from above card = %d, want 0", cardTop) } + m = New(Config{Title: "k", Content: md, Save: func(string) error { return nil }}) + out, _ = m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + m = out.(Model) + idx = firstKanban(m) + // Enter from below: focus paragraph below, navigateUp. m.focusBlock(idx + 1) m.navigateUp() @@ -876,6 +868,49 @@ func TestKanbanEntrySymmetric(t *testing.T) { } } +func TestKanbanReEntryRestoresExitedSelection(t *testing.T) { + mdLines := []string{"above", "", "```kanban"} + for _, title := range []string{"Column 01", "Column 02", "Column 03", "Column 04", "Column 05", "Column 06"} { + mdLines = append(mdLines, "## "+title, "- first", "- second") + } + mdLines = append(mdLines, "```", "", "below") + md := strings.Join(mdLines, "\n") + + m := New(Config{Title: "k", Content: md, Save: func(string) error { return nil }}) + out, _ := m.Update(tea.WindowSizeMsg{Width: 70, Height: 30}) + m = out.(Model) + idx := firstKanban(m) + if idx < 1 { + t.Fatalf("expected kanban after leading paragraph, idx=%d", idx) + } + m.focusBlock(idx) + if m.kanban == nil { + t.Fatalf("kanban not initialized") + } + + for i := 0; i < 5; i++ { + m = pressKey(m, "right") + } + m = pressKey(m, "down") + m = pressKey(m, "down") + leftCol, leftCard := m.kanbanPositions[idx].col, m.kanbanPositions[idx].card + if leftCol != 5 || leftCard != 1 { + t.Fatalf("saved position = %d/%d, want 5/1", leftCol, leftCard) + } + + m = pressKey(m, "up") + if m.kanban == nil { + t.Fatalf("kanban should be active after re-entry") + } + if m.kanban.col != leftCol || m.kanban.card != leftCard { + t.Fatalf("re-entry selection = %d/%d, want %d/%d", m.kanban.col, m.kanban.card, leftCol, leftCard) + } + _ = m.renderBlock(idx) + if m.kanban.colOffset == 0 { + t.Fatalf("re-entry should keep the horizontal window near the saved column") + } +} + func TestKanbanDeletingBlockBelowKeepsBoardRendered(t *testing.T) { // Repro: kanban followed by an empty paragraph. Focus paragraph, // press backspace to delete it. After deletion, active is the kanban @@ -1049,6 +1084,47 @@ func TestKanbanHorizontalScrollFollowsFocus(t *testing.T) { } } +func TestKanbanInactiveRenderKeepsExitedColumnWindow(t *testing.T) { + mdLines := []string{"```kanban"} + for _, title := range []string{"Column 01", "Column 02", "Column 03", "Column 04", "Column 05", "Column 06"} { + mdLines = append(mdLines, "## "+title, "- card") + } + mdLines = append(mdLines, "```", "", "below") + md := strings.Join(mdLines, "\n") + + m := New(Config{Title: "k", Content: md}) + out, _ := m.Update(tea.WindowSizeMsg{Width: 70, Height: 30}) + m = out.(Model) + idx := firstKanban(m) + if idx < 0 || m.kanban == nil { + t.Fatalf("kanban not initialized") + } + + for i := 0; i < 5; i++ { + m = pressKey(m, "right") + } + _ = m.renderBlock(idx) + if m.kanban.colOffset == 0 { + t.Fatalf("expected horizontal offset before leaving kanban") + } + + m = pressKey(m, "down") + if m.active != idx+1 { + t.Fatalf("active = %d, want paragraph below at %d", m.active, idx+1) + } + if m.kanban != nil { + t.Fatalf("kanban state should be inactive after leaving") + } + + rendered := stripANSI(m.renderBlock(idx)) + if !strings.Contains(rendered, "Column 06") { + t.Fatalf("inactive kanban should stay on exited column window; rendered:\n%s", rendered) + } + if strings.Contains(rendered, "Column 01") { + t.Fatalf("inactive kanban jumped back to the left edge; rendered:\n%s", rendered) + } +} + func TestKanbanSortByPriority(t *testing.T) { md := "```kanban\n## Todo\n- !! mid one\n- urgent later\n- !!! urgent first\n- ! low\n```" m := New(Config{Title: "k", Content: md}) diff --git a/internal/editor/render.go b/internal/editor/render.go index d2a5304..2566350 100644 --- a/internal/editor/render.go +++ b/internal/editor/render.go @@ -146,7 +146,11 @@ func (m Model) renderBlock(idx int) string { if isActive { return m.renderActiveBlock(idx, b, content) } - return renderInactiveBlock(b, content, m.width, m.wordWrap, m.blocks, idx) + kanbanOffset := 0 + if b.Type == block.Kanban && m.kanbanOffsets != nil { + kanbanOffset = m.kanbanOffsets[idx] + } + return renderInactiveBlockWithKanbanOffset(b, content, m.width, m.wordWrap, m.blocks, idx, kanbanOffset) } // renderActiveBlock renders the block that currently has focus. @@ -837,6 +841,10 @@ func highlightCode(code, language string) string { // renderInactiveBlock renders a block as styled static text (no cursor). func renderInactiveBlock(b block.Block, content string, width int, wordWrap bool, blocks []block.Block, idx int) string { + return renderInactiveBlockWithKanbanOffset(b, content, width, wordWrap, blocks, idx, 0) +} + +func renderInactiveBlockWithKanbanOffset(b block.Block, content string, width int, wordWrap bool, blocks []block.Block, idx, kanbanOffset int) string { // Compute the available content width, matching the active block's calculation. contentWidth := width - gutterWidth - blockPrefixWidth(b) if contentWidth < 1 { @@ -1042,7 +1050,7 @@ func renderInactiveBlock(b block.Block, content string, width int, wordWrap bool if boardWidth < 30 { boardWidth = 30 } - rendered = renderInactiveKanbanBoard(content, boardWidth, 0, th, wordWrap) + rendered = renderInactiveKanbanBoard(content, boardWidth, kanbanOffset, th, wordWrap) default: rendered = wrapped diff --git a/internal/editor/undo.go b/internal/editor/undo.go index c217f68..df7085f 100644 --- a/internal/editor/undo.go +++ b/internal/editor/undo.go @@ -122,9 +122,7 @@ func (m *Model) restoreState(state editorState) { // If active block is a Kanban, init kanban state. if active < len(m.blocks) && m.blocks[active].Type == block.Kanban { m.kanban = newKanbanState(m.blocks[active].Content) - if m.kanbanOffsets != nil { - m.kanban.colOffset = m.kanbanOffsets[active] - } + m.restoreKanbanPosition(active) if m.kanbanSortByPrio { m.kanban.sortByPriority() }