crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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:
MCrossmate/Views/CellView.swift | 49++++++++++++++++++++++++++++++++++++++++++++-----
MCrossmate/Views/GridView.swift | 31+++++++++++++++++++++++++------
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 }) } }