belote-cli 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
belote/ui.py ADDED
@@ -0,0 +1,825 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ import sys
5
+ import time
6
+
7
+ from .deck import Card, Rank, Suit
8
+ from .game import (
9
+ GameState,
10
+ Phase,
11
+ Seat,
12
+ TrickCard,
13
+ legal_cards,
14
+ team_of,
15
+ partner,
16
+ )
17
+ from .ansi import (
18
+ RESET, BOLD, DIM, REVERSE, UNDERLINE,
19
+ fg, clear_screen, hide_cursor, show_cursor,
20
+ felt_bg, red_fg, black_fg, card_face_bg, card_back_bg,
21
+ highlight_bg, gold_fg, white_fg, light_gray_fg, green_fg,
22
+ banner_bg, banner_fg, visible_len, ansi_center, ansi_ljust,
23
+ face_card_bg,
24
+ )
25
+ from .input import KeyReader, KeyEvent, Key
26
+
27
+
28
+ # Card display dimensions — fixed constants only, no terminal queries at module level.
29
+ # Terminal width is queried fresh inside render() on every frame.
30
+ CARD_W = 6
31
+ CARD_H = 5
32
+ CARD_GAP = 1
33
+
34
+ # Fixed visible width for the WEST and EAST side columns in the middle section.
35
+ SIDE_COL_W = 22
36
+
37
+
38
+ def _card_symbol(card: Card) -> str:
39
+ return f"{card.rank.value}{card.suit.symbol}"
40
+
41
+
42
+ def _card_face(card: Card, selected: bool = False, legal: bool = True) -> list[str]:
43
+ """Render a card as CARD_H lines of width CARD_W."""
44
+ sym = card.rank.value + " " if len(card.rank.value) == 1 else card.rank.value
45
+ suit_sym = card.suit.symbol
46
+ face_text = f"{sym}{suit_sym}"
47
+
48
+ is_face = card.rank in (Rank.JACK, Rank.QUEEN, Rank.KING)
49
+
50
+ color = red_fg() if card.suit.is_red else black_fg()
51
+
52
+ if selected:
53
+ bg_code = highlight_bg()
54
+ elif is_face:
55
+ bg_code = face_card_bg()
56
+ else:
57
+ bg_code = card_face_bg()
58
+
59
+ prefix = "" if legal else DIM
60
+
61
+ inner_w = CARD_W - 2
62
+
63
+ # Optional miniature art for face cards
64
+ art = " "
65
+ if card.rank == Rank.JACK:
66
+ art = "⚔ "
67
+ elif card.rank == Rank.QUEEN:
68
+ art = "♕ "
69
+ elif card.rank == Rank.KING:
70
+ art = "♔ "
71
+ elif card.rank == Rank.ACE:
72
+ art = "★ "
73
+
74
+ return [
75
+ f"{prefix}{bg_code}{color}┌{'─' * inner_w}┐{RESET}",
76
+ f"{prefix}{bg_code}{color}│{face_text.ljust(inner_w)}│{RESET}",
77
+ f"{prefix}{bg_code}{color}│{art.center(inner_w)}│{RESET}",
78
+ f"{prefix}{bg_code}{color}│{face_text.rjust(inner_w)}│{RESET}",
79
+ f"{prefix}{bg_code}{color}└{'─' * inner_w}┘{RESET}",
80
+ ]
81
+
82
+
83
+ def _card_back() -> list[str]:
84
+ """Render a face-down card."""
85
+ inner_w = CARD_W - 2
86
+ hatch = "╳" * (inner_w // 2)
87
+ return [
88
+ f"{card_back_bg()}┌{'─' * inner_w}┐{RESET}",
89
+ f"{card_back_bg()}│{' ' * inner_w}│{RESET}",
90
+ f"{card_back_bg()}│{hatch.center(inner_w)}│{RESET}",
91
+ f"{card_back_bg()}│{' ' * inner_w}│{RESET}",
92
+ f"{card_back_bg()}└{'─' * inner_w}┘{RESET}",
93
+ ]
94
+
95
+
96
+ def _card_back_small() -> str:
97
+ """Single-line face-down card for opponent hand display."""
98
+ return f"{card_back_bg()}▓▓{RESET}"
99
+
100
+
101
+ def _felt_blank(width: int) -> str:
102
+ return felt_bg() + " " * width + RESET
103
+
104
+
105
+ def _felt_pad(content: str, width: int) -> str:
106
+ """Center content on a green felt background of `width` visible chars."""
107
+ vlen = visible_len(content)
108
+ total_pad = max(0, width - vlen)
109
+ lpad = total_pad // 2
110
+ rpad = total_pad - lpad
111
+ return felt_bg() + " " * lpad + RESET + content + felt_bg() + " " * rpad + RESET
112
+
113
+
114
+ def _felt_placeholder() -> list[str]:
115
+ """Card-sized dashed outline on the felt — shown for an empty trick slot."""
116
+ inner_w = CARD_W - 2
117
+ dim = fg(35, 130, 70)
118
+ top = felt_bg() + dim + "┌" + "─" * inner_w + "┐" + RESET
119
+ mid = felt_bg() + dim + "│" + " " * inner_w + "│" + RESET
120
+ bottom = felt_bg() + dim + "└" + "─" * inner_w + "┘" + RESET
121
+ return [top, mid, mid, mid, bottom]
122
+
123
+
124
+ def _render_trick_mat(seat_map: dict[Seat, Card], center_w: int) -> list[str]:
125
+ """21-row green felt mat with full card graphics at compass positions."""
126
+ def slot(seat: Seat) -> list[str]:
127
+ return _card_face(seat_map[seat]) if seat in seat_map else _felt_placeholder()
128
+
129
+ n_card = slot(Seat.NORTH)
130
+ w_card = slot(Seat.WEST)
131
+ e_card = slot(Seat.EAST)
132
+ s_card = slot(Seat.SOUTH)
133
+
134
+ # Horizontal anchors: West centred at ¼, East centred at ¾ of center_w
135
+ w_start = max(0, center_w // 4 - CARD_W // 2)
136
+ e_start = max(0, 3 * center_w // 4 - CARD_W // 2)
137
+ mid_gap = max(0, e_start - w_start - CARD_W)
138
+ r_pad = max(0, center_w - e_start - CARD_W)
139
+
140
+ n_label = _felt_pad(f"{light_gray_fg()}N{RESET}", center_w)
141
+ s_label = _felt_pad(f"{light_gray_fg()}S{RESET}", center_w)
142
+
143
+ rows: list[str] = []
144
+
145
+ rows.append(_felt_blank(center_w)) # top padding
146
+ rows.append(n_label) # N label
147
+ for line in n_card: # North card (5 rows)
148
+ rows.append(_felt_pad(line, center_w))
149
+ rows.append(_felt_blank(center_w)) # gap
150
+ for i in range(CARD_H): # West + East (5 rows)
151
+ rows.append(
152
+ felt_bg() + " " * w_start + RESET +
153
+ w_card[i] +
154
+ felt_bg() + " " * mid_gap + RESET +
155
+ e_card[i] +
156
+ felt_bg() + " " * r_pad + RESET
157
+ )
158
+ rows.append(_felt_blank(center_w)) # gap
159
+ for line in s_card: # South card (5 rows)
160
+ rows.append(_felt_pad(line, center_w))
161
+ rows.append(s_label) # S label
162
+ rows.append(_felt_blank(center_w)) # bottom padding
163
+
164
+ return rows # 21 rows total
165
+
166
+
167
+ def _render_hand_horizontal(
168
+ cards: tuple[Card, ...],
169
+ selection: int | None,
170
+ legal: tuple[Card, ...],
171
+ term_w: int,
172
+ ) -> list[str]:
173
+ """Render South's hand horizontally, already centered to term_w.
174
+
175
+ The cursor ▲ row is offset to match the visual center of the selected card,
176
+ accounting for the left-padding that ansi_center adds.
177
+ """
178
+ if not cards:
179
+ return [""]
180
+
181
+ # Build each card's CARD_H lines
182
+ card_line_groups: list[list[str]] = [
183
+ _card_face(c, selected=(i == selection), legal=(c in legal))
184
+ for i, c in enumerate(cards)
185
+ ]
186
+
187
+ # Join cards horizontally with a 1-space gap
188
+ gap = " "
189
+ slot_w = CARD_W + len(gap) # visible width of one card slot
190
+ total_hand_w = len(cards) * slot_w - len(gap) # visible width of full hand
191
+
192
+ # Compute left padding that ansi_center will add — we need this for the cursor
193
+ left_pad = max(0, (term_w - total_hand_w) // 2)
194
+
195
+ rows: list[str] = []
196
+ for row_idx in range(CARD_H):
197
+ raw = gap.join(group[row_idx] for group in card_line_groups)
198
+ rows.append(ansi_center(raw, term_w)) # ← ANSI-aware centering
199
+
200
+ # Cursor row — must account for the centering offset so ▲ lands under the card
201
+ if selection is not None:
202
+ cursor_col = left_pad + selection * slot_w + CARD_W // 2
203
+ rows.append(" " * cursor_col + "▲")
204
+
205
+ return rows
206
+
207
+
208
+ def _seat_label(seat: Seat, state: GameState) -> str:
209
+ """Colored seat label, highlighted when it's that seat's turn."""
210
+ name = seat.name
211
+ if state.turn == seat:
212
+ return f"{BOLD}{gold_fg()}{name} >>{RESET}"
213
+ return f"{BOLD}{white_fg()}{name}{RESET}"
214
+
215
+
216
+ def _render_middle_section(state: GameState, term_w: int) -> list[str]:
217
+ """Build the 3-column middle section: WEST | green felt mat | EAST."""
218
+ west_hand = state.hand_of(Seat.WEST)
219
+ east_hand = state.hand_of(Seat.EAST)
220
+
221
+ center_w = max(0, term_w - SIDE_COL_W * 2)
222
+
223
+ # ── Center column (trick area / bidding up-card) ──────────────────────────
224
+ center_rows = []
225
+ if state.phase == Phase.BIDDING and state.up_card:
226
+ # Show the up-card in the center
227
+ up_face = _card_face(state.up_card)
228
+ center_rows = [
229
+ ansi_center(f"{BOLD}{white_fg()}ROUND {state.bidding_round}{RESET}", 20),
230
+ "",
231
+ ansi_center(up_face[0], 20),
232
+ ansi_center(up_face[1], 20),
233
+ ansi_center(up_face[2], 20),
234
+ ansi_center(up_face[3], 20),
235
+ ansi_center(up_face[4], 20),
236
+ "",
237
+ ]
238
+ # Pad to match trick mat height if needed
239
+ while len(center_rows) < 21: # Trick mat is 21 rows
240
+ center_rows.insert(0, "")
241
+ if len(center_rows) < 21:
242
+ center_rows.append("")
243
+ else:
244
+ trick = state.current_trick
245
+ seat_map: dict[Seat, Card] = {tc.seat: tc.card for tc in trick}
246
+ center_rows = _render_trick_mat(seat_map, center_w)
247
+
248
+ n_rows = len(center_rows)
249
+
250
+ # ── Left column (WEST) — vertically centred in the mat ───────────────────
251
+ w_label = _seat_label(Seat.WEST, state)
252
+ w_cards = f"{_card_back_small()} " * min(len(west_hand), 4)
253
+ w_count = f"{light_gray_fg()}({len(west_hand)} left){RESET}"
254
+
255
+ left_rows: list[str] = [""] * n_rows
256
+ mid = n_rows // 2
257
+ left_rows[mid - 1] = w_label
258
+ left_rows[mid] = w_cards
259
+ left_rows[mid + 1] = w_count
260
+
261
+ # ── Right column (EAST) — vertically centred in the mat ──────────────────
262
+ e_label = _seat_label(Seat.EAST, state)
263
+ e_cards = f"{_card_back_small()} " * min(len(east_hand), 4)
264
+ e_count = f"{light_gray_fg()}({len(east_hand)} left){RESET}"
265
+
266
+ right_rows: list[str] = [""] * n_rows
267
+ right_rows[mid - 3] = e_label
268
+ right_rows[mid - 2] = e_cards
269
+ right_rows[mid - 1] = e_count
270
+
271
+ # Last Trick Panel
272
+ if state.completed_tricks:
273
+ last = state.completed_tricks[-1]
274
+ right_rows[mid + 1] = f"{UNDERLINE}Last Trick:{RESET}"
275
+ # Format as "S:7♠ E:J♦"
276
+ line1 = " ".join(f"{tc.seat.name[0]}:{_card_symbol(tc.card)}" for tc in last[:2])
277
+ line2 = " ".join(f"{tc.seat.name[0]}:{_card_symbol(tc.card)}" for tc in last[2:])
278
+ right_rows[mid + 2] = line1
279
+ right_rows[mid + 3] = line2
280
+
281
+ # ── Combine columns ───────────────────────────────────────────────────────
282
+ result: list[str] = []
283
+ for l, c, r in zip(left_rows, center_rows, right_rows):
284
+ result.append(ansi_ljust(l, SIDE_COL_W) + c + ansi_ljust(r, SIDE_COL_W))
285
+
286
+ return result
287
+
288
+
289
+ def _build_hud(state: GameState, term_w: int) -> str:
290
+ """Build the top HUD bar, padded to term_w visible chars."""
291
+ trump_sym = state.trump.symbol if state.trump else "?"
292
+ ns, ew = state.team_scores
293
+ trick_num = len(state.completed_tricks) + (1 if state.current_trick else 0)
294
+ taker_name = state.taker.name if state.taker else "-"
295
+
296
+ # Live round points
297
+ ns_round = state.tricks_won_by_team(0) # We'll need to count points instead of tricks
298
+ ew_round = state.tricks_won_by_team(1)
299
+
300
+ # Actually calculate card points for better 'live' feel
301
+ from .deck import card_points
302
+ ns_pts = 0
303
+ ew_pts = 0
304
+ for trick in state.completed_tricks:
305
+ from .game import trick_winner_seat, team_of
306
+ winner = trick_winner_seat(trick, state.trump)
307
+ p = sum(card_points(tc.card, state.trump) for tc in trick)
308
+ if winner and team_of(winner) == 0:
309
+ ns_pts += p
310
+ elif winner:
311
+ ew_pts += p
312
+
313
+ # Include Dix de Der in live score if 8 tricks are done
314
+ if len(state.completed_tricks) == 8:
315
+ from .game import trick_winner_seat, team_of
316
+ winner = trick_winner_seat(state.completed_tricks[-1], state.trump)
317
+ if winner and team_of(winner) == 0:
318
+ ns_pts += 10
319
+ elif winner:
320
+ ew_pts += 10
321
+
322
+ left = f"{BOLD}{gold_fg()}BELOTE{RESET}"
323
+ mid = (f"{white_fg()}Trump: {trump_sym} "
324
+ f"NS: {BOLD}{ns}{RESET}{white_fg()} (+{ns_pts}) "
325
+ f"EW: {BOLD}{ew}{RESET}{white_fg()} (+{ew_pts}) "
326
+ f"Trick {trick_num}/8 Taker: {taker_name}{RESET}")
327
+ bar = left + " " + mid
328
+ return ansi_ljust(bar, term_w)
329
+
330
+
331
+ def animate_score_update(state: GameState, target_ns: int, target_ew: int, duration: float = 1.0) -> None:
332
+ """Animate the team scores rolling up to their new values."""
333
+ import time
334
+ from .game import replace
335
+
336
+ start_ns, start_ew = state.team_scores
337
+ steps = 20
338
+ delay = duration / steps
339
+
340
+ for i in range(1, steps + 1):
341
+ curr_ns = start_ns + (target_ns - start_ns) * i // steps
342
+ curr_ew = start_ew + (target_ew - start_ew) * i // steps
343
+
344
+ temp_state = replace(state, team_scores=(curr_ns, curr_ew))
345
+ display(temp_state, None)
346
+ time.sleep(delay)
347
+
348
+
349
+ def render(state: GameState, selection: int | None = None) -> str:
350
+ """Pure: returns a full-screen ANSI-formatted string. No I/O.
351
+
352
+ Terminal width is queried fresh on every call so resizing works correctly.
353
+ """
354
+ # Query terminal size HERE, not at module level.
355
+ term_w, _ = shutil.get_terminal_size(fallback=(120, 40))
356
+
357
+ out = clear_screen() + hide_cursor()
358
+ legal : tuple[Card, ...] = ()
359
+ if state.phase == Phase.PLAYING and state.turn == Seat.SOUTH:
360
+ legal = legal_cards(state, Seat.SOUTH)
361
+
362
+ lines: list[str] = []
363
+
364
+ # ── HUD ──────────────────────────────────────────────────────────────────
365
+ lines.append(_build_hud(state, term_w))
366
+ lines.append("─" * term_w)
367
+
368
+ # ── NORTH ────────────────────────────────────────────────────────────────
369
+ north_hand = state.hand_of(Seat.NORTH)
370
+ north_cards = f"{_card_back_small()} " * min(len(north_hand), 4)
371
+ north_label = _seat_label(Seat.NORTH, state)
372
+ north_count = f"{light_gray_fg()}({len(north_hand)} cards){RESET}"
373
+ lines.append("")
374
+ lines.append(ansi_center(
375
+ f"{north_label} {north_cards} {north_count}", term_w
376
+ ))
377
+
378
+ # ── WEST | trick area | EAST (3-column, same rows) ──────────────────────
379
+ lines.append("")
380
+ for row in _render_middle_section(state, term_w):
381
+ lines.append(row)
382
+
383
+ # ── DIVIDER ───────────────────────────────────────────────────────────────
384
+ lines.append("")
385
+ lines.append("─" * term_w)
386
+
387
+ # ── PHASE INFO ────────────────────────────────────────────────────────────
388
+ phase_info = ""
389
+ if state.phase == Phase.BIDDING:
390
+ if state.turn == Seat.SOUTH:
391
+ phase_info = f"{BOLD}{gold_fg()}>> YOUR TURN <<{RESET}"
392
+ else:
393
+ phase_info = f"{light_gray_fg()}Waiting for {state.turn.name} to bid...{RESET}"
394
+ elif state.phase == Phase.PLAYING:
395
+ if state.turn == Seat.SOUTH:
396
+ phase_info = f"{BOLD}{gold_fg()}>> YOUR TURN <<{RESET}"
397
+ else:
398
+ phase_info = f"{light_gray_fg()}Waiting for {state.turn.name}...{RESET}"
399
+ elif state.phase == Phase.SCORING:
400
+ phase_info = f"{BOLD}{gold_fg()}Round complete!{RESET}"
401
+
402
+ if state.announced:
403
+ phase_info += f" {BOLD}{banner_bg()}{banner_fg()} {state.announced} {RESET}"
404
+
405
+ lines.append(ansi_center(phase_info, term_w))
406
+ lines.append("")
407
+
408
+ # ── SOUTH hand ────────────────────────────────────────────────────────────
409
+ south_hand = state.hand_of(Seat.SOUTH)
410
+ south_legal = legal if state.turn == Seat.SOUTH else ()
411
+
412
+ if south_hand:
413
+ # Keyboard shortcut hints
414
+ hints = " ".join(f"[{i+1}]" for i in range(len(south_hand)))
415
+ lines.append(ansi_center(hints, term_w))
416
+
417
+ # Cards + cursor (already centered inside _render_hand_horizontal)
418
+ for sl in _render_hand_horizontal(south_hand, selection, south_legal, term_w):
419
+ lines.append(sl)
420
+
421
+ south_label = _seat_label(Seat.SOUTH, state)
422
+ lines.append(ansi_center(f"{south_label} (you)", term_w))
423
+
424
+ # ── BIDDING PROMPT ────────────────────────────────────────────────────────
425
+ if state.phase == Phase.BIDDING and state.turn == Seat.SOUTH:
426
+ lines.append("")
427
+ prompt = f"{BOLD}{gold_fg()}Bid: [P]ass [1]♠ [2]♥ [3]♦ [4]♣{RESET}"
428
+ lines.append(ansi_center(prompt, term_w))
429
+
430
+ # Pad to minimum height
431
+ while len(lines) < 26:
432
+ lines.append("")
433
+
434
+ # CRITICAL: use \r\n not \n.
435
+ # tty.setraw() clears OPOST which disables ONLCR (automatic \n→\r\n).
436
+ # Without \r, the cursor moves DOWN but not back to column 1, so every
437
+ # subsequent line starts where the previous one ended — producing the
438
+ # diagonal stagger visible in the screenshot. \r\n fixes this.
439
+ return out + "\r\n".join(lines) + show_cursor()
440
+
441
+
442
+ def display(state: GameState, selection: int | None = None) -> None:
443
+ sys.stdout.write(render(state, selection))
444
+ sys.stdout.flush()
445
+
446
+
447
+ def prompt_card(state: GameState, reader: KeyReader) -> Card | None:
448
+ """Interactive card selection with arrow keys. Returns None if QUIT is pressed."""
449
+ hand = state.hand_of(Seat.SOUTH)
450
+ legal = legal_cards(state, Seat.SOUTH)
451
+
452
+ if not hand:
453
+ raise ValueError("No cards in hand")
454
+ if not legal:
455
+ return hand[0]
456
+
457
+ # Start selection on the first legal card
458
+ sel = next((i for i, c in enumerate(hand) if c in legal), 0)
459
+
460
+ while True:
461
+ display(state, sel)
462
+ event = reader.read()
463
+
464
+ match event.key:
465
+ case Key.QUIT:
466
+ return None
467
+ case Key.LEFT | Key.UP:
468
+ new = sel - 1
469
+ while new >= 0 and hand[new] not in legal:
470
+ new -= 1
471
+ if new >= 0:
472
+ sel = new
473
+ case Key.RIGHT | Key.DOWN:
474
+ new = sel + 1
475
+ while new < len(hand) and hand[new] not in legal:
476
+ new += 1
477
+ if new < len(hand):
478
+ sel = new
479
+ case Key.ENTER:
480
+ if hand[sel] in legal:
481
+ return hand[sel]
482
+ # Fallback: return nearest legal card
483
+ for delta in range(1, len(hand)):
484
+ for d in (delta, -delta):
485
+ idx = sel + d
486
+ if 0 <= idx < len(hand) and hand[idx] in legal:
487
+ return hand[idx]
488
+ case Key.CHAR:
489
+ if event.char and event.char.isdigit():
490
+ idx = int(event.char) - 1
491
+ if 0 <= idx < len(hand) and hand[idx] in legal:
492
+ return hand[idx]
493
+
494
+
495
+ def prompt_bid(state: GameState, reader: KeyReader) -> Suit | str | None:
496
+ """Interactive bid selection. Returns 'QUIT' if QUIT is pressed."""
497
+ if state.bidding_round == 1:
498
+ # Round 1: Take (up_card suit) or Pass
499
+ options = [state.up_card.suit, None] # type: ignore[union-attr]
500
+ labels = [f"Take {state.up_card.suit.symbol}", "Pass"] # type: ignore[union-attr]
501
+ else:
502
+ # Round 2: Any suit except up_card suit, or Pass
503
+ all_suits = [Suit.SPADES, Suit.HEARTS, Suit.DIAMONDS, Suit.CLUBS]
504
+ other_suits = [s for s in all_suits if s != state.up_card.suit] # type: ignore[union-attr]
505
+ options = other_suits + [None]
506
+ labels = [s.symbol for s in other_suits] + ["Pass"]
507
+
508
+ sel = 0
509
+
510
+ while True:
511
+ display(state, None)
512
+ term_w, _ = shutil.get_terminal_size(fallback=(120, 40))
513
+
514
+ if state.bidding_round == 2:
515
+ # Nice boxed UI for round 2
516
+ inner_w = 40
517
+ box_lines = [
518
+ f"┌{'─' * inner_w}┐",
519
+ f"│{f'ROUND {state.bidding_round} BID'.center(inner_w)}│",
520
+ f"├{'─' * inner_w}┤",
521
+ f"│{' ' * inner_w}│",
522
+ ]
523
+
524
+ # Options row
525
+ opt_str = ""
526
+ for i, opt in enumerate(options):
527
+ lbl = labels[i]
528
+ prefix = f"{BOLD}{gold_fg()}" if i == sel else ""
529
+ suffix = RESET
530
+
531
+ # Add color to suit symbols
532
+ color = ""
533
+ if isinstance(opt, Suit):
534
+ color = red_fg() if opt.is_red else black_fg()
535
+
536
+ entry = f"{prefix}({i+1}) {color}{lbl}{RESET}{prefix}"
537
+ if i == sel:
538
+ entry = f"{REVERSE} {entry} {RESET}"
539
+ else:
540
+ entry = f" {entry} "
541
+ opt_str += entry + " "
542
+
543
+ box_lines.append(f"│{ansi_center(opt_str.strip(), inner_w)}│")
544
+ box_lines.append(f"│{' ' * inner_w}│")
545
+ box_lines.append(f"└{'─' * inner_w}┘")
546
+
547
+ for bl in box_lines:
548
+ sys.stdout.write("\r\n" + ansi_center(bl, term_w))
549
+ sys.stdout.write("\r\n")
550
+ else:
551
+ # Round 1 simple prompt
552
+ parts = [
553
+ f"{BOLD}{gold_fg()}({i+1}){lbl}{RESET}" if i == sel else f"({i+1}){lbl}"
554
+ for i, lbl in enumerate(labels)
555
+ ]
556
+ prompt = f"{BOLD}{white_fg()}Round {state.bidding_round} Bid: {' '.join(parts)}{RESET}"
557
+ sys.stdout.write("\r\n" + ansi_center(prompt, term_w) + "\r\n")
558
+
559
+ sys.stdout.flush()
560
+
561
+ event = reader.read()
562
+ match event.key:
563
+ case Key.QUIT:
564
+ return "QUIT"
565
+ case Key.LEFT | Key.UP:
566
+ sel = (sel - 1) % len(options)
567
+ case Key.RIGHT | Key.DOWN:
568
+ sel = (sel + 1) % len(options)
569
+ case Key.ENTER:
570
+ return options[sel]
571
+ case Key.CHAR:
572
+ if event.char:
573
+ if event.char.lower() == 'p':
574
+ return None
575
+ try:
576
+ idx = int(event.char) - 1
577
+ if 0 <= idx < len(options):
578
+ return options[idx]
579
+ except ValueError:
580
+ pass
581
+
582
+
583
+ def show_rules(reader: KeyReader) -> None:
584
+ """Display scrollable rules and history in EN/FR."""
585
+ from .rules import RULES_CONTENT
586
+ lang = "en"
587
+ scroll = 0
588
+
589
+ while True:
590
+ term_w, term_h = shutil.get_terminal_size(fallback=(120, 40))
591
+ content = RULES_CONTENT[lang]
592
+
593
+ # Build all lines first
594
+ all_lines = []
595
+ all_lines.append(f"{BOLD}{gold_fg()}{content['title']}{RESET}")
596
+ all_lines.append("=" * visible_len(content['title']))
597
+ all_lines.append("")
598
+
599
+ for section in content['sections']:
600
+ all_lines.append(f"{BOLD}{white_fg()}{section['header']}{RESET}")
601
+ all_lines.append("-" * len(section['header']))
602
+ # Wrap text manually
603
+ words = section['text'].split()
604
+ line = " "
605
+ for w in words:
606
+ if len(line) + len(w) > 70:
607
+ all_lines.append(line)
608
+ line = " " + w + " "
609
+ else:
610
+ line += w + " "
611
+ all_lines.append(line)
612
+ all_lines.append("")
613
+
614
+ all_lines.append(f"{DIM}Press [T] to Toggle Language ({lang.upper()}) | [Q/Enter] Back{RESET}")
615
+
616
+ # Window the lines
617
+ view_h = term_h - 4
618
+ scroll = max(0, min(scroll, len(all_lines) - view_h))
619
+ visible_lines = all_lines[scroll : scroll + view_h]
620
+
621
+ out = clear_screen() + hide_cursor()
622
+ rendered = "\r\n".join(ansi_center(line, term_w) for line in visible_lines)
623
+ sys.stdout.write(out + rendered)
624
+ sys.stdout.flush()
625
+
626
+ event = reader.read()
627
+ match event.key:
628
+ case Key.QUIT | Key.ENTER | Key.ESC:
629
+ return
630
+ case Key.UP:
631
+ scroll = max(0, scroll - 1)
632
+ case Key.DOWN:
633
+ scroll = min(len(all_lines) - view_h, scroll + 1)
634
+ case Key.CHAR:
635
+ if event.char and event.char.lower() == 't':
636
+ lang = "fr" if lang == "en" else "en"
637
+ scroll = 0
638
+
639
+
640
+ def _render_main_menu_art(sel: int, options: list[str], frame: int) -> list[str]:
641
+ """Render the full main menu art with cards logo and chalice container."""
642
+ f = frame % 4
643
+ # Steam frames
644
+ steams = [
645
+ (" ( ", " ) (", " ( "),
646
+ (" ) ", " ( )", " ) "),
647
+ (" ( ", " ) (", " ( "),
648
+ (" ( ", " ) (", " ( ")
649
+ ]
650
+ st = steams[f]
651
+
652
+ g = gold_fg()
653
+ w = white_fg()
654
+
655
+ cards_art = [
656
+ f" {w}⢠⣴⣶⣶⣶⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀{RESET}",
657
+ f" {w}⣿⣿⣿⣿⣿⣿⣦⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀{RESET}",
658
+ f" {w}⢰⣿⣿⣿⣿⡿⠟⠁⣠⣴⣶⣦⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀{RESET}",
659
+ f" {w}⢸⣿⣿⠟⠉⣠⣴⣿⣿⣿⠟⠁⣠⣾⣿⣦⡀⠀⠀⠀⠀⠀⠀⠀{RESET}",
660
+ f" {w}⠉⣀⣴⣾⣿⣿⣿⠟⢁⣤⣾⣿⣿⣿⣿⣿⡆⠀⠀⠀⠀⠀⠀{RESET}",
661
+ f" {w}⢀⣤⣾⣿⣿⣿⡿⠛⢁⣴⣿⣿⣿⣿⣿⣿⣿⠟⠁⡀⠀⠀⠀⠀⠀{RESET}",
662
+ f" {w}⢼⣿⣿⣿⡿⠋⣀⣴⣿⣿⣿⣿⣿⣿⣿⡿⠉⣠⣾⣿⡆⠀⠀⠀⠀{RESET}",
663
+ f" {w}⠘⢿⡿⠋⣠⣾⣿⣿⣿⠟⠁⣿⣿⣿⣿⣿⠟⢁⣀⠀⠀⠀{RESET}",
664
+ f" {w}⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⠏⢀⣴⣿⣿⣿⠋⢠⣾⣿⣷⣦⡀{RESET}",
665
+ f" {w}⢻⣿⣿⣿⣿⣿⣿⣿⠟⢁⣴⣿⣿⣿⡿⠁⣰⣿⣿⣿⣿⣿⣿{RESET}",
666
+ f" {w}⠹⢿⣿⣿⣿⡿⠋⣠⣾⣿⣿⣿⠟⢀⣼⣿⣿⣿⣿⣿⣿⡟{RESET}",
667
+ f" {w}⠉⠉⠉⠀⢾⣿⣿⣿⣿⠋⠀⠚⠛⠛⠛⠛⠛⠛⠁⠀{RESET}",
668
+ ]
669
+
670
+ # The Vessel / Chalice frame template
671
+ cup = [
672
+ f" {w}{st[0]}{RESET}",
673
+ f" {w}{st[1]}{RESET}",
674
+ f" {g}___...(-------)-....___{RESET}",
675
+ f" {g}.-'' ) ( ''-.{RESET}",
676
+ f" {g}.-'``'|-._ ) _.-|{RESET}",
677
+ f" {g}/ .--.| `''---...........---''` |{RESET}",
678
+ f" {g}/ / | [[ OPT 0 ]] |{RESET}",
679
+ f" {g}| | | [[ OPT 1 ]] |{RESET}",
680
+ f" {g}\\ \\ | [[ OPT 2 ]] |{RESET}",
681
+ f" {g}`\\ `\\ | [[ OPT 3 ]] |{RESET}",
682
+ f" {g}`\\ `| [[ OPT 4 ]] |{RESET}",
683
+ f" {g}_/ /\\ [[ OPT 5 ]] /{RESET}",
684
+ f" {g}(__/ \\ /{RESET}",
685
+ f" {g}_..---''` \\ /`''---.._{RESET}",
686
+ f" {g}.-' \\ / '-.{RESET}",
687
+ f" {g}: `-.__ __.-' :{RESET}",
688
+ f" {g}: ) ''---...---'' ( :{RESET}",
689
+ f" {g}'._ `''...___...--''` _.'{RESET}",
690
+ f" {g}jgs \\''--..__ __..--''/{RESET}",
691
+ f" {g}'._ '''----.....______.....----''' _.'{RESET}",
692
+ f" {g}`''--..,,_____ _____,,..--''`{RESET}",
693
+ f" {g}`'''----'''`{RESET}",
694
+ ]
695
+
696
+ # Process placeholders
697
+ final_cup = []
698
+ for line in cup:
699
+ new_line = line
700
+ for i in range(6):
701
+ tag = f"[[ OPT {i} ]]"
702
+ if tag in line:
703
+ label = options[i] if i < len(options) else ""
704
+ if i == sel:
705
+ text = f"{REVERSE} > {label} < {RESET}"
706
+ else:
707
+ text = f" {label} "
708
+ # Center text in a fixed 29-character wide field
709
+ centered = ansi_center(text, 29)
710
+ new_line = line.replace(tag, centered)
711
+ final_cup.append(new_line)
712
+
713
+ return cards_art + [""] + final_cup
714
+
715
+
716
+ def show_main_menu(reader: KeyReader, difficulty: str, target: int, speed: str) -> tuple[str, str, int, str]:
717
+ """Display the main menu and return (choice, difficulty, target, speed)."""
718
+ curr_diff = difficulty
719
+ curr_target = target
720
+ curr_speed = speed
721
+
722
+ sel = 0
723
+ diffs = ["easy", "medium", "hard"]
724
+ targs = [500, 1000, 1500, 2000]
725
+ spds = ["slow", "normal", "fast", "instant"]
726
+ frame = 0
727
+
728
+ while True:
729
+ options_labels = [
730
+ "Start Game",
731
+ f"Difficulty: < {curr_diff.capitalize()} >",
732
+ f"Target Score: < {curr_target} >",
733
+ f"Speed: < {curr_speed.capitalize()} >",
734
+ "Rules & History",
735
+ "Quit"
736
+ ]
737
+
738
+ term_w, term_h = shutil.get_terminal_size(fallback=(120, 40))
739
+ out = clear_screen() + hide_cursor()
740
+
741
+ # Build the art containing the menu
742
+ all_lines = _render_main_menu_art(sel, options_labels, frame)
743
+
744
+ # Center the entire block vertically and horizontally
745
+ v_pad = max(0, (term_h - len(all_lines) - 2) // 2)
746
+ h_pad = "" # Horizontal centering handled by ansi_center within _render
747
+
748
+ lines = [""] * v_pad
749
+ for line in all_lines:
750
+ lines.append(ansi_center(line, term_w))
751
+
752
+ lines.append("")
753
+ lines.append(ansi_center(f"{light_gray_fg()}↑/↓: Navigate ←/→: Change Settings Enter: Confirm Q: Quit{RESET}", term_w))
754
+
755
+ sys.stdout.write(out + "\r\n".join(lines))
756
+ sys.stdout.flush()
757
+
758
+ event = reader.read_timeout(0.3)
759
+ if event is None:
760
+ frame += 1
761
+ continue
762
+
763
+ match event.key:
764
+ case Key.QUIT:
765
+ return "Quit", curr_diff, curr_target, curr_speed
766
+ case Key.UP:
767
+ sel = (sel - 1) % len(options_labels)
768
+ case Key.DOWN:
769
+ sel = (sel + 1) % len(options_labels)
770
+ case Key.LEFT | Key.RIGHT:
771
+ delta = 1 if event.key == Key.RIGHT else -1
772
+ if sel == 1:
773
+ curr_diff = diffs[(diffs.index(curr_diff) + delta) % len(diffs)]
774
+ elif sel == 2:
775
+ curr_target = targs[(targs.index(curr_target) + delta) % len(targs)]
776
+ elif sel == 3:
777
+ curr_speed = spds[(spds.index(curr_speed) + delta) % len(spds)]
778
+ case Key.ENTER:
779
+ choice = ["Start Game", "Difficulty", "Target Score", "Speed", "Rules & History", "Quit"][sel]
780
+ if choice in ("Start Game", "Quit", "Rules & History"):
781
+ return choice, curr_diff, curr_target, curr_speed
782
+ # For settings, Enter can also toggle forward
783
+ if sel == 1:
784
+ curr_diff = diffs[(diffs.index(curr_diff) + 1) % len(diffs)]
785
+ elif sel == 2:
786
+ curr_target = targs[(targs.index(curr_target) + 1) % len(targs)]
787
+ elif sel == 3:
788
+ curr_speed = spds[(spds.index(curr_speed) + 1) % len(spds)]
789
+
790
+
791
+ def announce(message: str, duration: float = 2.0) -> None:
792
+ """Display a transient announcement banner."""
793
+ import time
794
+ sys.stdout.write(f"\n{banner_bg()}{banner_fg()} {BOLD} {message} {RESET}\n")
795
+ sys.stdout.flush()
796
+ time.sleep(duration)
797
+
798
+
799
+ def show_final_screen(state: GameState) -> None:
800
+ """Display the game-over screen."""
801
+ ns, ew = state.team_scores
802
+ if ns >= state.target and ew >= state.target:
803
+ winner = "NS" if ns > ew else "EW"
804
+ elif ns >= state.target:
805
+ winner = "NS"
806
+ else:
807
+ winner = "EW"
808
+
809
+ lines = [
810
+ "",
811
+ f"{BOLD}{gold_fg()}{'=' * 50}{RESET}",
812
+ f"{BOLD}{gold_fg()} GAME OVER{RESET}",
813
+ f"{BOLD}{gold_fg()}{'=' * 50}{RESET}",
814
+ "",
815
+ f" {white_fg()}Team NS: {ns} points{RESET}",
816
+ f" {white_fg()}Team EW: {ew} points{RESET}",
817
+ "",
818
+ f" {BOLD}{gold_fg()}Winner: Team {winner}!{RESET}",
819
+ "",
820
+ f" {light_gray_fg()}Press Enter to exit{RESET}",
821
+ ]
822
+
823
+ sys.stdout.write(clear_screen())
824
+ sys.stdout.write("\n".join(lines))
825
+ sys.stdout.flush()