← poker-tutor
import SwiftUI
import PokerCore
import PokerAI
import PokerCoach
import PokerSession
struct TableView: View {
@Environment(AppState.self) var app
@Environment(\.metrics) var metrics
var body: some View {
VStack(spacing: 0) {
header
if let run = app.assessment { AssessmentBanner(handNumber: app.handNumber, run: run) }
if let run = app.drill { DrillBanner(handNumber: app.handNumber, run: run) }
GeometryReader { geo in
ZStack {
felt(in: geo.size)
centre(in: geo.size)
ForEach(app.seats.filter { $0.status != .sittingOut && $0.seat != app.heroSeat },
id: \.seat) { seat in
SeatView(seat: seat)
.position(seatPoint(for: seat.seat, in: geo.size))
}
// The button and the blinds, on the felt in front of the seats that hold them.
ForEach(markedSeats, id: \.seat) { entry in
MarkerRow(marks: entry.marks)
.position(markerPoint(for: entry.seat, marks: entry.marks.count, in: geo.size))
}
if app.thumbsUp { ThumbsUp() }
// Over everything else on the felt, including the seat ring, so a card held up
// is not competing with the table it came from.
if let card = app.magnified {
MagnifiedCard(card: card) { app.putCardBack() }
}
}
.animation(.spring(response: 0.32, dampingFraction: 0.72), value: app.thumbsUp)
}
.padding(.horizontal, metrics.compact ? 4 : 20)
heroRow
actionBar
}
.background(Theme.background)
}
/// The player's own cards, name, stack and bet, in a row of their own between the felt and
/// the buttons. Not on the ring: the same layout as PLO Tutor, which Ken preferred to a seat
/// box holding the cards. See `Metrics.heroCard`.
@ViewBuilder private var heroRow: some View {
if let hero = app.seats.first(where: { $0.seat == app.heroSeat && $0.status != .sittingOut }) {
HeroRow(seat: hero)
.frame(height: metrics.heroRowHeight)
.frame(maxWidth: .infinity)
.background(Theme.background)
}
}
// MARK: Header
private var header: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: metrics.compact ? 12 : 18) {
Button { app.leaveTable() } label: {
HStack(spacing: 3) {
Symbol("chevron.left")
if !metrics.compact { Text("Leave") }
}
// On the whole row, so the chevron grows with the word beside it.
.font(.system(size: Theme.size(12), weight: .medium, design: .rounded))
.foregroundStyle(Theme.dim)
.frame(minWidth: metrics.compact ? 32 : 0, minHeight: metrics.compact ? 32 : 0)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
readout("Blinds", "\(app.level.smallBlind)/\(app.level.bigBlind)")
if app.mode == .tournament {
readout(metrics.compact ? "Left" : "Players left", "\(app.playersLeft)")
}
if app.mode == .cash {
readout(Copy.cashStructureReadout, Copy.structureName(app.bettingStructure))
}
readout("Hand", "\(app.handNumber)")
if !metrics.compact {
readout("Table", "difficulty \(app.tableDifficulty)")
}
Spacer()
if !metrics.compact { commentary }
Button { app.toggleSound() } label: {
Symbol(app.soundOn ? "speaker.wave.2" : "speaker.slash", size: 13)
.foregroundStyle(Theme.dim)
.frame(minWidth: 32, minHeight: 32)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
// On a phone the running commentary needs the width to itself.
if metrics.compact, !commentaryText.isEmpty {
commentary
}
}
.padding(.horizontal, metrics.compact ? 14 : 22)
.padding(.vertical, metrics.compact ? 9 : 12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Theme.panel)
}
private var commentaryText: String {
app.statusLine.isEmpty ? (app.handSummary.isEmpty ? "" : "You have \(app.handSummary)") : app.statusLine
}
@ViewBuilder private var commentary: some View {
HStack(spacing: 10) {
if !app.handSummary.isEmpty {
Text("You have \(app.handSummary)").body().foregroundStyle(Theme.accent)
}
if !app.statusLine.isEmpty {
Text(app.statusLine).body().foregroundStyle(Theme.gold)
}
}
}
private func readout(_ title: String, _ value: String) -> some View {
VStack(alignment: .leading, spacing: 1) {
Text(title.uppercased()).caption(weight: .medium).foregroundStyle(Theme.dim.opacity(0.8))
Text(value).numeric().foregroundStyle(Theme.text)
}
}
// MARK: Felt
private func felt(in size: CGSize) -> some View {
let inset: CGFloat = metrics.compact ? 30 : 62
return ZStack {
RoundedRectangle(cornerRadius: 300, style: .continuous)
.fill(Theme.rail)
.padding(inset - 16)
.shadow(color: .black.opacity(0.5), radius: 18, y: 8)
RoundedRectangle(cornerRadius: 300, style: .continuous)
.fill(
RadialGradient(colors: [Theme.feltInner, Theme.feltOuter],
center: .center, startRadius: 20, endRadius: max(size.width, size.height) * 0.55)
)
.padding(inset)
.overlay(
RoundedRectangle(cornerRadius: 300, style: .continuous)
.stroke(Color.white.opacity(0.07), lineWidth: 1)
.padding(inset)
)
}
}
private func centre(in size: CGSize) -> some View {
VStack(spacing: 12) {
HStack(spacing: Metrics.boardGap) {
ForEach(0..<5, id: \.self) { index in
if index < app.board.count {
CardView(card: app.board[index], size: metrics.boardCard,
onTap: { app.magnify($0) })
} else {
RoundedRectangle(cornerRadius: 7, style: .continuous)
.stroke(Color.white.opacity(0.10), style: StrokeStyle(lineWidth: 1, dash: [4, 4]))
.frame(width: metrics.boardCard * 0.72, height: metrics.boardCard)
}
}
}
if app.pot > 0 {
HStack(spacing: 6) {
Text("POT").caption(weight: .medium).foregroundStyle(Theme.text.opacity(0.55))
Text("\(app.pot)").numeric(15).foregroundStyle(Theme.text)
}
.padding(.horizontal, 12).padding(.vertical, 5)
.background(Capsule().fill(Color.black.opacity(0.32)))
}
}
.position(x: size.width / 2, y: size.height / 2 - 6)
}
/// Seats sit around an ellipse, with the player always at the bottom.
private func seatPoint(for seat: Int, in size: CGSize) -> CGPoint {
let live = app.seats.filter { $0.status != .sittingOut }.map(\.seat).sorted()
guard let index = live.firstIndex(of: seat), !live.isEmpty else { return .zero }
let heroIndex = live.firstIndex(of: app.heroSeat) ?? 0
return FeltRing(size: size, metrics: metrics)
.point(seatsFromHero: (index - heroIndex + live.count) % live.count, of: live.count)
}
/// Which seats carry a marker this hand, and which ones. Heads-up the button posts the
/// small blind, so it carries both.
private var markedSeats: [(seat: Int, marks: [TableMark])] {
let headsUp = app.positions.count == 2
return app.seats.filter { $0.status != .sittingOut }.compactMap { seat in
guard let position = app.positions[seat.seat] else { return nil }
let marks = TableMark.marks(for: position, headsUp: headsUp)
return marks.isEmpty ? nil : (seat.seat, marks)
}
}
/// Where a seat's marker sits: see `Metrics.markerCentre`.
private func markerPoint(for seat: Int, marks: Int, in size: CGSize) -> CGPoint {
let live = app.seats.filter { $0.status != .sittingOut }.map(\.seat).sorted()
guard let index = live.firstIndex(of: seat), !live.isEmpty else { return .zero }
let heroIndex = live.firstIndex(of: app.heroSeat) ?? 0
return metrics.markerCentre(seatsFromHero: (index - heroIndex + live.count) % live.count,
of: live.count, marks: marks, in: size)
}
// MARK: Actions
private var actionBar: some View {
Group {
if let legal = app.legal {
// A fresh identity per decision, so the raise slider always starts from a sensible
// size rather than wherever it was left on the last street.
ActionBar(legal: legal)
.id("\(app.handNumber)-\(app.board.count)-\(app.pot)-\(legal.callCost)")
} else if app.handComplete {
// No buttons. Ken, 2026-08-22: "in some cases, I need to press next hand, sometimes
// I don't. Lets make it consistent and not need that button."
//
// The table deals on by itself now, so all this row still has to do is say what
// just happened — and on a phone, handing a button's worth of height back to the
// felt is worth more than the buttons were. The review is not lost with them: a
// hand worth reviewing opens itself, and every hand is under Hands afterwards.
HStack {
Text(app.statusLine).body().foregroundStyle(Theme.gold)
Spacer()
}
.padding(.horizontal, metrics.compact ? 14 : 22)
.padding(.vertical, metrics.compact ? 6 : 10)
.background(Theme.panel)
} else if app.engine == nil {
VStack(alignment: .leading, spacing: 10) {
Text(app.statusLine.isEmpty ? "Table closed." : app.statusLine)
.body().foregroundStyle(Theme.dim)
HStack {
if !metrics.compact { Spacer() }
PillButton(title: "Back to home", wide: metrics.compact) { app.screen = .home }
}
}
.padding(.horizontal, metrics.compact ? 14 : 22)
.padding(.vertical, metrics.compact ? 10 : 14)
.background(Theme.panel)
} else {
HStack {
Text(waitingText).body().foregroundStyle(Theme.dim)
Spacer()
}
.frame(minHeight: metrics.compact ? 46 : 0)
.padding(.horizontal, metrics.compact ? 14 : 22)
.padding(.vertical, metrics.compact ? 14 : 22)
.background(Theme.panel)
}
}
}
private var waitingText: String {
guard let actor = app.actorSeat else { return "Dealing…" }
return "\(app.label(forSeat: actor)) is thinking…"
}
}
/// Fold, check or call, and a raise with a size you choose.
///
/// One row where there is room for one. On a phone it stacks: the sizing controls on top, then the
/// decisions themselves along the bottom, where a thumb naturally sits.
struct ActionBar: View {
@Environment(AppState.self) var app
@Environment(\.metrics) var metrics
let legal: LegalActions
/// The chips counted out so far, in the order they were picked up, so the last one can be put
/// back down again.
@State var picked: [Chip] = []
/// Fixed-limit has nothing to count out: a bet is one size, so the tray stays in the drawer
/// and the Bet or Raise button carries the amount.
private var fixedLimit: Bool { app.bettingStructure == .fixedLimit }
private var tray: ChipTray? { fixedLimit ? nil : app.chipTray }
private var amount: Int { picked.reduce(0) { $0 + $1.value } }
var body: some View {
Group {
if metrics.compact { stacked } else { inLine }
}
.padding(.horizontal, metrics.compact ? 14 : 22)
.padding(.vertical, metrics.compact ? 10 : 14)
.background(Theme.panel)
}
private var inLine: some View {
HStack(alignment: .center, spacing: 14) {
fold
check
call
if let tray {
Divider().frame(height: 46).overlay(Color.white.opacity(0.08))
chips(tray)
counter(tray)
commit(tray)
}
fixedBet
capNote
Spacer()
}
}
private var stacked: some View {
VStack(spacing: 10) {
if let tray {
chips(tray)
counter(tray)
}
capNote
HStack(spacing: 8) {
fold
check
call
if let tray { commit(tray) }
fixedBet
}
}
}
// MARK: Fixed-limit
/// One size, so one button: "Bet 100" or "Raise to 200".
@ViewBuilder private var fixedBet: some View {
if fixedLimit, legal.canBetOrRaise {
PillButton(title: legal.canBet ? Copy.fixedBetTitle(legal.maxRaiseTo)
: Copy.fixedRaiseTitle(legal.maxRaiseTo),
subtitle: legal.maxIsAllIn ? "all in" : nil,
wide: metrics.compact) {
app.act(legal.canBet ? .bet(to: legal.maxRaiseTo) : .raise(to: legal.maxRaiseTo))
}
}
}
@ViewBuilder private var capNote: some View {
if legal.capped {
Text(Copy.bettingCapped).caption().foregroundStyle(Theme.dim)
}
}
// MARK: The chips
private func chips(_ tray: ChipTray) -> some View {
HStack(spacing: metrics.compact ? 5 : 7) {
ForEach(tray.offered) { chip in
ChipButton(chip: chip,
size: metrics.compact ? 42 : 46,
enabled: tray.canAdd(chip, to: amount)) {
picked.append(chip)
Sound.shared.play(.chips)
}
}
}
}
/// What has been counted out, and the two ways to change your mind about it.
private func counter(_ tray: ChipTray) -> some View {
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 6) {
Text(legal.canBet ? "BET" : "RAISE TO").caption(weight: .medium).foregroundStyle(Theme.dim)
Text("\(amount)").numeric(16).foregroundStyle(amount > 0 ? Theme.text : Theme.dim)
if let short = tray.shortfall(amount), amount > 0 {
Text("· \(short) short of the minimum").caption().foregroundStyle(Theme.gold)
} else if amount == 0 {
Text("· minimum \(tray.minimum)").caption().foregroundStyle(Theme.dim)
} else if amount == tray.maximum {
Text("· all in").caption().foregroundStyle(Theme.warn)
}
}
HStack(spacing: 6) {
GhostButton(title: "Undo", narrow: true) {
if !picked.isEmpty { picked.removeLast() }
}
.opacity(picked.isEmpty ? 0.4 : 1)
.disabled(picked.isEmpty)
GhostButton(title: "Clear", narrow: true) { picked.removeAll() }
.opacity(picked.isEmpty ? 0.4 : 1)
.disabled(picked.isEmpty)
// Counting out an all-in one chip at a time is nobody's idea of a good time.
GhostButton(title: "All in", narrow: true) {
picked = tray.quickest(tray.maximum)
Sound.shared.play(.chips)
}
}
}
}
// MARK: The decisions
@ViewBuilder private var fold: some View {
if legal.canFold && !legal.canCheck {
GhostButton(title: "Fold", tint: Theme.warn, wide: metrics.compact) { app.act(.fold) }
}
}
@ViewBuilder private var check: some View {
if legal.canCheck {
GhostButton(title: "Check", wide: metrics.compact) { app.act(.check) }
}
}
@ViewBuilder private var call: some View {
if legal.canCall {
PillButton(title: "Call", subtitle: "\(legal.callCost)\(legal.callIsAllIn ? " — all in" : "")",
tint: Theme.hero, wide: metrics.compact) { app.act(.call) }
}
}
private func commit(_ tray: ChipTray) -> some View {
let ready = tray.isLegal(amount)
return PillButton(title: legal.canBet ? "Bet" : "Raise",
subtitle: ready ? "\(amount)" : nil,
wide: metrics.compact,
enabled: ready) {
app.act(legal.canBet ? .bet(to: amount) : .raise(to: amount))
}
}
}
/// A casino chip. Pressing it puts one more of these in.
struct ChipButton: View {
let chip: Chip
var size: CGFloat = 46
var enabled = true
let action: () -> Void
@State var pressed = false
var body: some View {
Button(action: action) {
ZStack {
Circle().fill(Theme.chipFill(chip))
.shadow(color: .black.opacity(0.4), radius: 2, y: 1)
// The dashed ring is what makes a coloured circle read as a poker chip.
Circle()
.strokeBorder(Color.white.opacity(0.75), style: StrokeStyle(lineWidth: size * 0.09,
dash: [size * 0.16, size * 0.13]))
Circle().strokeBorder(Color.black.opacity(0.28), lineWidth: 1)
Circle().fill(Theme.chipFill(chip)).padding(size * 0.19)
// Fixed: the denomination is a fraction of the chip it is printed on. Growing it
// does not make it easier to read, it makes it wider than the chip.
Text(chip.label)
.font(.system(size: size * (chip.label.count > 3 ? 0.26 : 0.30),
weight: .bold, design: .rounded))
.foregroundStyle(Theme.chipText(chip))
}
.frame(width: size, height: size)
.scaleEffect(pressed ? 0.9 : 1)
.opacity(enabled ? 1 : 0.28)
.contentShape(Circle())
}
.buttonStyle(.plain)
.disabled(!enabled)
.animation(.spring(response: 0.2, dampingFraction: 0.6), value: pressed)
.hoverHighlight { pressed = $0 && enabled }
}
}
/// One opponent at the table. The player is not drawn here — see `HeroRow`.
struct SeatView: View {
@Environment(AppState.self) var app
@Environment(\.metrics) var metrics
let seat: SeatState
private var isActor: Bool { app.actorSeat == seat.seat }
private var revealed: [Card]? { app.revealedCards(forSeat: seat.seat) }
var body: some View {
VStack(spacing: 5) {
HStack(spacing: Metrics.cardGap) {
if let revealed {
ForEach(Array(revealed.enumerated()), id: \.offset) { _, card in
CardView(card: card, size: metrics.opponentRevealCard)
}
} else if seat.isInHand {
ForEach(0..<Metrics.heroCardCount, id: \.self) { _ in
CardView(card: nil, size: metrics.faceDownCard, faceDown: true)
}
}
}
.opacity(seat.status == .folded ? 0.25 : 1)
SeatBox(seat: seat)
SeatAction(seat: seat)
}
.frame(width: metrics.seatWidth)
// A badge wider than its seat overlaps the seats beside it, and whichever seat happens to
// be drawn later wins. The one that just did something is the one worth reading, so it is
// lifted above its neighbours rather than left to the order of a ForEach.
.zIndex(app.lastAction(forSeat: seat.seat) != nil || seat.committedThisRound > 0 ? 1 : 0)
.scaleEffect(isActor ? 1.045 : 1)
.animation(.spring(response: 0.3, dampingFraction: 0.75), value: isActor)
.opacity(seat.status == .folded ? 0.72 : 1)
}
}
/// The player's row under the felt: the two cards side by side, then the same name, stack and
/// bet an opponent's seat carries, laid across instead of down.
struct HeroRow: View {
@Environment(AppState.self) var app
@Environment(\.metrics) var metrics
let seat: SeatState
var body: some View {
HStack(spacing: metrics.heroRowGap) {
HStack(spacing: Metrics.cardGap) {
ForEach(Array(app.heroCards.enumerated()), id: \.offset) { _, card in
CardView(card: card, size: metrics.heroCard, onTap: { app.magnify($0) })
}
}
.opacity(seat.status == .folded ? 0.4 : 1)
SeatBox(seat: seat)
.frame(width: metrics.seatWidth)
SeatAction(seat: seat)
.frame(width: metrics.heroActionWidth, alignment: .leading)
}
.opacity(seat.status == .folded ? 0.8 : 1)
}
}
/// A seat's name, seat number and stack, in the box that lights up when it is their turn.
struct SeatBox: View {
@Environment(AppState.self) var app
@Environment(\.metrics) var metrics
let seat: SeatState
private var isHero: Bool { seat.seat == app.heroSeat }
private var isActor: Bool { app.actorSeat == seat.seat }
var body: some View {
VStack(spacing: 2) {
HStack(spacing: 5) {
Text(app.label(forSeat: seat.seat))
.font(.system(size: metrics.seatText(metrics.compact ? 10 : 12),
weight: .semibold, design: .rounded))
.lineLimit(1)
.foregroundStyle(isHero ? Theme.hero : Theme.text)
if let position = app.positions[seat.seat] {
// The number first, the old name after it: the badge is the one place a
// seat is read a hundred times a session, so it carries both.
Text(position.badge(inTableOf: app.positions.count))
.font(.system(size: metrics.seatText(9), weight: .medium, design: .rounded))
.foregroundStyle(Theme.background)
.padding(.horizontal, 4).padding(.vertical, 1)
.background(Capsule().fill(position == .button ? Theme.gold : Theme.dim.opacity(0.7)))
}
}
Text(seat.status == .folded ? "folded" : (seat.status == .allIn ? "all in" : "\(seat.stack)"))
.font(.system(size: metrics.seatText(metrics.compact ? 10 : 12),
weight: .medium, design: .rounded)).digitsAligned()
.foregroundStyle(seat.status == .folded ? Theme.dim : Theme.text.opacity(0.85))
}
.padding(.horizontal, metrics.compact ? 7 : 11)
.padding(.vertical, metrics.compact ? 4 : 6)
.background(
RoundedRectangle(cornerRadius: 9, style: .continuous)
.fill(isActor ? Theme.panelRaised : Theme.panel.opacity(0.92))
)
.overlay(
RoundedRectangle(cornerRadius: 9, style: .continuous)
.stroke(isActor ? Theme.accent : Color.white.opacity(0.07), lineWidth: isActor ? 1.6 : 1)
)
}
}
/// What a seat just did — a fold or check badge, or the chips they put in — under an opponent's
/// box and beside the player's.
struct SeatAction: View {
@Environment(AppState.self) var app
@Environment(\.metrics) var metrics
let seat: SeatState
var body: some View {
ZStack {
if let applied = app.lastAction(forSeat: seat.seat), applied.isPassive {
ActionBadge(caption: applied.caption, fold: applied.isFold)
.transition(.scale(scale: 0.7).combined(with: .opacity))
} else if seat.committedThisRound > 0 {
ChipsView(amount: seat.committedThisRound,
caption: app.lastAction(forSeat: seat.seat)?.caption)
.transition(.scale(scale: 0.7).combined(with: .opacity))
}
}
// Ken, 2026-08-22: "I cant always see the bet/raise from one of the other players."
//
// The seat box is 86 points wide on a phone and "Raise 12000" wants about 95, so the
// badge was being handed less room than it needed and truncating the very thing
// somebody was trying to read. It takes its own width now and hangs over the sides of
// the seat, which costs nothing — it is drawn over felt, it is the widest thing on the
// seat for about a second, and the alternative was a bet nobody could read.
.fixedSize(horizontal: true, vertical: false)
.frame(height: metrics.seatText(22))
.animation(.spring(response: 0.28, dampingFraction: 0.7), value: seat.committedThisRound)
.animation(.spring(response: 0.28, dampingFraction: 0.7), value: app.lastAction(forSeat: seat.seat))
}
}
/// What a player just did, when there are no chips to show it with.
struct ActionBadge: View {
@Environment(\.metrics) var metrics
let caption: String
let fold: Bool
var body: some View {
Text(caption)
.font(.system(size: metrics.seatText(11), weight: .semibold, design: .rounded))
.foregroundStyle(fold ? Theme.dim : Theme.text.opacity(0.9))
.padding(.horizontal, 8).padding(.vertical, 3)
.background(Capsule().fill(Color.black.opacity(0.42)))
}
}
/// The one piece of praise in the app: shown on a hand played well, and gone before it can be
/// acknowledged. There is deliberately nothing to press.
struct ThumbsUp: View {
@State var landed = false
var body: some View {
Symbol("hand.thumbsup.fill", size: 62)
.foregroundStyle(Theme.accent)
.shadow(color: .black.opacity(0.45), radius: 12, y: 4)
.scaleEffect(landed ? 1 : 0.55)
.opacity(landed ? 1 : 0)
.rotationEffect(.degrees(landed ? 0 : -14))
.onAppear {
withAnimation(.spring(response: 0.34, dampingFraction: 0.6)) { landed = true }
}
.allowsHitTesting(false)
}
}
/// Where the seats go on the felt, as arithmetic rather than as a drawing.
///
/// Pulled out of the view because it is the part that fails quietly: the seat boxes carry the
/// player's text-size setting, so at Extra large every box is wider and taller than the ring was
/// laid out for, and nine of them either overlap each other or slide off the edge of the window.
/// A test can walk every table size at every text size in a few milliseconds; a person cannot.
/// The three things a real table puts on the felt: the dealer button and the two blinds.
public enum TableMark: String, CaseIterable, Sendable {
case dealer = "D"
case smallBlind = "SB"
case bigBlind = "BB"
/// What a seat in `position` carries. Heads-up the button posts the small blind, so the
/// engine names that seat the button and it shows both.
public static func marks(for position: Position, headsUp: Bool) -> [TableMark] {
switch position {
case .button: return headsUp ? [.dealer, .smallBlind] : [.dealer]
case .smallBlind: return [.smallBlind]
case .bigBlind: return [.bigBlind]
default: return []
}
}
}
/// The dealer's white disc and the blinds' dark chips, side by side where a seat holds more
/// than one. Sizes from `Metrics`; where it goes is `Metrics.markerCentre`.
struct MarkerRow: View {
@Environment(\.metrics) var metrics
let marks: [TableMark]
var body: some View {
HStack(spacing: metrics.markerGap) {
ForEach(marks, id: \.self) { mark in
let size = mark == .dealer ? metrics.dealerDisc : metrics.blindMarker
Text(mark.rawValue)
.font(.system(size: size * (mark == .dealer ? 0.58 : 0.42), weight: .heavy, design: .rounded))
.foregroundStyle(mark == .dealer ? Color.black.opacity(0.85) : Theme.text)
.frame(width: size, height: size)
.background(
Circle().fill(mark == .dealer ? Color.white : Theme.panelRaised)
)
.overlay(
Circle().stroke(mark == .dealer ? Color.black.opacity(0.25) : Theme.dim.opacity(0.8),
lineWidth: 1)
)
.shadow(color: .black.opacity(0.35), radius: 2, y: 1)
}
}
.accessibilityLabel(marks.map(\.spoken).joined(separator: ", "))
}
}
extension TableMark {
var spoken: String {
switch self {
case .dealer: return "dealer button"
case .smallBlind: return "small blind"
case .bigBlind: return "big blind"
}
}
}
public struct FeltRing {
public let size: CGSize
public let seatWidth: CGFloat
public let seatHeight: CGFloat
public let compact: Bool
public init(size: CGSize, seatWidth: CGFloat, seatHeight: CGFloat, compact: Bool) {
self.size = size
self.seatWidth = seatWidth
self.seatHeight = seatHeight
self.compact = compact
}
@MainActor public init(size: CGSize, metrics: Metrics) {
self.init(size: size, seatWidth: metrics.seatWidth,
seatHeight: metrics.ringSeatHeight, compact: metrics.compact)
}
public var radiusX: CGFloat { size.width / 2 - seatWidth * 0.62 }
/// Enough room for a seat box to sit fully on the felt at the top and the bottom.
public var radiusY: CGFloat {
size.height / 2 - max(compact ? 46 : 58, seatHeight / 2 + 8)
}
/// The player's place is the bottom of the ring and the rest run clockwise from there. Nothing
/// is drawn at the player's point: their cards and their name sit in a row under the felt
/// (`Metrics.heroCard`), so the ring's bottom is the gap the corner seats keep from that row.
public func point(seatsFromHero: Int, of count: Int) -> CGPoint {
guard count > 0 else { return .zero }
let angle = (.pi / 2) + Double(seatsFromHero) / Double(count) * 2 * .pi
return CGPoint(x: size.width / 2 + cos(angle) * radiusX,
y: size.height / 2 + sin(angle) * radiusY)
}
public func points(of count: Int) -> [CGPoint] {
(0..<max(count, 0)).map { point(seatsFromHero: $0, of: count) }
}
/// Do the opponents' seat boxes clear each other, and stay inside the felt?
///
/// Boxes are rectangles, so two of them are clear when they are a full box apart on either
/// axis — seats stacked up the side of the felt need height between them, not width. The
/// first point is the player's, and nothing is drawn there, so it is skipped.
public func holds(_ count: Int) -> Bool {
let placed = Array(points(of: count).dropFirst())
for (index, first) in placed.enumerated() {
if first.x - seatWidth / 2 < -0.5 || first.x + seatWidth / 2 > size.width + 0.5 { return false }
if first.y - seatHeight / 2 < -0.5 || first.y + seatHeight / 2 > size.height + 0.5 { return false }
for second in placed[(index + 1)...] {
let dx = abs(first.x - second.x), dy = abs(first.y - second.y)
if dx + 0.5 < seatWidth && dy + 0.5 < seatHeight { return false }
}
}
return true
}
}