belote-cli 2.9.0__tar.gz → 2.9.2__tar.gz

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.
Files changed (106) hide show
  1. {belote_cli-2.9.0 → belote_cli-2.9.2}/CHANGELOG.md +38 -0
  2. {belote_cli-2.9.0 → belote_cli-2.9.2}/PKG-INFO +27 -19
  3. {belote_cli-2.9.0 → belote_cli-2.9.2}/README.md +26 -18
  4. {belote_cli-2.9.0 → belote_cli-2.9.2}/pyproject.toml +1 -1
  5. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/__init__.py +1 -1
  6. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/ui/announce.py +15 -2
  7. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/ui/menu.py +39 -34
  8. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/ui/prompts.py +6 -64
  9. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/ui/render.py +96 -12
  10. {belote_cli-2.9.0 → belote_cli-2.9.2}/.claude/settings.local.json +0 -0
  11. {belote_cli-2.9.0 → belote_cli-2.9.2}/.gitignore +0 -0
  12. {belote_cli-2.9.0 → belote_cli-2.9.2}/.python-version +0 -0
  13. {belote_cli-2.9.0 → belote_cli-2.9.2}/DEVELOPMENT.md +0 -0
  14. {belote_cli-2.9.0 → belote_cli-2.9.2}/GRIMAUD Standard Playing-Cards-1898.png +0 -0
  15. {belote_cli-2.9.0 → belote_cli-2.9.2}/LICENSE +0 -0
  16. {belote_cli-2.9.0 → belote_cli-2.9.2}/scripts/benchmark.py +0 -0
  17. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/__init__.py +0 -0
  18. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/ai.py +0 -0
  19. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/ansi.py +0 -0
  20. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/__init__.py +0 -0
  21. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/core/__init__.py +0 -0
  22. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/core/economy.py +0 -0
  23. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/core/run_state.py +0 -0
  24. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/core/scoring.py +0 -0
  25. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/engine/__init__.py +0 -0
  26. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/engine/event_bus.py +0 -0
  27. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/engine/modifier_patch.py +0 -0
  28. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/engine/round_driver.py +0 -0
  29. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/items/__init__.py +0 -0
  30. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/items/base.py +0 -0
  31. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/items/jokers/__init__.py +0 -0
  32. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/items/jokers/annonces.py +0 -0
  33. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/items/jokers/coinche.py +0 -0
  34. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/items/jokers/contract.py +0 -0
  35. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/items/jokers/corrupted.py +0 -0
  36. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/items/jokers/economy.py +0 -0
  37. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/items/jokers/hand_comp.py +0 -0
  38. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/items/jokers/trick_timing.py +0 -0
  39. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/items/partner_jokers/__init__.py +0 -0
  40. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/items/partner_jokers/passive.py +0 -0
  41. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/items/partner_jokers/risky.py +0 -0
  42. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/items/partner_jokers/shaper.py +0 -0
  43. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/items/planets.py +0 -0
  44. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/items/registry.py +0 -0
  45. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/items/tarots.py +0 -0
  46. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/items/vouchers.py +0 -0
  47. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/main.py +0 -0
  48. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/partner/__init__.py +0 -0
  49. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/partner/partner_state.py +0 -0
  50. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/partner/personality.py +0 -0
  51. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/partner/trust.py +0 -0
  52. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/progression/__init__.py +0 -0
  53. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/progression/save.py +0 -0
  54. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/progression/unlocks.py +0 -0
  55. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/run/__init__.py +0 -0
  56. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/run/ante.py +0 -0
  57. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/run/ante_themes.py +0 -0
  58. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/run/boss.py +0 -0
  59. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/run/decks.py +0 -0
  60. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/run/shop.py +0 -0
  61. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/ui/__init__.py +0 -0
  62. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/ui/announce.py +0 -0
  63. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/ui/collection.py +0 -0
  64. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/ui/hud.py +0 -0
  65. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/ui/menu.py +0 -0
  66. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/ui/rules.py +0 -0
  67. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/ui/shop.py +0 -0
  68. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/belatro/ui/trust_bar.py +0 -0
  69. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/config.py +0 -0
  70. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/context.py +0 -0
  71. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/deck.py +0 -0
  72. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/game.py +0 -0
  73. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/gameflow.py +0 -0
  74. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/input.py +0 -0
  75. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/main.py +0 -0
  76. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/rules.py +0 -0
  77. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/scoring.py +0 -0
  78. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/stats.py +0 -0
  79. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/themes.py +0 -0
  80. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/ui/__init__.py +0 -0
  81. {belote_cli-2.9.0 → belote_cli-2.9.2}/src/belote/ui/layout.py +0 -0
  82. {belote_cli-2.9.0 → belote_cli-2.9.2}/tests/__init__.py +0 -0
  83. {belote_cli-2.9.0 → belote_cli-2.9.2}/tests/belatro/__init__.py +0 -0
  84. {belote_cli-2.9.0 → belote_cli-2.9.2}/tests/belatro/test_belatro.py +0 -0
  85. {belote_cli-2.9.0 → belote_cli-2.9.2}/tests/belatro/test_boss_modifiers_integration.py +0 -0
  86. {belote_cli-2.9.0 → belote_cli-2.9.2}/tests/belatro/test_collection_logic.py +0 -0
  87. {belote_cli-2.9.0 → belote_cli-2.9.2}/tests/belatro/test_contract_unlocks.py +0 -0
  88. {belote_cli-2.9.0 → belote_cli-2.9.2}/tests/belatro/test_dead_flag_fixes.py +0 -0
  89. {belote_cli-2.9.0 → belote_cli-2.9.2}/tests/belatro/test_deck_variants.py +0 -0
  90. {belote_cli-2.9.0 → belote_cli-2.9.2}/tests/belatro/test_partner_trust.py +0 -0
  91. {belote_cli-2.9.0 → belote_cli-2.9.2}/tests/belatro/test_phase0_coverage.py +0 -0
  92. {belote_cli-2.9.0 → belote_cli-2.9.2}/tests/belatro/test_phase1_plumbing.py +0 -0
  93. {belote_cli-2.9.0 → belote_cli-2.9.2}/tests/belatro/test_phase2_content.py +0 -0
  94. {belote_cli-2.9.0 → belote_cli-2.9.2}/tests/belatro/test_phase3_meta.py +0 -0
  95. {belote_cli-2.9.0 → belote_cli-2.9.2}/tests/belatro/test_progression.py +0 -0
  96. {belote_cli-2.9.0 → belote_cli-2.9.2}/tests/belatro/test_round_driver.py +0 -0
  97. {belote_cli-2.9.0 → belote_cli-2.9.2}/tests/test_ai.py +0 -0
  98. {belote_cli-2.9.0 → belote_cli-2.9.2}/tests/test_belote.py +0 -0
  99. {belote_cli-2.9.0 → belote_cli-2.9.2}/tests/test_extended.py +0 -0
  100. {belote_cli-2.9.0 → belote_cli-2.9.2}/tests/test_game_logic.py +0 -0
  101. {belote_cli-2.9.0 → belote_cli-2.9.2}/tests/test_gameflow.py +0 -0
  102. {belote_cli-2.9.0 → belote_cli-2.9.2}/tests/test_layout.py +0 -0
  103. {belote_cli-2.9.0 → belote_cli-2.9.2}/tests/test_new_coverage.py +0 -0
  104. {belote_cli-2.9.0 → belote_cli-2.9.2}/tests/test_official_rules.py +0 -0
  105. {belote_cli-2.9.0 → belote_cli-2.9.2}/tests/test_properties.py +0 -0
  106. {belote_cli-2.9.0 → belote_cli-2.9.2}/tests/test_undo.py +0 -0
