Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions context/knowledge/gotchas/dag-rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,5 @@ The `dagview` widget (`internal/tui/dagview/`) renders a layered top-down graph.
- **planview has a HORIZONTAL viewport that ensure-visibles the cursor (BUG-010), the twin of the existing vertical `scrollOffsetFor`.** When the WIDEST stage block overflows the diagram width, `drawStages` left-aligns every block at `inner.X` and shifts it left by `w.xOffset`, which `ensureCursorVisibleX` recomputes each Draw so the cursor's selected box (border included) is fully within the region — minimal-scroll from the prior offset, with a 1-col `edgeGutter` reserved at each edge. When everything fits, `xOffset==0` and each stage stays CENTERED (unchanged; `SetData` resets `xOffset`). The box positions are REUSED from the dagview-derived layout — the selected box's `(selRelX, selW)` is threaded out of `buildStageBlock` (and `layoutFannedGroup` returns the selected MEMBER's geometry); never relayout-to-fit / wrap / shrink. Off-screen siblings are signalled by dim `‹`/`›` indicators (`edgeMoreLeft`/`edgeMoreRight`) drawn at the pane edge on each overflowing stage's middle row; the gutter keeps them off the selected box. Painting stays clipped to the `clipRect` (no `screen.Sync`, full-rect coverage). `xOffset` is NOT folded into `branchShape` — the cursor already is, and cursor change is what drives the scroll.
- **`OnBranchChange` must be wired to `forceRedraw` (log-only, never `Sync`).** The snapshot install, focus flip, cursor move, group fan-out, and drill-in each shift the cell set in the same rect — tcell's per-cell diff leaves ghosts otherwise. Stale cells are prevented by `tview.Clear()` + the widget's full-rect `DrawBorderedPanel`/`FillArea`. See `gotchas/ui-threading.md`. `planview.branchShape` folds the (stage, slot, member) cursor, fanned-group state, and the current-orchestrator title hash into one uint64 so back-to-back `SetData` with the same shape doesn't spam `forceRedraw`.

- **A fanned group's member boxes WRAP onto multiple rows to fit the pane width (BUG-011 fan-wrap) — never a single overflowing row.** `layoutFannedGroup` takes the diagram inner width (threaded through `buildStageBlock(s, availW)`) and packs member boxes left-to-right into rows bounded by `availW - labelCol - 2*groupPad - 2` (clamped ≥1); a member box wider than that budget still occupies its own row. The enclosure height grows to hold the wrapped rows (`boxH = sum(rowHeights) + 2`) and `←→` walks the member index linearly across rows (stepping off a row's end lands on the next row's first member — the cursor model is unchanged, only the visual position wraps). The inter-stage `│` connector re-anchors automatically because `drawStages` uses the block's (now taller) `height`/`width` for `y += b.height` and `ec = bx + b.width/2`. Wrapping removes horizontal overflow in the common case, so BUG-010's `‹`/`›` viewport only fires for a lone over-wide member box; `selMemRelX/selMemW` (the selected member's X within the enclosure) is still returned for that path. Distinct from the older in-code `(BUG-011)` annotations (collapsed-group count icons) — a different bug number.
- **Enter (`ActivateCursor`) and Space (`ToggleCursorFan`) are SPLIT in planview (BUG-013 + follow-up).** Enter on a fanned-group MEMBER (`cursor.Member >= 0`) navigates — fires the member's leaf action via `CurrentNodeID()` (`OnEnter`, or `OnDrillIn` when Drillable), exactly like a lone leaf — and does NOT collapse. `ActivateCursor` delegates the non-member group cases (collapsed → fan out; fanned enclosure `Member < 0` → collapse) to `ToggleCursorFan`. **Space is a PURE toggle: never navigates** — it fans out a collapsed group and collapses a fanned one regardless of member, and is a no-op on a lone-node slot (opening a leaf is Enter-only). Collapsing a fanned group is Space's or `Esc`'s (`EscBack`) job, never Enter-on-member.
132 changes: 95 additions & 37 deletions internal/tui/planview/planview.go
Original file line number Diff line number Diff line change
Expand Up @@ -1550,7 +1550,7 @@ func (w *Widget) drawStages(screen tcell.Screen, inner widget.InnerRect) {

blocks := make([]stageBlock, len(w.stages))
for s := range w.stages {
blocks[s] = w.buildStageBlock(s)
blocks[s] = w.buildStageBlock(s, inner.W)
}

// Total block height: each stage's height plus a 1-row connector between.
Expand Down Expand Up @@ -1712,7 +1712,9 @@ func (w *Widget) scrollOffsetFor(blocks []stageBlock, regionH int) int {
// (lone nodes as rounded boxes, collapsed groups as dashed boxes, fanned groups
// as a dashed enclosure wrapping the member node-boxes). The block's width is the
// sum of sibling widths + boxGap between them; its height is the tallest sibling.
func (w *Widget) buildStageBlock(s int) stageBlock {
// availW is the diagram's inner width, used so a fanned group wraps its member
// boxes onto multiple rows to fit the pane instead of overflowing (BUG-011).
func (w *Widget) buildStageBlock(s int, availW int) stageBlock {
type sib struct {
width, height int
draw func(screen tcell.Screen, x, y int, clip clipRect)
Expand All @@ -1727,7 +1729,7 @@ func (w *Widget) buildStageBlock(s int) stageBlock {
onSlot := isCursorStage && w.cursor.Slot == slotIdx
switch {
case sl.group != nil && w.Fanned(s, slotIdx):
bw, bh, draw, memRelX, memW := w.layoutFannedGroup(s, slotIdx, sl.group)
bw, bh, draw, memRelX, memW := w.layoutFannedGroup(s, slotIdx, sl.group, availW)
sb := sib{width: bw, height: bh, draw: draw}
if onSlot {
if memW > 0 { // a member is selected inside the fanned group
Expand Down Expand Up @@ -1860,22 +1862,27 @@ func (w *Widget) layoutDashedBox(label string, sub []countSeg, topLabel string,
}

// layoutFannedGroup lays a fanned group out as a SOLID rounded enclosure wrapping
// the member node-boxes laid out horizontally inside (BUG-005, matching the
// design). The enclosure carries the group's role label VERTICALLY down its left
// inner edge (one rune per row, dim) and a ▲ collapse affordance at the top-right;
// each member that feeds downstream (g.FeedingMembers) gets a ↘ on its box. The
// cursor's member box gets the selection fill; the enclosure itself fills only
// when the cursor rests on the group slot with no member selected.
// the member node-boxes inside (BUG-005, matching the design). The member boxes
// are packed left-to-right and WRAPPED onto multiple rows so the enclosure fits
// the available diagram width (availW) instead of overflowing in one row
// (BUG-011): a new row starts whenever the next box would exceed the inner-width
// budget, and a box wider than the budget on its own still occupies its own row
// (the BUG-010 horizontal viewport then scrolls to it). The enclosure carries the
// group's role label VERTICALLY down its left inner edge (one rune per row, dim)
// and a ▲ collapse affordance at the top-right; each member that feeds downstream
// (g.FeedingMembers) gets a ↘ on its box. The cursor's member box gets the
// selection (double border); the enclosure itself is selected only when the
// cursor rests on the group slot with no member selected.
// The two trailing return values are the selected member's box x-offset
// (relative to the enclosure's left edge) and width, for the horizontal viewport
// (BUG-010); selMemW is 0 unless the cursor sits on a member of THIS group.
func (w *Widget) layoutFannedGroup(s, slotIdx int, g *Group) (int, int, func(tcell.Screen, int, int, clipRect), int, int) {
func (w *Widget) layoutFannedGroup(s, slotIdx int, g *Group, availW int) (int, int, func(tcell.Screen, int, int, clipRect), int, int) {
onSlot := w.cursor.Stage == s && w.cursor.Slot == slotIdx
type mbox struct {
width, height int
draw func(tcell.Screen, int, int, clipRect)
}
var members []mbox
members := make([]mbox, 0, len(g.Members))
for memberIdx, id := range g.Members {
glyph, style := w.nodeGlyph(id)
content := string(glyph) + " " + w.LabelOf(id)
Expand All @@ -1885,39 +1892,84 @@ func (w *Widget) layoutFannedGroup(s, slotIdx int, g *Group) (int, int, func(tce
bw, bh, d := w.layoutNodeBox(content, style, onSlot && w.cursor.Member == memberIdx)
members = append(members, mbox{bw, bh, d})
}
innerW, innerH := 0, 0
for i, m := range members {
innerW += m.width
if i > 0 {
innerW += boxGap
}
if m.height > innerH {
innerH = m.height
}
}
// Reserve one extra inner column on the left for the vertical role label when
// the group has a common token (else the label column is omitted).
vlabel := []rune(w.commonRoleToken(g.Members))
labelCol := 0
if len(vlabel) > 0 {
labelCol = 1
}
// Inner-width budget for the member rows: the available diagram width minus the
// label column, the enclosure's horizontal padding, and its two borders. At
// least 1 so a degenerate narrow pane still packs one (overflowing) box per row.
budget := availW - labelCol - 2*groupPad - 2
if budget < 1 {
budget = 1
}
// Pack member indices into rows: keep adding to the current row until the next
// box would exceed the budget, then start a new row. A row always holds at
// least one box (even if it alone exceeds the budget).
var rows [][]int
var cur []int
curW := 0
for i, m := range members {
add := m.width
if len(cur) > 0 {
add += boxGap
}
if len(cur) > 0 && curW+add > budget {
rows = append(rows, cur)
cur = nil
curW = 0
add = m.width
}
cur = append(cur, i)
curW += add
}
if len(cur) > 0 {
rows = append(rows, cur)
}
// Row geometry: each row's width is the sum of its members + gaps; its height
// the tallest member. innerW = widest row, innerH = sum of row heights.
rowHeights := make([]int, len(rows))
innerW, innerH := 0, 0
for r, row := range rows {
rw, rh := 0, 0
for j, mi := range row {
if j > 0 {
rw += boxGap
}
rw += members[mi].width
if members[mi].height > rh {
rh = members[mi].height
}
}
rowHeights[r] = rh
if rw > innerW {
innerW = rw
}
innerH += rh
}
boxW := labelCol + innerW + 2*groupPad + 2
boxH := innerH + 2 // rounded top + bottom edges
enclosureSel := onSlot && w.cursor.Member < 0
memBase := 1 + labelCol + groupPad
// Selected member geometry (relative to the enclosure box left edge), mirroring
// the member-draw loop's x math below.
// the member-draw loop's x math below. Only the X axis is threaded to the
// horizontal viewport (BUG-010); wrapping keeps the group within the width in
// the common case, so the viewport only fires for a lone over-wide member box.
selMemRelX, selMemW := 0, 0
memBase := 1 + labelCol + groupPad
mcx := memBase
for i, m := range members {
if i > 0 {
mcx += boxGap
}
if onSlot && w.cursor.Member == i {
selMemRelX, selMemW = mcx, m.width
for _, row := range rows {
mcx := memBase
for j, mi := range row {
if j > 0 {
mcx += boxGap
}
if onSlot && w.cursor.Member == mi {
selMemRelX, selMemW = mcx, members[mi].width
}
mcx += members[mi].width
}
mcx += m.width
}
border := w.enclosureBorderStyle(enclosureSel)
labelStyle := theme.StyleDimmed
Expand All @@ -1939,13 +1991,19 @@ func (w *Widget) layoutFannedGroup(s, slotIdx int, g *Group) (int, int, func(tce
}
setIf(screen, clip, x+1, ry, r, labelStyle)
}
cx := x + 1 + labelCol + groupPad
for i, m := range members {
if i > 0 {
cx += boxGap
// Member boxes, row by row. Each row's top is the running sum of prior row
// heights below the enclosure's top border.
ry := y + 1
for r, row := range rows {
cx := x + memBase
for j, mi := range row {
if j > 0 {
cx += boxGap
}
members[mi].draw(screen, cx, ry, clip)
cx += members[mi].width
}
m.draw(screen, cx, y+1, clip)
cx += m.width
ry += rowHeights[r]
}
}
return boxW, boxH, draw, selMemRelX, selMemW
Expand Down
156 changes: 147 additions & 9 deletions internal/tui/planview/render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -593,10 +593,13 @@ func TestDraw_HScroll_EdgeIndicators(t *testing.T) {
testutil.Equal(t, strings.ContainsRune(atLast, '›'), false)
}

// TestDraw_HScroll_FannedMemberScrollsIntoView (BUG-010): a fanned group wider
// than the pane scrolls so the SELECTED member box is fully visible, with an
// earlier member scrolled off the left.
func TestDraw_HScroll_FannedMemberScrollsIntoView(t *testing.T) {
// TestDraw_FannedGroupWrapsInsteadOfHScroll (BUG-011 supersedes the BUG-010
// fanned-scroll path): a fanned group wider than the pane no longer scrolls one
// overflowing row horizontally — it WRAPS onto multiple rows, so the selected
// member AND the previously-off-left first member are BOTH fully visible at once,
// and no horizontal-scroll edge indicator is drawn. (Lone-node stages still
// scroll horizontally — see TestDraw_HScroll_SelectedNodeFullyVisible.)
func TestDraw_FannedGroupWrapsInsteadOfHScroll(t *testing.T) {
w := fanGroup("1a", "1b", "1c", "1d", "1e", "1f", "1g", "1h")
w.SetFocused(true)
w.MoveStage(1) // onto the collapsed group
Expand All @@ -605,12 +608,15 @@ func TestDraw_HScroll_FannedMemberScrollsIntoView(t *testing.T) {
w.MoveSlot(1) // walk to the last member (1h)
}
testutil.Equal(t, w.CurrentNodeID(), "1h")
sc := drawToSim(t, w, 40, 18)
// ○ is the planned-node glyph; inside a fanned group the box label is the
// parsed short-id ("1h"), not the full role name.
sc := drawToSim(t, w, 40, 30)
// The selected last member is fully visible AND the first member is still on
// screen (wrapped to an earlier row), not scrolled off the left.
assertBoxFullyVisible(t, sc, "○ 1h")
_, _, found := findStringCell(sc, "○ 1a")
testutil.Equal(t, found, false)
assertBoxFullyVisible(t, sc, "○ 1a")
// No horizontal-scroll indicators — wrapping removed the overflow.
out := drawToString(t, w, 40, 30)
testutil.Equal(t, strings.ContainsRune(out, '‹'), false)
testutil.Equal(t, strings.ContainsRune(out, '›'), false)
}

// TestDraw_HScroll_NoIndicatorsWhenFits (BUG-010): when every stage fits the pane
Expand Down Expand Up @@ -687,6 +693,138 @@ func TestGroupCounts_IncludesCancelled(t *testing.T) {
testutil.Contains(t, got, "1 ✕")
}

// fanGroupFeeding builds a plan stage0 [1a] -> group {members…} (stage1) -> [3a]
// where every member feeds 3a, so the group is a downstream-feeding parallel
// group with stages above AND below it. Used to exercise the wrapped-group edge
// anchoring (BUG-011 fan-wrap).
func fanGroupFeeding(members ...string) *Widget {
w := New()
nodes := []Node{node("1a"), node("3a")}
var edges []Edge
for _, m := range members {
nodes = append(nodes, node(m))
edges = append(edges, Edge{From: "1a", To: m}, Edge{From: m, To: "3a"})
}
w.SetData(nodes, edges)
w.SetFocused(true)
return w
}

// TestDraw_FannedGroupWrapsToFitWidth (BUG-011 fan-wrap): a fanned group with
// more members than fit one row at a narrow pane width wraps its member boxes
// onto MULTIPLE ROWS so every box is fully visible — no overflow off the right
// edge and no horizontal-scroll edge indicators. The first and last members
// land on different rows.
func TestDraw_FannedGroupWrapsToFitWidth(t *testing.T) {
members := []string{"2a", "2b", "2c", "2d", "2e", "2f", "2g", "2h"}
// Plain group (no downstream feed) so member boxes carry no `↘` and their
// content matches assertBoxFullyVisible's exact box-width math.
w := fanGroup(members...)
// Fan out the group (stage 1).
w.MoveStage(1)
_, isGroup := w.GroupAt(w.CursorPos().Stage, w.CursorPos().Slot)
testutil.Equal(t, isGroup, true)
w.ActivateCursor()

const paneW, paneH = 40, 30
sc := drawToSim(t, w, paneW, paneH)
// Every member box is present AND fully visible (left + right borders on-screen).
for _, m := range members {
assertBoxFullyVisible(t, sc, "○ "+m)
}
// The first and last members are on DIFFERENT rows (the group wrapped).
_, yFirst, okF := findStringCell(sc, "○ 2a")
_, yLast, okL := findStringCell(sc, "○ 2h")
testutil.Equal(t, okF, true)
testutil.Equal(t, okL, true)
testutil.Equal(t, yFirst != yLast, true)

// Wrapping removed the horizontal overflow → no edge indicators.
out := drawToString(t, w, paneW, paneH)
testutil.Equal(t, strings.ContainsRune(out, '›'), false)
testutil.Equal(t, strings.ContainsRune(out, '‹'), false)
}

// TestDraw_FannedGroupWrapCursorReachesEveryMember (BUG-011 fan-wrap): with the
// group wrapped onto multiple rows, walking the cursor right from the first
// member advances through every member in order across the rows.
func TestDraw_FannedGroupWrapCursorReachesEveryMember(t *testing.T) {
members := []string{"2a", "2b", "2c", "2d", "2e", "2f", "2g", "2h"}
w := fanGroup(members...)
w.MoveStage(1)
w.ActivateCursor() // fan out → cursor on member 0 (2a)
testutil.Equal(t, w.CurrentNodeID(), "2a")
// Render narrow so it wraps; the cursor model is width-independent, but assert
// the rendered grid is multi-row first.
const paneW, paneH = 40, 30
sc := drawToSim(t, w, paneW, paneH)
_, y0, _ := findStringCell(sc, "○ 2a")
_, y7, _ := findStringCell(sc, "○ 2h")
testutil.Equal(t, y0 != y7, true)
// Walk right through all members; each step names the next member in order.
for i := 1; i < len(members); i++ {
w.MoveSlot(1)
testutil.Equal(t, w.CurrentNodeID(), members[i])
}
}

// TestDraw_FannedGroupWrapDownstreamEdgeAnchored (BUG-011 fan-wrap): when a
// wrapped group feeds a downstream stage, the downstream stage's box renders
// BELOW the (now taller) wrapped block, and an inter-stage `│` connector sits
// between them — the edge re-anchors under the taller block.
func TestDraw_FannedGroupWrapDownstreamEdgeAnchored(t *testing.T) {
members := []string{"2a", "2b", "2c", "2d", "2e", "2f", "2g", "2h"}
w := fanGroupFeeding(members...)
w.MoveStage(1)
w.ActivateCursor() // fan out the group

const paneW, paneH = 40, 30
sc := drawToSim(t, w, paneW, paneH)
// The downstream node 3a renders below the wrapped group's last member row.
_, yLastMember, okM := findStringCell(sc, "○ 2h")
_, y3a, ok3a := findStringCell(sc, "○ 3a")
testutil.Equal(t, okM, true)
testutil.Equal(t, ok3a, true)
testutil.Equal(t, y3a > yLastMember, true)
// An inter-stage connector `│` sits on a row strictly between the group block
// and the downstream box (the edge anchors under the taller wrapped block).
cells, scw, _ := sc.GetContents()
connectorBetween := false
for y := yLastMember + 1; y < y3a; y++ {
for x := 0; x < scw; x++ {
c := cells[y*scw+x]
if len(c.Runes) > 0 && c.Runes[0] == '│' {
connectorBetween = true
}
}
}
testutil.Equal(t, connectorBetween, true)
}

// TestDraw_FannedGroupNarrowPaneOnePerRow (BUG-011 fan-wrap, degenerate edge):
// at a pane so narrow that a single member box exceeds the inner-width budget,
// each member wraps to its own row, the enclosure still overflows the pane
// width, and the BUG-010 horizontal viewport keeps the SELECTED member visible
// (drawing a `‹` once scrolled right). Exercises the budget clamp + the fanned
// horizontal-scroll path that wrapping otherwise supersedes.
func TestDraw_FannedGroupNarrowPaneOnePerRow(t *testing.T) {
w := fanGroup("1a", "1b", "1c")
w.MoveStage(1)
w.ActivateCursor() // fan out → cursor on member 0
w.MoveSlot(1) // walk to the last visible member
w.MoveSlot(1) // (clamps at the last member)
// A very narrow pane: a single ~8-wide member box can't fit the inner budget,
// so each member occupies its own row and the enclosure overflows the width.
sc := drawToSim(t, w, 12, 20)
// The render must not panic and the cursor still names a member box.
id := w.CurrentNodeID()
testutil.Equal(t, id == "1a" || id == "1b" || id == "1c", true)
// Content is hidden to the left after scrolling right → a `‹` indicator.
out := drawToString(t, w, 12, 20)
testutil.Contains(t, out, "‹")
_ = sc
}

func TestTruncateLabel_RuneAware(t *testing.T) {
// A long name truncates to fallbackLabelRunes with an ellipsis, rune-counted.
long := "verylongrolenamehere"
Expand Down
Loading
Loading