commit 0dd80f165744bc242b14c0a5f0217bcc104ee481
parent ead08bca7c90d7e32e8c09e7e9336f5522b29210
Author: Michael Camilleri <[email protected]>
Date: Fri, 15 May 2026 01:00:55 +0900
Add border to Cursor Track
For the remote Cursor Track, add a border around the outside to indicate
the slot that the collaborator is looking at.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
2 files changed, 69 insertions(+), 11 deletions(-)
diff --git a/Crossmate/Views/CellView.swift b/Crossmate/Views/CellView.swift
@@ -9,7 +9,7 @@ struct CellView: View, Equatable {
var isRelatedToFocus: Bool = false
let specialKind: Puzzle.Special?
var remoteWordTint: Color? = nil
- var remoteOutline: Color? = nil
+ var remoteTrackBorder: CellBorder? = nil
var authorTint: Color? = nil
@Environment(PlayerPreferences.self) private var preferences
@@ -24,7 +24,7 @@ struct CellView: View, Equatable {
&& lhs.isRelatedToFocus == rhs.isRelatedToFocus
&& lhs.specialKind == rhs.specialKind
&& lhs.remoteWordTint == rhs.remoteWordTint
- && lhs.remoteOutline == rhs.remoteOutline
+ && lhs.remoteTrackBorder == rhs.remoteTrackBorder
&& lhs.authorTint == rhs.authorTint
}
@@ -57,9 +57,9 @@ struct CellView: View, Equatable {
Rectangle()
.strokeBorder(playerColor.highlightFill, lineWidth: 3)
}
- if let remoteOutline {
- Rectangle()
- .strokeBorder(remoteOutline, lineWidth: 2)
+ if let remoteTrackBorder {
+ CellBorderShape(edges: remoteTrackBorder.edges)
+ .stroke(remoteTrackBorder.color, lineWidth: 2)
}
}
}
@@ -122,6 +122,45 @@ struct CellView: View, Equatable {
}
}
+struct CellBorder: Equatable {
+ let color: Color
+ let edges: CellBorderEdges
+}
+
+struct CellBorderEdges: OptionSet, Equatable {
+ let rawValue: Int
+
+ static let top = CellBorderEdges(rawValue: 1 << 0)
+ static let trailing = CellBorderEdges(rawValue: 1 << 1)
+ static let bottom = CellBorderEdges(rawValue: 1 << 2)
+ static let leading = CellBorderEdges(rawValue: 1 << 3)
+}
+
+private struct CellBorderShape: Shape {
+ let edges: CellBorderEdges
+
+ func path(in rect: CGRect) -> Path {
+ var path = Path()
+ if edges.contains(.top) {
+ path.move(to: CGPoint(x: rect.minX, y: rect.minY))
+ path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
+ }
+ if edges.contains(.trailing) {
+ path.move(to: CGPoint(x: rect.maxX, y: rect.minY))
+ path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
+ }
+ if edges.contains(.bottom) {
+ path.move(to: CGPoint(x: rect.maxX, y: rect.maxY))
+ path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
+ }
+ if edges.contains(.leading) {
+ path.move(to: CGPoint(x: rect.minX, y: rect.maxY))
+ path.addLine(to: CGPoint(x: rect.minX, y: rect.minY))
+ }
+ return path
+ }
+}
+
/// Small dot pinned to the top-leading corner of the cell, sized as a
/// fraction of the shorter cell dimension. Marks cells that start a word.
private struct CornerDot: Shape {
diff --git a/Crossmate/Views/GridView.swift b/Crossmate/Views/GridView.swift
@@ -10,7 +10,7 @@ struct GridView: View {
var body: some View {
let width = session.puzzle.width
let height = session.puzzle.height
- let (outlineByCell, tintByCell) = showsSharedAnnotations ? remoteOverlays() : ([:], [:])
+ let (borderByCell, tintByCell) = showsSharedAnnotations ? remoteOverlays() : ([:], [:])
let authorTintByID: [String: Color] = showsSharedAnnotations
? Dictionary(
uniqueKeysWithValues: roster.entries.map { ($0.authorID, $0.color.tint) }
@@ -41,7 +41,7 @@ struct GridView: View {
isRelatedToFocus: relatedCells.contains(pos),
specialKind: session.puzzle.specialKind,
remoteWordTint: tintByCell[pos],
- remoteOutline: outlineByCell[pos],
+ remoteTrackBorder: borderByCell[pos],
authorTint: square.entry.isEmpty
? nil
: square.letterAuthorID.flatMap { authorTintByID[$0] }
@@ -60,21 +60,40 @@ struct GridView: View {
/// presence records carry the selected answer slot, not the focused
/// square.
private func remoteOverlays() -> (
- outline: [GridPosition: Color],
+ border: [GridPosition: CellBorder],
tint: [GridPosition: Color]
) {
+ var border: [GridPosition: (Date, CellBorder)] = [:]
var tint: [GridPosition: (Date, Color)] = [:]
for (_, sel) in roster.remoteSelections {
- for cell in session.puzzle.wordCells(
+ let cells = session.puzzle.wordCells(
atRow: sel.row, col: sel.col, direction: sel.direction
- ) {
+ )
+ for (index, cell) in cells.enumerated() {
let pos = GridPosition(row: cell.row, col: cell.col)
if tint[pos].map({ $0.0 < sel.updatedAt }) ?? true {
tint[pos] = (sel.updatedAt, sel.color.highlightFill)
}
+ var edges: CellBorderEdges
+ switch sel.direction {
+ case .across:
+ edges = [.top, .bottom]
+ if index == cells.startIndex { edges.insert(.leading) }
+ if index == cells.index(before: cells.endIndex) { edges.insert(.trailing) }
+ case .down:
+ edges = [.leading, .trailing]
+ if index == cells.startIndex { edges.insert(.top) }
+ if index == cells.index(before: cells.endIndex) { edges.insert(.bottom) }
+ }
+ if border[pos].map({ $0.0 < sel.updatedAt }) ?? true {
+ border[pos] = (
+ sel.updatedAt,
+ CellBorder(color: sel.color.selectionFill, edges: edges)
+ )
+ }
}
}
- return ([:], tint.mapValues { $0.1 })
+ return (border.mapValues { $0.1 }, tint.mapValues { $0.1 })
}
}