@@ -5,6 +5,44 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.9.2] - 2026-05-07
9
+
10
+ Render-pipeline fix for Konsole (KDE/Kubuntu) and other strict ANSI terminals where UI elements visibly stacked on top of each other — the top HUD repeating ~6 times, "Theme: Sepia Vintage" duplicating in the right column, "Partner" doubling, the bid prompt repainting below itself, and bid history accumulating between frames. The bug existed in the code on every terminal but VTE-based emulators (LXTerminal, GNOME Terminal, xterm) auto-blanked the leaking cells, masking it. Konsole's Vt102Emulation does not, so the leakage was visible.
11
+
12
+ ### Fixed
13
+
14
+ - **`src/belote/ui/render.py::render`** — the bidding selector (Round 1 inline highlighted Take/Pass; Round 2 boxed grid; optional `partner_bid_tendency_text` line) is now painted inside the main render frame via the new `bid_selection: int | None` parameter on `render()` and `display()`. Previously `prompt_bid` paid display() and then wrote 8+ extra `\r\n` lines, which scrolled the alt-screen on the bottom row and left stale rows that the next frame's blank padding never repainted.
15
+ - **`src/belote/ui/render.py::render`** — every line in the rendered frame now ends with `clear_to_eol()` (`\x1b[K`), including blank vertical-centering padding rows. The previous `line + clear_to_eol() if line else line` branch skipped the escape on padding rows, betting the previous frame's content area had already cleared them — but that bet broke whenever an external write (announcement, prompt artifact) deposited debris on a padding row. Cost is one 3-byte escape per row; benefit is correct rendering on any ANSI-compliant terminal regardless of strictness.
16
+ - **`src/belote/ui/prompts.py::prompt_bid`** — gutted of all post-`display()` `sys.stdout.write` calls. Each loop iteration is now a single `display(state, None, bid_selection=sel)` + `reader.read()`. Removed unused `REVERSE` and `black_fg` imports (the box-rendering code that consumed them moved to `render.py::_build_bid_prompt_lines`).
17
+ - **`src/belote/ui/announce.py::announce`** — replaced the `\r\n`-bracketed banner with absolute cursor positioning (`move(term_h - 1, 1) + clear_line()`) so the banner can never trigger a scroll, even when the cursor is parked on the bottom row of the alt-screen.
18
+
19
+ ### Added
20
+
21
+ - **`src/belote/ui/render.py::_build_bid_prompt_lines`** — pure helper that returns the in-frame bidding selector lines. Encapsulates the Round 1 inline form, the Round 2 60-column boxed form, and the optional partner-tendency line so the main render loop reads as one coherent paint.
22
+
23
+ ### Notes
24
+
25
+ No gameplay, scoring, or AI changes. 435/435 tests pass. The fix is observable when running under Konsole — start a Belote round, force a Round 2 bid (pass on the up-card), and arrow-navigate the selector: previously the box stacked between iterations; now it repaints in place.
26
+
27
+ ## [2.9.1] - 2026-05-06
28
+
29
+ UI polish patch on top of 2.9.0. Two pieces of menu art had drifted; this release fixes both and tightens the menu plumbing so the cup walls hold for every label combination.
30
+
31
+ ### Fixed
32
+
33
+ - **`src/belote/ui/menu.py::get_cards_art`** — the croissant Braille had a corrupted line 8 (`⠘⢿⡿⠋⣠⣾⣿⣿⣿⠟⠁⣿⣿⣿⣿⣿⠟⢁⣀`, 21 cells) that broke the right curl, and was missing the 13th-line `⠉⠉⠉` crumb under the tip entirely. Restored to the canonical 13-line, 25-cell-wide reference. Each line now uses U+2800 Braille blanks for its leading indent (uniform 25-cell width) rather than mixed ASCII spaces, so callers don't have to re-pad.
34
+ - **`src/belote/ui/menu.py::CUP_TEMPLATE`** — body inner width was 47 chars (29-char opt + 16 trailing dead space + 2 gutter), much wider than the 23-char lid and saucer. Now 29 chars flush so menu options sit between the cup walls with no padding. Steam line 2 now correctly shows the two-puff frame (`) (`) — previously the template's leading-indent disagreement with `STEAMS[..][1]` pushed the second puff out of view.
35
+ - **`src/belote/ui/menu.py::_render_main_menu_art`** — selected-row markers tightened from ` > {label} < ` (6-char overhead) to `> {label} <` (4-char overhead) so the selected row uses the same width budget as unselected (` {label} `, also 4-char overhead). Without this, a selected `Theme: < Sepia Vintage >` would bust the right cup wall.
36
+ - **`src/belote/ui/menu.py::show_main_menu`** — settings labels shortened to fit the new 25-char usable label width: `AI Config:` → `AI:`, `Target Score:` → `Target:`. `Speed:` and `Theme:` keep their names with tightened spacing. All four `<` markers still column-align.
37
+
38
+ ### Changed
39
+
40
+ - **`src/belote/ui/menu.py::_render_main_menu_art`** — opt-slot loop and assertion bound dropped from 12 → 9 to match the new template. The error message ("add opt slots to CUP_TEMPLATE") still points at the right fix if the menu ever grows past 9 entries.
41
+
42
+ ### Notes
43
+
44
+ No gameplay or scoring changes; classic and BelAtro behavior is bit-for-bit identical. 435/435 tests pass; ruff and mypy clean on `src/`. Pre-existing lint/type debt in `tests/` and `scripts/benchmark.py` (18 ruff, 69 mypy strict-mode annotations) is untouched and tracked separately.
45
+
8
46
  ## [2.9.0] - 2026-05-06
