← poker-tutor
import SwiftUI
/// Poker words the player can tap to find out what they mean.
///
/// Two pieces, and one rule they share: a block of writing shows **one** definition at a time,
/// underneath itself. Tapping a second word replaces the card rather than stacking another one up,
/// and tapping the same word again puts it away.
///
/// `GlossaryText` marks up a paragraph — the first appearance of each word only, because a
/// paragraph with every occurrence underlined is a paragraph nobody reads. `TermChip` is a single
/// word the screen places deliberately: a statistic above a number, a seat name under a row.
///
/// Both work the same on a Mac and on a phone. The marked-up words are links carrying a scheme of
/// our own, and the block answers them itself, so a tap never leaves the app; the chips are
/// ordinary buttons. Everything is sized through `Theme`, so both follow the text-size setting, and
/// the only colours used are the palette's own.
/// One definition, opened in place under whatever was tapped.
///
/// The definition is itself marked up. Several of them lean on another entry — "trips" is explained
/// against "a set", "continuation bet" against "the flop" — and a definition that ends in a word the
/// reader has to go and look up somewhere else has sent them back where they started. Tapping a word
/// inside the card swaps the card for that word's, because both are bound to the same one piece of
/// state, so the cards can never stack up.
struct DefinitionCard: View {
let term: GlossaryTerm
@Binding var showing: GlossaryTerm?
var close: (() -> Void)?
var body: some View {
HStack(alignment: .top, spacing: 8) {
VStack(alignment: .leading, spacing: 3) {
Text(term.term).heading().foregroundStyle(Theme.gold)
GlossaryText(term.definition, size: TextRole.body.base, colour: Theme.text, showing: $showing)
}
Spacer(minLength: 0)
if let close {
Button(action: close) {
Symbol("xmark", size: Theme.size(10, capped: true))
.foregroundStyle(Theme.dim)
.padding(4)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
.padding(11)
.frame(maxWidth: .infinity, alignment: .leading)
.background(RoundedRectangle(cornerRadius: 9, style: .continuous).fill(Theme.panelRaised))
.overlay(RoundedRectangle(cornerRadius: 9, style: .continuous)
.stroke(Theme.gold.opacity(0.3), lineWidth: 1))
}
}
/// Where a block puts its definition. Nothing at all until a word is tapped.
struct DefinitionSlot: View {
@Binding var showing: GlossaryTerm?
var body: some View {
if let term = showing {
DefinitionCard(term: term, showing: $showing) { showing = nil }
.transition(.opacity)
}
}
}
@MainActor
private extension Text {
/// A term chip's type: on the felt (capped) it is a felt label; anywhere else it is a caption,
/// however small the caller used to ask for it.
func chipType(_ size: CGFloat, capped: Bool) -> Text {
capped ? label(size, capped: true) : caption(weight: .medium)
}
}
/// A paragraph with its poker words made tappable.
struct GlossaryText: View {
let text: String
var size: CGFloat = 12
var colour: Color = Theme.dim
@Binding var showing: GlossaryTerm?
init(_ text: String, size: CGFloat = 12, colour: Color = Theme.dim,
showing: Binding<GlossaryTerm?>) {
self.text = text
self.size = size
self.colour = colour
self._showing = showing
}
var body: some View {
prose
.body(Theme.coachBase(size))
.foregroundStyle(colour)
.fixedSize(horizontal: false, vertical: true)
.tint(Theme.gold)
.environment(\.openURL, OpenURLAction { url in
guard let term = Glossary.term(forLink: url) else { return .systemAction }
withAnimation(.easeOut(duration: 0.14)) { toggle(term) }
return .handled
})
}
private func toggle(_ term: GlossaryTerm) {
showing = showing?.id == term.id ? nil : term
}
/// The paragraph as a `Text`. Apple's SwiftUI takes attributed text; Skip's Android SwiftUI
/// cannot yet, but reads Markdown, and a Markdown link is the same tap target.
private var prose: Text {
#if os(Android)
Text(LocalizedStringKey(markedDown))
#else
Text(marked)
#endif
}
/// The writing as Markdown: each first mention becomes `[word](link)`, everything else escaped
/// so that a stray asterisk in the coach's prose cannot turn into emphasis.
private var markedDown: String {
func escaped(_ plain: Substring) -> String {
var out = ""
for ch in plain {
if "\\*_[]`<>#".contains(ch) { out.append("\\") }
out.append(ch)
}
return out
}
var out = ""
var cursor = text.startIndex
for mention in Glossary.mentions(in: text) {
out += escaped(text[cursor..<mention.range.lowerBound])
out += "[" + escaped(text[mention.range]) + "](" + mention.term.link.absoluteString + ")"
cursor = mention.range.upperBound
}
out += escaped(text[cursor...])
return out
}
/// The writing, with the first appearance of each word turned into a tap target. Dotted rather
/// than solid, and in the palette's own gold: visibly offered, not shouted.
private var marked: AttributedString {
var out = AttributedString()
var cursor = text.startIndex
for mention in Glossary.mentions(in: text) {
out.append(AttributedString(String(text[cursor..<mention.range.lowerBound])))
var word = AttributedString(String(text[mention.range]))
word.link = mention.term.link
#if !os(Android)
// SwiftUI's own text attributes. Skip's Android text has no equivalent yet, so there
// the words are links in the usual link colour rather than dotted gold.
word.foregroundColor = Theme.gold
word.underlineStyle = Text.LineStyle(pattern: .dot, color: Theme.gold.opacity(0.75))
#endif
out.append(word)
cursor = mention.range.upperBound
}
out.append(AttributedString(String(text[cursor...])))
return out
}
}
/// A single word the screen places itself — a statistic, a seat name, a label on a tile.
///
/// A word with no entry behind it draws as ordinary text rather than as a tap that does nothing.
/// That way a renamed entry degrades to plain writing on screen, and the test that walks the
/// glossary is what catches it.
struct TermChip: View {
let key: String
var display: String?
var size: CGFloat = 11
var colour: Color = Theme.dim
var capped = false
@Binding var showing: GlossaryTerm?
init(_ key: String, showing: Binding<GlossaryTerm?>, display: String? = nil,
size: CGFloat = 11, colour: Color = Theme.dim, capped: Bool = false) {
self.key = key
self._showing = showing
self.display = display
self.size = size
self.colour = colour
self.capped = capped
}
private var term: GlossaryTerm? { Glossary.look(up: key) }
private var words: String { display ?? term?.term ?? key }
var body: some View {
if let term {
Button {
withAnimation(.easeOut(duration: 0.14)) {
showing = showing?.id == term.id ? nil : term
}
} label: {
Text(words)
.chipType(size, capped: capped)
.underline(true, pattern: .dot, color: Theme.gold.opacity(0.75))
.foregroundStyle(showing?.id == term.id ? Theme.gold : colour)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.tooltip(term.definition)
} else {
Text(words).chipType(size, capped: capped).foregroundStyle(colour)
}
}
}
// MARK: - The whole list
/// Every word in one place, alphabetically, so the vocabulary can be browsed rather than only
/// stumbled on mid-hand. Opened from Settings.
struct GlossaryView: View {
@Environment(\.metrics) var metrics
@Environment(\.dismiss) var dismiss
@State var search = ""
private var shown: [GlossaryTerm] {
let query = search.trimmingCharacters(in: .whitespaces).lowercased()
guard !query.isEmpty else { return Glossary.alphabetical }
return Glossary.alphabetical.filter {
$0.term.lowercased().contains(query)
|| $0.definition.lowercased().contains(query)
|| $0.otherNames.contains { $0.lowercased().contains(query) }
}
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
Divider().overlay(Color.white.opacity(0.07))
ScrollView {
LazyVStack(alignment: .leading, spacing: 14) {
if shown.isEmpty {
Text(Copy.noWordMatches)
.body().foregroundStyle(Theme.dim)
.fixedSize(horizontal: false, vertical: true)
}
ForEach(shown) { term in
GlossaryEntryRow(term: term)
}
}
.padding(metrics.gutter)
}
}
.frame(minWidth: metrics.compact ? nil : 560, minHeight: metrics.compact ? nil : 520)
.background(Theme.background)
}
/// One entry on the browsable list, with its own definition slot.
///
/// A word inside a definition is tappable here for the same reason it is inside a card: the
/// definitions lean on each other, and dead-ending somebody in the one place they came to look
/// a word up is the worst place in the app to do it. Each row holds its own state, so the
/// answer opens under the entry that was asked rather than somewhere else on a long list.
struct GlossaryEntryRow: View {
let term: GlossaryTerm
@State var showing: GlossaryTerm?
var body: some View {
VStack(alignment: .leading, spacing: 3) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(term.term).heading().foregroundStyle(Theme.text)
if let other = term.otherNames.first {
Text("also called \(other)").caption().foregroundStyle(Theme.dim.opacity(0.8))
}
}
GlossaryText(term.definition, size: TextRole.body.base, colour: Theme.dim, showing: $showing)
DefinitionSlot(showing: $showing)
}
}
}
private var header: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 3) {
Text(Copy.pokerWordsTitle)
.font(.system(size: metrics.titleSize, weight: .bold, design: .rounded))
.foregroundStyle(Theme.text)
Text(Copy.pokerWordsIntro(Glossary.terms.count))
.body().foregroundStyle(Theme.dim)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 12)
GhostButton(title: "Done") { dismiss() }
}
HStack(spacing: 7) {
Symbol("magnifyingglass", size: Theme.size(11))
.foregroundStyle(Theme.dim)
TextField("Find a word", text: $search)
.textFieldStyle(.plain)
.font(.system(size: Theme.size(13), weight: .regular, design: .rounded))
.foregroundStyle(Theme.text)
.autocorrectionDisabled()
if !search.isEmpty {
Button { search = "" } label: {
Symbol("xmark.circle.fill", size: Theme.size(11))
.foregroundStyle(Theme.dim)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 12)
.frame(minHeight: metrics.compact ? 40 : 32)
.background(RoundedRectangle(cornerRadius: 9, style: .continuous).fill(Theme.panelRaised))
}
.padding(metrics.gutter)
}
}