From 53f1d8839d3b4feb3e7c802f742c00ca67df43c6 Mon Sep 17 00:00:00 2001 From: Pool Camacho Date: Wed, 15 Apr 2026 03:16:11 -0600 Subject: [PATCH] fix(graph): edge from merge node starts at child lane, polish node and curve styling Closes #3. The merge-parent edges were recorded with fromLane = parentLane (the lane we had just chosen for the parent), which meant the start-of-edge curve was drawn at the parent's future column instead of rising from the merge commit. Side branches looked disconnected from their origin, and merge-back lines appeared to start mid-air on the wrong side. fromLane now tracks the child commit's lane for every parent edge, so the curve visibly leaves the merge node and sweeps into the side lane. While in the file, split the Canvas body into edgePath() and drawNode() helpers, bump edge stroke to 2pt, bump regular node diameter to 9px, and change merge node styling to a hollow ring plus a small inner dot so joins stay readable over crossing lane lines. --- Maple/Services/CommitGraphBuilder.swift | 4 +- Maple/Views/CommitHistoryView.swift | 122 +++++++++++++++--------- 2 files changed, 81 insertions(+), 45 deletions(-) diff --git a/Maple/Services/CommitGraphBuilder.swift b/Maple/Services/CommitGraphBuilder.swift index e0c6479..3e681ba 100644 --- a/Maple/Services/CommitGraphBuilder.swift +++ b/Maple/Services/CommitGraphBuilder.swift @@ -97,7 +97,9 @@ enum CommitGraphBuilder { activeLanes.append(parent) } maxLaneSeen = max(maxLaneSeen, parentLane + 1) - pendingEdges.append(PendingEdge(fromRow: row, fromLane: parentLane, parentID: parent, isMergeParent: true)) + // fromLane is the child's lane so the edge visibly starts from + // the merge node; toLane (the parent's lane) is resolved below. + pendingEdges.append(PendingEdge(fromRow: row, fromLane: lane, parentID: parent, isMergeParent: true)) } } } diff --git a/Maple/Views/CommitHistoryView.swift b/Maple/Views/CommitHistoryView.swift index e762148..7ad88f4 100644 --- a/Maple/Views/CommitHistoryView.swift +++ b/Maple/Views/CommitHistoryView.swift @@ -162,69 +162,103 @@ struct CommitGraphRowCanvas: View { let laneWidth: CGFloat let rowHeight: CGFloat + private let edgeWidth: CGFloat = 2.0 + private let nodeDiameter: CGFloat = 9 + private let mergeInnerDiameter: CGFloat = 5 + private let mergeOuterDiameter: CGFloat = 13 + private let mergeRingWidth: CGFloat = 2.0 + private let laneOffset: CGFloat = 12 + var body: some View { Canvas { context, _ in - let laneOffset: CGFloat = 12 // leading padding before lane 0 - func xFor(lane: Int) -> CGFloat { laneOffset + CGFloat(lane) * laneWidth + laneWidth / 2 } - // 1) Edges touching this row for edge in layout.edges { guard edge.fromRow <= rowIndex, rowIndex <= edge.toRow else { continue } let fromX = xFor(lane: edge.fromLane) let toX = xFor(lane: edge.toLane) let color = CommitGraphColors.color(forLane: edge.toLane) + let path = edgePath(from: fromX, to: toX, edgeFromRow: edge.fromRow, edgeToRow: edge.toRow) - var path = Path() - - if edge.fromRow == rowIndex && edge.toRow == rowIndex { - // Degenerate same-row edge (shouldn't happen with parent relationships) - path.move(to: CGPoint(x: fromX, y: rowHeight / 2)) - path.addLine(to: CGPoint(x: toX, y: rowHeight / 2)) - } else if edge.fromRow == rowIndex { - // Start half: from commit center out to bottom edge, potentially curving to another lane - path.move(to: CGPoint(x: fromX, y: rowHeight / 2)) - if fromX == toX { - path.addLine(to: CGPoint(x: toX, y: rowHeight)) - } else { - path.addCurve( - to: CGPoint(x: toX, y: rowHeight), - control1: CGPoint(x: fromX, y: rowHeight * 0.82), - control2: CGPoint(x: toX, y: rowHeight * 0.68) - ) - } - } else if edge.toRow == rowIndex { - // End half: top edge down to commit center, vertical at destination lane - path.move(to: CGPoint(x: toX, y: 0)) - path.addLine(to: CGPoint(x: toX, y: rowHeight / 2)) - } else { - // Crossing: vertical at destination lane from top to bottom - path.move(to: CGPoint(x: toX, y: 0)) - path.addLine(to: CGPoint(x: toX, y: rowHeight)) - } - - context.stroke(path, with: .color(color), style: StrokeStyle(lineWidth: 1.5, lineCap: .round)) + context.stroke(path, with: .color(color), style: StrokeStyle(lineWidth: edgeWidth, lineCap: .round)) } - // 2) Node circle for this row (drawn last so it sits above edges) if let node = layout.node(atRow: rowIndex) { - let x = xFor(lane: node.lane) - let y = rowHeight / 2 - let color = CommitGraphColors.color(forLane: node.lane) + drawNode(node, in: context, xFor: xFor) + } + } + } - let diameter: CGFloat = node.isMerge ? 10 : 8 - let rect = CGRect(x: x - diameter / 2, y: y - diameter / 2, width: diameter, height: diameter) + /// Returns the slice of the edge that lives in the current row. Lane transitions + /// happen entirely inside the child row (the `fromRow` end) so branches visibly + /// originate from the source commit rather than drifting across multiple rows. + private func edgePath(from fromX: CGFloat, to toX: CGFloat, edgeFromRow: Int, edgeToRow: Int) -> Path { + var path = Path() + + if edgeFromRow == rowIndex && edgeToRow == rowIndex { + path.move(to: CGPoint(x: fromX, y: rowHeight / 2)) + path.addLine(to: CGPoint(x: toX, y: rowHeight / 2)) + } else if edgeFromRow == rowIndex { + path.move(to: CGPoint(x: fromX, y: rowHeight / 2)) + if fromX == toX { + path.addLine(to: CGPoint(x: toX, y: rowHeight)) + } else { + // Smoother S: stay at source-x longer then sweep into target-x near the bottom + path.addCurve( + to: CGPoint(x: toX, y: rowHeight), + control1: CGPoint(x: fromX, y: rowHeight * 0.75), + control2: CGPoint(x: toX, y: rowHeight * 0.80) + ) + } + } else if edgeToRow == rowIndex { + path.move(to: CGPoint(x: toX, y: 0)) + path.addLine(to: CGPoint(x: toX, y: rowHeight / 2)) + } else { + path.move(to: CGPoint(x: toX, y: 0)) + path.addLine(to: CGPoint(x: toX, y: rowHeight)) + } - context.fill(Path(ellipseIn: rect), with: .color(color)) + return path + } - if node.isMerge { - let outer = rect.insetBy(dx: -2.5, dy: -2.5) - context.stroke(Path(ellipseIn: outer), with: .color(color), lineWidth: 1.5) - } - } + /// Regular commit: filled dot. Merge: hollow ring with a small inner dot, so joins + /// stay readable over vertical lane lines that cross behind them. + private func drawNode(_ node: CommitGraphLayout.Node, in context: GraphicsContext, xFor: (Int) -> CGFloat) { + let x = xFor(node.lane) + let y = rowHeight / 2 + let color = CommitGraphColors.color(forLane: node.lane) + + if node.isMerge { + let outerRect = CGRect( + x: x - mergeOuterDiameter / 2, + y: y - mergeOuterDiameter / 2, + width: mergeOuterDiameter, + height: mergeOuterDiameter + ) + context.stroke( + Path(ellipseIn: outerRect), + with: .color(color), + lineWidth: mergeRingWidth + ) + + let innerRect = CGRect( + x: x - mergeInnerDiameter / 2, + y: y - mergeInnerDiameter / 2, + width: mergeInnerDiameter, + height: mergeInnerDiameter + ) + context.fill(Path(ellipseIn: innerRect), with: .color(color)) + } else { + let rect = CGRect( + x: x - nodeDiameter / 2, + y: y - nodeDiameter / 2, + width: nodeDiameter, + height: nodeDiameter + ) + context.fill(Path(ellipseIn: rect), with: .color(color)) } } }