9
47
 
10
48
  Audit-driven sweep on top of 2.8.0: four engine bugs fixed and the long-deferred Tout Atout / Sans Atout bidding affordance shipped end-to-end. The README's "future work" line for those contracts is gone — both contracts are now bidable in classic Belote and BelAtro, and the two jokers / two unlock counters that had been waiting on the affordance now fire in real play. Plan: `plans/bug-hunt-code-performance-elegant-starlight.md`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: belote-cli
3
- Version: 2.9.0
3
+ Version: 2.9.2
4
4
  Summary: A 4-player terminal card game
5
5
  Project-URL: Homepage, https://github.com/ElysiumDisc/belote
6
6
  Project-URL: Repository, https://github.com/ElysiumDisc/belote
@@ -88,18 +88,19 @@ belatro
88
88
 
89
89
  ### Main Menu
90
90
  ```text
91
- ⢠⣴⣶⣶⣶⣄
92
- ⣿⣿⣿⣿⣿⣿⣦
93
- ⢰⣿⣿⣿⣿⡿⠟⠁⣠⣴⣶⣦⠄
94
- ⢸⣿⣿⠟⠉⣠⣴⣿⣿⣿⠟⠁⣠⣾⣿⣦⡀
95
- ⠉⣀⣴⣾⣿⣿⣿⠟⢁⣤⣾⣿⣿⣿⣿⣿⡆
96
- ⢀⣤⣾⣿⣿⣿⡿⠛⢁⣴⣿⣿⣿⡿⠛⢁⣴⣿⣿⣿⣿⣿⣿⣿⠟⠁⡀
97
- ⢼⣿⣿⣿⡿⠋⣀⣴⣿⣿⣿⣿⣿⣿⣿⡿⠉⣠⣾⣿⡆
98
- ⠘⢿⡿⠋⣠⣾⣿⣿⣿⠟⠁⣿⣿⣿⣿⣿⠟⢁⣀
99
- ⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⠏⢀⣴⣿⣿⣿⠋⢠⣾⣿⣷⣦⡀
100
- ⢻⣿⣿⣿⣿⣿⣿⣿⠟⢁⣴⣿⣿⣿⡿⠁⣰⣿⣿⣿⣿⣿⣿
101
- ⠹⢿⣿⣿⣿⡿⠋⣠⣾⣿⣿⣿⠟⢀⣼⣿⣿⣿⣿⣿⣿⡟
102
- ⠉⠉⠉⠀⢾⣿⣿⣿⣿⠋⠀⠚⠛⠛⠛⠛⠛⠛⠁
91
+ ⢠⣴⣶⣶⣶⣄
92
+ ⣿⣿⣿⣿⣿⣿⣦
93
+ ⢰⣿⣿⣿⣿⡿⠟⠁⣠⣴⣶⣦⠄
94
+ ⢸⣿⣿⠟⠉⣠⣴⣿⣿⣿⠟⠁⣠⣾⣿⣦⡀
95
+ ⠉⣀⣴⣾⣿⣿⣿⠟⢁⣤⣾⣿⣿⣿⣿⣿⡆
96
+ ⢀⣤⣾⣿⣿⣿⡿⠛⢁⣴⣿⣿⣿⣿⣿⣿⣿⠟⠁⡀
97
+ ⢼⣿⣿⣿⡿⠋⣀⣴⣿⣿⣿⣿⣿⣿⣿⡿⠉⣠⣾⣿⡆
98
+ ⠘⢿⡿⠋⣠⣾⣿⣿⣿⣿⣿⣿⣿⡿⠋⢀⣾⣿⣿⠟⢁⣀
99
+ ⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⠏⢀⣴⣿⣿⣿⠋⢠⣾⣿⣷⣦⡀
100
+ ⢻⣿⣿⣿⣿⣿⣿⣿⠟⢁⣴⣿⣿⣿⡿⠁⣰⣿⣿⣿⣿⣿⣿
101
+ ⠹⢿⣿⣿⣿⡿⠋⣠⣾⣿⣿⣿⠟⢀⣼⣿⣿⣿⣿⣿⣿⡟
102
+ ⠉⠉⠉ ⢾⣿⣿⣿⣿⠋ ⠚⠛⠛⠛⠛⠛⠛⠁
103
+ ⠉⠉⠉
103
104
 
104
105
  (
105
106
  ) (
@@ -107,12 +108,17 @@ belatro
107
108
  .-'' ) ( ''-.
108
109
  .-'``'|-._ ) _.-|
109
110
  / .--.| `''---...........---''` |
110
- / / | > Start Game < |
111
- | | | Difficulty: Medium |
112
- \ \ | Target Score: 1000 |
113
- `\ `\ | Speed: Normal |
114
- `\ `| Rules & History |
115
- _/ /\ Quit /
111
+ / / | BelAtro |
112
+ | | | > Start Game < |
113
+ | | | AI: < Hard > |
114
+ | | | Target: < 1000 > |
115
+ | | | Speed: < Normal > |
116
+ | | | Theme: < Classic Green > |
117
+ | | | Rules & History |
118
+ \ \ | Statistics |
119
+ `\ `\ | Quit |
120
+ `\ `| |
121
+ _/ /\ /
116
122
  (__/ \ /
117
123
  _..---''` \ /`''---.._
118
124
  .-' \ / '-.
@@ -289,3 +295,5 @@ This will wipe all global statistics and reset your discovered item Almanac in B
289
295
  ## Terminal Hygiene
290
296
 
291
297
  Signal handlers (SIGINT, SIGTERM) and atexit hooks ensure the terminal is always restored — cursor visible, colors reset, alt-screen off — even after Ctrl+C or crashes.
298
+
299
+ Every rendered row ends with `\x1b[K` (clear-to-end-of-line) and every interactive prompt (bid selector, card selector, full-screen overlays) repaints in a single in-frame pass — no `\r\n`-bracketed writes outside the render. This keeps the game free of stale-cell artifacts on strict ANSI emulators like Konsole (KDE), in addition to the more lenient VTE-based terminals (GNOME Terminal, LXTerminal, xterm).
@@ -45,18 +45,19 @@ belatro
45
45
 
46
46
  ### Main Menu
47
47
  ```text
48
- ⢠⣴⣶⣶⣶⣄
49
- ⣿⣿⣿⣿⣿⣿⣦
50
- ⢰⣿⣿⣿⣿⡿⠟⠁⣠⣴⣶⣦⠄
51
- ⢸⣿⣿⠟⠉⣠⣴⣿⣿⣿⠟⠁⣠⣾⣿⣦⡀
52
- ⠉⣀⣴⣾⣿⣿⣿⠟⢁⣤⣾⣿⣿⣿⣿⣿⡆
53
- ⢀⣤⣾⣿⣿⣿⡿⠛⢁⣴⣿⣿⣿⡿⠛⢁⣴⣿⣿⣿⣿⣿⣿⣿⠟⠁⡀
54
- ⢼⣿⣿⣿⡿⠋⣀⣴⣿⣿⣿⣿⣿⣿⣿⡿⠉⣠⣾⣿⡆
55
- ⠘⢿⡿⠋⣠⣾⣿⣿⣿⠟⠁⣿⣿⣿⣿⣿⠟⢁⣀
56
- ⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⠏⢀⣴⣿⣿⣿⠋⢠⣾⣿⣷⣦⡀
57
- ⢻⣿⣿⣿⣿⣿⣿⣿⠟⢁⣴⣿⣿⣿⡿⠁⣰⣿⣿⣿⣿⣿⣿
58
- ⠹⢿⣿⣿⣿⡿⠋⣠⣾⣿⣿⣿⠟⢀⣼⣿⣿⣿⣿⣿⣿⡟
59
- ⠉⠉⠉⠀⢾⣿⣿⣿⣿⠋⠀⠚⠛⠛⠛⠛⠛⠛⠁
48
+ ⢠⣴⣶⣶⣶⣄
49
+ ⣿⣿⣿⣿⣿⣿⣦
50
+ ⢰⣿⣿⣿⣿⡿⠟⠁⣠⣴⣶⣦⠄
51
+ ⢸⣿⣿⠟⠉⣠⣴⣿⣿⣿⠟⠁⣠⣾⣿⣦⡀
52
+ ⠉⣀⣴⣾⣿⣿⣿⠟⢁⣤⣾⣿⣿⣿⣿⣿⡆
53
+ ⢀⣤⣾⣿⣿⣿⡿⠛⢁⣴⣿⣿⣿⣿⣿⣿⣿⠟⠁⡀
54
+ ⢼⣿⣿⣿⡿⠋⣀⣴⣿⣿⣿⣿⣿⣿⣿⡿⠉⣠⣾⣿⡆
55
+ ⠘⢿⡿⠋⣠⣾⣿⣿⣿⣿⣿⣿⣿⡿⠋⢀⣾⣿⣿⠟⢁⣀
56
+ ⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⠏⢀⣴⣿⣿⣿⠋⢠⣾⣿⣷⣦⡀
57
+ ⢻⣿⣿⣿⣿⣿⣿⣿⠟⢁⣴⣿⣿⣿⡿⠁⣰⣿⣿⣿⣿⣿⣿
58
+ ⠹⢿⣿⣿⣿⡿⠋⣠⣾⣿⣿⣿⠟⢀⣼⣿⣿⣿⣿⣿⣿⡟
59
+ ⠉⠉⠉ ⢾⣿⣿⣿⣿⠋ ⠚⠛⠛⠛⠛⠛⠛⠁
60
+ ⠉⠉⠉
60
61
 
61
62
  (
62
63
  ) (
@@ -64,12 +65,17 @@ belatro
64
65
  .-'' ) ( ''-.
65
66
  .-'``'|-._ ) _.-|
66
67
  / .--.| `''---...........---''` |
67
- / / | > Start Game < |
68
- | | | Difficulty: Medium |
69
- \ \ | Target Score: 1000 |
70
- `\ `\ | Speed: Normal |
71
- `\ `| Rules & History |
72
- _/ /\ Quit /
68
+ / / | BelAtro |
69
+ | | | > Start Game < |
70
+ | | | AI: < Hard > |
71
+ | | | Target: < 1000 > |
72
+ | | | Speed: < Normal > |
73
+ | | | Theme: < Classic Green > |
74
+ | | | Rules & History |
75
+ \ \ | Statistics |
76
+ `\ `\ | Quit |
77
+ `\ `| |
78
+ _/ /\ /
73
79
  (__/ \ /
74
80
  _..---''` \ /`''---.._
75
81
  .-' \ / '-.
@@ -246,3 +252,5 @@ This will wipe all global statistics and reset your discovered item Almanac in B
246
252
  ## Terminal Hygiene
247
253
 
248
254
  Signal handlers (SIGINT, SIGTERM) and atexit hooks ensure the terminal is always restored — cursor visible, colors reset, alt-screen off — even after Ctrl+C or crashes.
255
+
256
+ Every rendered row ends with `\x1b[K` (clear-to-end-of-line) and every interactive prompt (bid selector, card selector, full-screen overlays) repaints in a single in-frame pass — no `\r\n`-bracketed writes outside the render. This keeps the game free of stale-cell artifacts on strict ANSI emulators like Konsole (KDE), in addition to the more lenient VTE-based terminals (GNOME Terminal, LXTerminal, xterm).
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "belote-cli"
7
- version = "2.9.0"
7
+ version = "2.9.2"
8
8
  description = "A 4-player terminal card game"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,3 +1,3 @@
1
- __version__ = "2.8.0"
1
+ __version__ = "2.9.2"
2
2
 
3
3
  __all__ = ["__version__"]
@@ -12,9 +12,11 @@ from ..ansi import (
12
12
  ansi_center,
13
13
  banner_bg,
14
14
  banner_fg,
15
+ clear_line,
15
16
  clear_screen,
16
17
  gold_fg,
17
18
  hide_cursor,
19
+ move,
18
20
  white_fg,
19
21
  )
20
22
  from ..context import AUDIO
@@ -33,8 +35,19 @@ def toggle_mute() -> bool:
33
35
 
34
36
 
35
37
  def announce(message: str, duration: float = 2.0, reader: KeyReader | None = None) -> None:
36
- """Display a transient announcement banner."""
37
- sys.stdout.write(f"\r\n{banner_bg()}{banner_fg()} {BOLD} {message} {RESET}\r\n")
38
+ """Display a transient announcement banner.
39
+
40
+ Painted with absolute cursor positioning + clear_line so it never triggers
41
+ a scroll, even when the cursor is parked on the bottom row of the alt
42
+ screen. Writing \\r\\n at the bottom row scrolls the alt-screen on Konsole
43
+ and other strict emulators, which leaks the previous frame onto rows the
44
+ next render's blank padding doesn't repaint.
45
+ """
46
+ term_w, term_h = get_term_size()
47
+ banner = ansi_center(
48
+ f"{banner_bg()}{banner_fg()} {BOLD} {message} {RESET}", term_w
49
+ )
50
+ sys.stdout.write(move(max(1, term_h - 1), 1) + clear_line() + banner)
38
51
  sys.stdout.flush()
39
52
  if reader and duration > 0:
40
53
  interruptible_sleep(duration, reader)
@@ -25,43 +25,48 @@ from .render import get_term_size
25
25
 
26
26
 
27
27
  def get_cards_art() -> list[str]:
28
- """Return the cards logo art with current theme colors."""
28
+ """Return the croissant art with current theme colors.
29
+
30
+ Each line is 25 Braille cells wide (including U+2800 blanks for indent),
31
+ so callers can rely on uniform width without ASCII padding.
32
+ """
29
33
  c = menu_art_fg()
30
34
  return [
31
- f" {c}⢠⣴⣶⣶⣶⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀{RESET}",
32
- f" {c}⣿⣿⣿⣿⣿⣿⣦⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀{RESET}",
33
- f" {c}⢰⣿⣿⣿⣿⡿⠟⠁⣠⣴⣶⣦⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀{RESET}",
34
- f" {c}⢸⣿⣿⠟⠉⣠⣴⣿⣿⣿⠟⠁⣠⣾⣿⣦⡀⠀⠀⠀⠀⠀⠀⠀{RESET}",
35
- f" {c}⠉⣀⣴⣾⣿⣿⣿⠟⢁⣤⣾⣿⣿⣿⣿⣿⡆⠀⠀⠀⠀⠀⠀{RESET}",
36
- f" {c}⢀⣤⣾⣿⣿⣿⡿⠛⢁⣴⣿⣿⣿⣿⣿⣿⣿⠟⠁⡀⠀⠀⠀⠀⠀{RESET}",
37
- f" {c}⢼⣿⣿⣿⡿⠋⣀⣴⣿⣿⣿⣿⣿⣿⣿⡿⠉⣠⣾⣿⡆⠀⠀⠀⠀{RESET}",
38
- f" {c}⠘⢿⡿⠋⣠⣾⣿⣿⣿⠟⠁⣿⣿⣿⣿⣿⠟⢁⣀⠀⠀⠀{RESET}",
39
- f" {c}⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⠏⢀⣴⣿⣿⣿⠋⢠⣾⣿⣷⣦⡀{RESET}",
40
- f" {c}⢻⣿⣿⣿⣿⣿⣿⣿⠟⢁⣴⣿⣿⣿⡿⠁⣰⣿⣿⣿⣿⣿⣿{RESET}",
41
- f" {c}⠹⢿⣿⣿⣿⡿⠋⣠⣾⣿⣿⣿⠟⢀⣼⣿⣿⣿⣿⣿⣿⡟{RESET}",
42
- f" {c}⠉⠉⠉⠀⢾⣿⣿⣿⣿⠋⠀⠚⠛⠛⠛⠛⠛⠛⠁⠀{RESET}",
35
+ f"{c}⠀⠀⢠⣴⣶⣶⣶⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀{RESET}",
36
+ f"{c}⠀⠀⣿⣿⣿⣿⣿⣿⣦⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀{RESET}",
37
+ f"{c}⠀⢰⣿⣿⣿⣿⡿⠟⠁⣠⣴⣶⣦⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀{RESET}",
38
+ f"{c}⠀⢸⣿⣿⠟⠉⣠⣴⣿⣿⣿⠟⠁⣠⣾⣿⣦⡀⠀⠀⠀⠀⠀⠀⠀{RESET}",
39
+ f"{c}⠀⠀⠉⣀⣴⣾⣿⣿⣿⠟⢁⣤⣾⣿⣿⣿⣿⣿⡆⠀⠀⠀⠀⠀⠀{RESET}",
40
+ f"{c}⢀⣤⣾⣿⣿⣿⡿⠛⢁⣴⣿⣿⣿⣿⣿⣿⣿⠟⠁⡀⠀⠀⠀⠀⠀{RESET}",
41
+ f"{c}⢼⣿⣿⣿⡿⠋⣀⣴⣿⣿⣿⣿⣿⣿⣿⡿⠉⣠⣾⣿⡆⠀⠀⠀⠀{RESET}",
42
+ f"{c}⠘⢿⡿⠋⣠⣾⣿⣿⣿⣿⣿⣿⣿⡿⠋⢀⣾⣿⣿⠟⢁⣀⠀⠀⠀{RESET}",
43
+ f"{c}⠀⠀⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⠏⢀⣴⣿⣿⣿⠋⢠⣾⣿⣷⣦⡀{RESET}",
44
+ f"{c}⠀⠀⢻⣿⣿⣿⣿⣿⣿⣿⠟⢁⣴⣿⣿⣿⡿⠁⣰⣿⣿⣿⣿⣿⣿{RESET}",
45
+ f"{c}⠀⠀⠀⠹⢿⣿⣿⣿⡿⠋⣠⣾⣿⣿⣿⠟⢀⣼⣿⣿⣿⣿⣿⣿⡟{RESET}",
46
+ f"{c}⠀⠀⠀⠀⠀⠉⠉⠉⠀⢾⣿⣿⣿⣿⠋⠀⠚⠛⠛⠛⠛⠛⠛⠁⠀{RESET}",
47
+ f"{c}⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠉⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀{RESET}",
43
48
  ]
44
49
 
45
50
 
46
51
  CUP_TEMPLATE = [
47
- " {steam0}",
48
- " {steam1}",
52
+ " {steam0}",
53
+ " {steam1}",
49
54
  " {gold}___...(-------)-....___{reset}",
50
55
  " {gold}.-'' ) ( ''-.{reset}",
51
56
  " {gold}.-'``'|-._ ) _.-|{reset}",
52
57
  " {gold}/ .--.| `''---...........---''` |{reset}",
53
- " {gold}/ / | {opt0} |{reset}",
54
- " {gold}| | | {opt1} |{reset}",
55
- " {gold}| | | {opt2} |{reset}",
56
- " {gold}| | | {opt3} |{reset}",
57
- " {gold}| | | {opt4} |{reset}",
58
- " {gold}| | | {opt5} |{reset}",
59
- " {gold}| | | {opt6} |{reset}",
60
- " {gold}\\ \\ | {opt7} |{reset}",
61
- " {gold}`\\ `\\ | {opt8} |{reset}",
62
- " {gold}`\\ `| {opt9} |{reset}",
63
- " {gold}_/ /\\ {opt10} /{reset}",
64
- " {gold}(__/ \\ {opt11} /{reset}",
58
+ " {gold}/ / |{opt0}|{reset}",
59
+ " {gold}| | |{opt1}|{reset}",
60
+ " {gold}| | |{opt2}|{reset}",
61
+ " {gold}| | |{opt3}|{reset}",
62
+ " {gold}| | |{opt4}|{reset}",
63
+ " {gold}| | |{opt5}|{reset}",
64
+ " {gold}| | |{opt6}|{reset}",
65
+ " {gold}\\ \\ |{opt7}|{reset}",
66
+ " {gold}`\\ `\\ |{opt8}|{reset}",
67
+ " {gold}`\\ `| |{reset}",
68
+ " {gold}_/ /\\ /{reset}",
69
+ " {gold}(__/ \\ /{reset}",
65
70
  " {gold}_..---''` \\ /`''---.._{reset}",
66
71
  " {gold}.-' \\ / '-.{reset}",
67
72
  " {gold}: `-.__ __.-' :{reset}",
@@ -88,12 +93,12 @@ def _render_main_menu_art(sel: int, options: list[str], frame: int, term_h: int)
88
93
 
89
94
  # Process placeholders
90
95
  opts = {}
91
- assert len(options) <= 12, (
96
+ assert len(options) <= 9, (
92
97
  f"Too many menu options ({len(options)}); add opt slots to CUP_TEMPLATE"
93
98
  )
94
- for i in range(12):
99
+ for i in range(9):
95
100
  label = options[i] if i < len(options) else ""
96
- text = f"{REVERSE} > {label} < {RESET}" if i == sel else f" {label} "
101
+ text = f"{REVERSE}> {label} <{RESET}" if i == sel else f" {label} "
97
102
  opts[f"opt{i}"] = ansi_center(text, 29)
98
103
 
99
104
  final_cup = []
@@ -231,10 +236,10 @@ def show_main_menu(
231
236
  options_labels = [
232
237
  "BelAtro",
233
238
  "Start Game",
234
- f"AI Config: < {diff_display} >",
235
- f"Target Score: < {curr_target} >",
236
- f"Speed: < {curr_speed.capitalize()} >",
237
- f"Theme: < {theme_manager.get_current().name} >",
239
+ f"AI: < {diff_display} >",
240
+ f"Target: < {curr_target} >",
241
+ f"Speed: < {curr_speed.capitalize()} >",
242
+ f"Theme: < {theme_manager.get_current().name} >",
238
243
  "Rules & History",
239
244
  "Statistics",
240
245
  "Quit",
@@ -7,9 +7,7 @@ from ..ansi import (
7
7
  BOLD,
8
8
  DIM,
9
9
  RESET,
10
- REVERSE,
11
10
  ansi_center,
12
- black_fg,
13
11
  clear_screen,
14
12
  gold_fg,
15
13
  green_fg,
@@ -123,81 +121,25 @@ def prompt_bid(state: GameState, reader: KeyReader) -> Suit | str | None:
123
121
  Round 2 offers Tout Atout (TA) and Sans Atout (SA) in addition to the
124
122
  three remaining card suits. Per FFBelote rules, round 1 is "take the
125
123
  up-card suit at the standard contract" only — TA/SA aren't offered there.
124
+
125
+ The selector UI is painted by render() (via display(..., bid_selection=sel))
126
+ so each frame is a single in-place repaint. Writing additional lines after
127
+ display() would scroll the alt-screen on stricter terminals (Konsole) and
128
+ leak previous frames onto blank padding rows.
126
129
  """
127
130
  from ..game import SANS_ATOUT_BID
128
131
 
129
132
  if state.bidding_round == 1:
130
- # Round 1: Take (up_card suit) or Pass
131
133
  options: list[Suit | str | None] = [state.up_card.suit, None] # type: ignore[union-attr]
132
- labels = [f"Take {state.up_card.suit.symbol}", "Pass"] # type: ignore[union-attr]
133
134
  else:
134
- # Round 2: Any classic suit except up_card's, plus Tout Atout (every
135
- # suit acts as trump) and Sans Atout (no trump). All round-2 specials
136
- # gate to round 2 in `place_bid` — calling them in round 1 raises.
137
135
  all_suits = [Suit.SPADES, Suit.HEARTS, Suit.DIAMONDS, Suit.CLUBS]
138
136
  other_suits = [s for s in all_suits if s != state.up_card.suit] # type: ignore[union-attr]
139
137
  options = [*other_suits, Suit.TOUT_ATOUT, SANS_ATOUT_BID, None]
140
- labels = (
141
- [s.symbol for s in other_suits]
142
- + ["TA", "SA", "Pass"]
143
- )
144
138
 
145
139
  sel = 0
146
140
 
147
141
  while True:
148
- display(state, None)
149
- term_w, _ = get_term_size()
150
-
151
- # L'Encyclopédie voucher: surface partner bidding tendency before each bid.
152
- tendency = state._joker_state.get("partner_bid_tendency_text")
153
- if isinstance(tendency, str) and tendency:
154
- sys.stdout.write(
155
- "\r\n" + ansi_center(f"{DIM}{tendency}{RESET}", term_w) + "\r\n"
156
- )
157
-
158
- if state.bidding_round == 2:
159
- # Nice boxed UI for round 2. Widened to fit 7 options (♠/♥/♦/♣
160
- # minus up-card suit + TA + SA + Pass) on one row.
161
- inner_w = 60
162
- box_lines = [
163
- f"┌{'─' * inner_w}┐",
164
- f"│{f'ROUND {state.bidding_round} BID'.center(inner_w)}│",
165
- f"├{'─' * inner_w}┤",
166
- f"│{' ' * inner_w}│",
167
- ]
168
-
169
- # Options row
170
- opt_str = ""
171
- for i, opt in enumerate(options):
172
- lbl = labels[i]
173
- prefix = f"{BOLD}{gold_fg()}" if i == sel else ""
174
-
175
- # Add color to suit symbols
176
- color = ""
177
- if isinstance(opt, Suit):
178
- color = red_fg() if opt.is_red else black_fg()
179
-
180
- entry = f"{prefix}({i + 1}) {color}{lbl}{RESET}{prefix}"
181
- entry = f"{REVERSE} {entry} {RESET}" if i == sel else f" {entry} "
182
- opt_str += entry + " "
183
-
184
- box_lines.append(f"│{ansi_center(opt_str.strip(), inner_w)}│")
185
- box_lines.append(f"│{' ' * inner_w}│")
186
- box_lines.append(f"└{'─' * inner_w}┘")
187
-
188
- for bl in box_lines:
189
- sys.stdout.write("\r\n" + ansi_center(bl, term_w))
190
- sys.stdout.write("\r\n")
191
- else:
192
- # Round 1 simple prompt
193
- parts = [
194
- f"{BOLD}{gold_fg()}({i + 1}){lbl}{RESET}" if i == sel else f"({i + 1}){lbl}"
195
- for i, lbl in enumerate(labels)
196
- ]
197
- prompt = f"{BOLD}{white_fg()}Round {state.bidding_round} Bid: {' '.join(parts)}{RESET}"
198
- sys.stdout.write("\r\n" + ansi_center(prompt, term_w) + "\r\n")
199
-
200
- sys.stdout.flush()
142
+ display(state, None, bid_selection=sel)
201
143
 
202
144
  event = reader.read()
203
145
  match event.key:
@@ -9,6 +9,7 @@ from ..ansi import (
9
9
  BOLD,
10
10
  DIM,
11
11
  RESET,
12
+ REVERSE,
12
13
  UNDERLINE,
13
14
  ansi_center,
14
15
  ansi_ljust,
@@ -35,8 +36,9 @@ from ..ansi import (
35
36
  clear_screen as _clear_screen,
36
37
  )
37
38
  from ..context import TERMINAL
38
- from ..deck import Card, Rank
39
+ from ..deck import Card, Rank, Suit
39
40
  from ..game import (
41
+ SANS_ATOUT_BID,
40
42
  GameState,
41
43
  Phase,
42
44
  Seat,
@@ -562,13 +564,88 @@ def _build_hud(state: GameState, term_w: int, layout: LayoutPreset = STANDARD) -
562
564
  return bar + " " * pad + theme_label
563
565
 
564
566
 
565
- def render(state: GameState, selection: int | None = None, show_north_hand: bool = False) -> str:
567
+ def _build_bid_prompt_lines(state: GameState, term_w: int, bid_selection: int) -> list[str]:
568
+ """Build the in-frame bidding prompt: optional tendency line, then the
569
+ selector (Round 1: inline highlighted Take/Pass; Round 2: boxed grid).
570
+
571
+ Painted as part of the main render so no writes happen after `display()` —
572
+ that's what was causing the Konsole UI to stack (post-render \\r\\n's
573
+ scrolled the alt-screen, leaving stale content on rows the next frame's
574
+ blank padding doesn't repaint).
575
+ """
576
+ if state.bidding_round == 1:
577
+ up_card = state.up_card
578
+ assert up_card is not None # guaranteed by Phase.BIDDING + round 1
579
+ labels = [f"Take {up_card.suit.symbol}", "Pass"]
580
+ else:
581
+ all_suits = [Suit.SPADES, Suit.HEARTS, Suit.DIAMONDS, Suit.CLUBS]
582
+ up_card = state.up_card
583
+ assert up_card is not None
584
+ other_suits = [s for s in all_suits if s != up_card.suit]
585
+ labels = [s.symbol for s in other_suits] + ["TA", "SA", "Pass"]
586
+ round2_suits: list[Suit | str | None] = [
587
+ *other_suits,
588
+ Suit.TOUT_ATOUT,
589
+ SANS_ATOUT_BID,
590
+ None,
591
+ ]
592
+
593
+ lines: list[str] = []
594
+
595
+ tendency = state._joker_state.get("partner_bid_tendency_text")
596
+ if isinstance(tendency, str) and tendency:
597
+ lines.append(ansi_center(f"{DIM}{tendency}{RESET}", term_w))
598
+
599
+ if state.bidding_round == 2:
600
+ inner_w = 60
601
+ opt_str = ""
602
+ for i, lbl in enumerate(labels):
603
+ prefix = f"{BOLD}{gold_fg()}" if i == bid_selection else ""
604
+ color = ""
605
+ opt = round2_suits[i]
606
+ if isinstance(opt, Suit):
607
+ color = red_fg() if opt.is_red else black_fg()
608
+ entry = f"{prefix}({i + 1}) {color}{lbl}{RESET}{prefix}"
609
+ entry = f"{REVERSE} {entry} {RESET}" if i == bid_selection else f" {entry} "
610
+ opt_str += entry + " "
611
+
612
+ box = [
613
+ f"┌{'─' * inner_w}┐",
614
+ f"│{f'ROUND {state.bidding_round} BID'.center(inner_w)}│",
615
+ f"├{'─' * inner_w}┤",
616
+ f"│{' ' * inner_w}│",
617
+ f"│{ansi_center(opt_str.strip(), inner_w)}│",
618
+ f"│{' ' * inner_w}│",
619
+ f"└{'─' * inner_w}┘",
620
+ ]
621
+ lines.extend(ansi_center(bl, term_w) for bl in box)
622
+ else:
623
+ parts = [
624
+ f"{BOLD}{gold_fg()}({i + 1}){lbl}{RESET}" if i == bid_selection else f"({i + 1}){lbl}"
625
+ for i, lbl in enumerate(labels)
626
+ ]
627
+ prompt = f"{BOLD}{white_fg()}Round {state.bidding_round} Bid: {' '.join(parts)}{RESET}"
628
+ lines.append(ansi_center(prompt, term_w))
629
+
630
+ return lines
631
+
632
+
633
+ def render(
634
+ state: GameState,
635
+ selection: int | None = None,
636
+ show_north_hand: bool = False,
637
+ bid_selection: int | None = None,
638
+ ) -> str:
566
639
  """Pure: returns a full-screen ANSI-formatted string. No I/O.
567
640
 
568
641
  Terminal width is queried fresh on every call so resizing works correctly.
569
642
  The layout preset (compact / standard / spacious) is also picked fresh from
570
643
  the current dimensions, so resizing the terminal mid-game adapts on the
571
644
  next render.
645
+
646
+ `bid_selection` paints the bidding selector with the highlighted option
647
+ inside the frame. Pass `None` for non-interactive renders (the simple
648
+ static "Bid: [P]ass [1]♠..." hint shows instead).
572
649
  """
573
650
  term_w, term_h = get_term_size()
574
651
  layout = choose_layout(term_w, term_h)
@@ -657,8 +734,11 @@ def render(state: GameState, selection: int | None = None, show_north_hand: bool
657
734
  if state.phase == Phase.BIDDING and state.turn == Seat.SOUTH:
658
735
  if term_h > 40:
659
736
  lines.append("")
660
- prompt = f"{BOLD}{gold_fg()}Bid: [P]ass [1]♠ [2]♥ [3]♦ [4]♣{RESET}"
661
- lines.append(ansi_center(prompt, term_w))
737
+ if bid_selection is not None:
738
+ lines.extend(_build_bid_prompt_lines(state, term_w, bid_selection))
739
+ else:
740
+ prompt = f"{BOLD}{gold_fg()}Bid: [P]ass [1]♠ [2]♥ [3]♦ [4]♣{RESET}"
741
+ lines.append(ansi_center(prompt, term_w))
662
742
 
663
743
  # ── Vertical centering ──────────────────────────────────────────────────
664
744
  # If the terminal is taller than the rendered content, pad top + bottom so
@@ -670,12 +750,11 @@ def render(state: GameState, selection: int | None = None, show_north_hand: bool
670
750
  bottom_pad = slack - top_pad
671
751
  lines = [""] * top_pad + lines + [""] * bottom_pad
672
752
 
673
- # Only emit clear_to_eol on lines that actually have content; pure-padding
674
- # blank lines don't need it (we already cleared the screen on layout
675
- # changes and the previous render's content area was clear-to-eol'd).
676
- rendered_lines = [
677
- line + clear_to_eol() if line else line for line in lines[:term_h]
678
- ]
753
+ # Always emit clear_to_eol on every row, including blank padding. Konsole
754
+ # (and other strict emulators) don't auto-blank cells when an empty string
755
+ # passes through, so any debris from external writes (announcements, etc.)
756
+ # would remain visible. The cost is one extra 3-byte escape per row.
757
+ rendered_lines = [line + clear_to_eol() for line in lines[:term_h]]
679
758
  return "".join([out, "\r\n".join(rendered_lines), show_cursor()])
680
759
 
681
760
 
@@ -687,8 +766,13 @@ def display_hud(state: GameState) -> None:
687
766
  sys.stdout.flush()
688
767
 
689
768
 
690
- def display(state: GameState, selection: int | None = None, show_north_hand: bool = False) -> None:
691
- sys.stdout.write(render(state, selection, show_north_hand))
769
+ def display(
770
+ state: GameState,
771
+ selection: int | None = None,
772
+ show_north_hand: bool = False,
773
+ bid_selection: int | None = None,
774
+ ) -> None:
775
+ sys.stdout.write(render(state, selection, show_north_hand, bid_selection=bid_selection))
692
776
  sys.stdout.flush()
693
777
 
694
778
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes