chess-analyzer-tui 0.1.1__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Leonardo Laurindo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.4
2
+ Name: chess-analyzer-tui
3
+ Version: 0.1.1
4
+ Summary: Terminal chess analysis TUI with Stockfish.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/leolaurindo/chess-analyzer-tui
7
+ Project-URL: Source, https://github.com/leolaurindo/chess-analyzer-tui
8
+ Project-URL: Issues, https://github.com/leolaurindo/chess-analyzer-tui/issues
9
+ Keywords: chess,stockfish,terminal,tui,analysis
10
+ Classifier: Environment :: Console
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Games/Entertainment :: Board Games
13
+ Requires-Python: >=3.11
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ License-File: licenses/chess-tui-MIT.txt
17
+ Requires-Dist: chess>=1.11.2
18
+ Requires-Dist: textual>=8.2.8
19
+ Dynamic: license-file
20
+
21
+ # Chess Analyzer TUI
22
+
23
+ Interactive terminal chess analysis, with Stockfish as the default and support for other UCI engines.
24
+
25
+ Piece rendering adapted from [Thomas Mauran's chess-tui](https://github.com/thomas-mauran/chess-tui).
26
+ Full renderer credits and license information are below.
27
+
28
+ ## Install and run
29
+
30
+ Requires Python 3.11+.
31
+
32
+ Once published on PyPI, install the `chess-analyzer-tui` package with either tool
33
+ manager. Both install the `chess-analyzer` command (the command name is not a
34
+ separate PyPI package):
35
+
36
+ ```sh
37
+ uv tool install chess-analyzer-tui # or: pipx install chess-analyzer-tui
38
+ chess-analyzer
39
+ chess-analyzer --pgn game.pgn
40
+ chess-analyzer --white "Supi" --black "Carlsen" "2kr2nr/1pp2ppp/3b4/1P3q2/2Pp1B2/5Q1P/RP3PP1/R5K1 w - - 0 1"
41
+ ```
42
+
43
+ The command works from any directory. If it isn't on PATH, use
44
+ `uv tool update-shell` (or `pipx ensurepath`) and restart your shell.
45
+
46
+ Alternatively, pass a quoted FEN.
47
+
48
+ Options:
49
+
50
+ - `--ascii` — use ASCII pieces
51
+ - `--time 0.5` — set analysis time
52
+ - `--lines 3` — show multiple lines
53
+ - `--threads 2` — set engine threads, if supported
54
+ - `--hash 256` — set engine hash size, if supported
55
+ - `--engine /path/to/engine` — choose a UCI engine executable
56
+ - `--white "Supi"` / `--black "Carlsen"` — label the players (override PGN names)
57
+
58
+ ## Stockfish
59
+
60
+ Stockfish is the default engine and is installed separately. If it isn't found,
61
+ the app detects your OS (and Linux distribution) and suggests an installation command:
62
+
63
+ | Platform | Command |
64
+ | --- | --- |
65
+ | Debian/Ubuntu and derivatives (e.g. Linux Mint) | `sudo apt install stockfish` |
66
+ | Arch and derivatives (e.g. Manjaro) | `sudo pacman -S stockfish` |
67
+ | Fedora | `sudo dnf install stockfish` |
68
+ | macOS (Homebrew) | `brew install stockfish` |
69
+ | Windows (WinGet) | `winget install --id Stockfish.Stockfish --exact` |
70
+
71
+ Other distros/platforms get the [official download link](https://stockfishchess.org/download/).
72
+
73
+ After installation, make sure `stockfish` (`stockfish.exe` on Windows) is on
74
+ PATH: add the executable’s directory to PATH and restart your shell if needed.
75
+ Alternatively, pass an executable with `chess-analyzer --engine /path/to/stockfish`
76
+ (on Windows: `chess-analyzer --engine "C:\path\to\stockfish.exe"`).
77
+
78
+ Stockfish is found on PATH or beside the application module as `stockfish`
79
+ (`stockfish.exe` on Windows). Other UCI engines can be selected with
80
+ `--engine /path/to/engine`—for example, Leela Chess Zero (Lc0) with
81
+ `--engine /path/to/lc0`. Configure engine-specific files and settings, such as
82
+ Lc0's network weights and backend, separately. The app applies thread, hash,
83
+ and multiple-line settings only when the engine supports them.
84
+
85
+ ## Navigation
86
+
87
+ PGNs open at the final position and use their `White`/`Black` headers for player labels
88
+ when present. Use `--white` and `--black` to set or override names, including for FENs.
89
+
90
+ - **↑/↓** — choose an original move or engine alternative
91
+ - **→/Enter** — follow the selected move; **←** — step back
92
+ - **Esc** — return from an explored line to its game position
93
+ - **f** — flip board; **r** — reanalyze; **q** — quit
94
+
95
+ Moves, the return link, and any original-game move (or Start) are clickable.
96
+ Branches are retained, and navigation never waits for analysis.
97
+
98
+ Use at least 40×24 terminal cells. Larger boards use multiline pieces; smaller
99
+ ones use chess glyphs. Narrow layouts stack the panels; scroll with the mouse
100
+ wheel or Page Up/Down. Blue highlights the selected move, yellow the previous
101
+ move, and red a checked king.
102
+
103
+ ## Renderer credits
104
+
105
+ The piece artwork and size-adaptive rendering approach are adapted from
106
+ [Thomas Mauran's chess-tui](https://github.com/thomas-mauran/chess-tui), using its
107
+ [piece designs](https://github.com/thomas-mauran/chess-tui/tree/fc1d4841532bf72f5a25c5cb45abe82ec25e056b/src/pieces).
108
+ The artwork is used under the MIT License; the full notice is preserved in
109
+ [licenses/chess-tui-MIT.txt](licenses/chess-tui-MIT.txt). The rest of this project
110
+ is released under [MIT](LICENSE).
111
+
112
+ ## Verify
113
+
114
+ With Stockfish installed: `uv run python -m unittest discover -s tests -v`.
@@ -0,0 +1,94 @@
1
+ # Chess Analyzer TUI
2
+
3
+ Interactive terminal chess analysis, with Stockfish as the default and support for other UCI engines.
4
+
5
+ Piece rendering adapted from [Thomas Mauran's chess-tui](https://github.com/thomas-mauran/chess-tui).
6
+ Full renderer credits and license information are below.
7
+
8
+ ## Install and run
9
+
10
+ Requires Python 3.11+.
11
+
12
+ Once published on PyPI, install the `chess-analyzer-tui` package with either tool
13
+ manager. Both install the `chess-analyzer` command (the command name is not a
14
+ separate PyPI package):
15
+
16
+ ```sh
17
+ uv tool install chess-analyzer-tui # or: pipx install chess-analyzer-tui
18
+ chess-analyzer
19
+ chess-analyzer --pgn game.pgn
20
+ chess-analyzer --white "Supi" --black "Carlsen" "2kr2nr/1pp2ppp/3b4/1P3q2/2Pp1B2/5Q1P/RP3PP1/R5K1 w - - 0 1"
21
+ ```
22
+
23
+ The command works from any directory. If it isn't on PATH, use
24
+ `uv tool update-shell` (or `pipx ensurepath`) and restart your shell.
25
+
26
+ Alternatively, pass a quoted FEN.
27
+
28
+ Options:
29
+
30
+ - `--ascii` — use ASCII pieces
31
+ - `--time 0.5` — set analysis time
32
+ - `--lines 3` — show multiple lines
33
+ - `--threads 2` — set engine threads, if supported
34
+ - `--hash 256` — set engine hash size, if supported
35
+ - `--engine /path/to/engine` — choose a UCI engine executable
36
+ - `--white "Supi"` / `--black "Carlsen"` — label the players (override PGN names)
37
+
38
+ ## Stockfish
39
+
40
+ Stockfish is the default engine and is installed separately. If it isn't found,
41
+ the app detects your OS (and Linux distribution) and suggests an installation command:
42
+
43
+ | Platform | Command |
44
+ | --- | --- |
45
+ | Debian/Ubuntu and derivatives (e.g. Linux Mint) | `sudo apt install stockfish` |
46
+ | Arch and derivatives (e.g. Manjaro) | `sudo pacman -S stockfish` |
47
+ | Fedora | `sudo dnf install stockfish` |
48
+ | macOS (Homebrew) | `brew install stockfish` |
49
+ | Windows (WinGet) | `winget install --id Stockfish.Stockfish --exact` |
50
+
51
+ Other distros/platforms get the [official download link](https://stockfishchess.org/download/).
52
+
53
+ After installation, make sure `stockfish` (`stockfish.exe` on Windows) is on
54
+ PATH: add the executable’s directory to PATH and restart your shell if needed.
55
+ Alternatively, pass an executable with `chess-analyzer --engine /path/to/stockfish`
56
+ (on Windows: `chess-analyzer --engine "C:\path\to\stockfish.exe"`).
57
+
58
+ Stockfish is found on PATH or beside the application module as `stockfish`
59
+ (`stockfish.exe` on Windows). Other UCI engines can be selected with
60
+ `--engine /path/to/engine`—for example, Leela Chess Zero (Lc0) with
61
+ `--engine /path/to/lc0`. Configure engine-specific files and settings, such as
62
+ Lc0's network weights and backend, separately. The app applies thread, hash,
63
+ and multiple-line settings only when the engine supports them.
64
+
65
+ ## Navigation
66
+
67
+ PGNs open at the final position and use their `White`/`Black` headers for player labels
68
+ when present. Use `--white` and `--black` to set or override names, including for FENs.
69
+
70
+ - **↑/↓** — choose an original move or engine alternative
71
+ - **→/Enter** — follow the selected move; **←** — step back
72
+ - **Esc** — return from an explored line to its game position
73
+ - **f** — flip board; **r** — reanalyze; **q** — quit
74
+
75
+ Moves, the return link, and any original-game move (or Start) are clickable.
76
+ Branches are retained, and navigation never waits for analysis.
77
+
78
+ Use at least 40×24 terminal cells. Larger boards use multiline pieces; smaller
79
+ ones use chess glyphs. Narrow layouts stack the panels; scroll with the mouse
80
+ wheel or Page Up/Down. Blue highlights the selected move, yellow the previous
81
+ move, and red a checked king.
82
+
83
+ ## Renderer credits
84
+
85
+ The piece artwork and size-adaptive rendering approach are adapted from
86
+ [Thomas Mauran's chess-tui](https://github.com/thomas-mauran/chess-tui), using its
87
+ [piece designs](https://github.com/thomas-mauran/chess-tui/tree/fc1d4841532bf72f5a25c5cb45abe82ec25e056b/src/pieces).
88
+ The artwork is used under the MIT License; the full notice is preserved in
89
+ [licenses/chess-tui-MIT.txt](licenses/chess-tui-MIT.txt). The rest of this project
90
+ is released under [MIT](LICENSE).
91
+
92
+ ## Verify
93
+
94
+ With Stockfish installed: `uv run python -m unittest discover -s tests -v`.
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.4
2
+ Name: chess-analyzer-tui
3
+ Version: 0.1.1
4
+ Summary: Terminal chess analysis TUI with Stockfish.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/leolaurindo/chess-analyzer-tui
7
+ Project-URL: Source, https://github.com/leolaurindo/chess-analyzer-tui
8
+ Project-URL: Issues, https://github.com/leolaurindo/chess-analyzer-tui/issues
9
+ Keywords: chess,stockfish,terminal,tui,analysis
10
+ Classifier: Environment :: Console
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Games/Entertainment :: Board Games
13
+ Requires-Python: >=3.11
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ License-File: licenses/chess-tui-MIT.txt
17
+ Requires-Dist: chess>=1.11.2
18
+ Requires-Dist: textual>=8.2.8
19
+ Dynamic: license-file
20
+
21
+ # Chess Analyzer TUI
22
+
23
+ Interactive terminal chess analysis, with Stockfish as the default and support for other UCI engines.
24
+
25
+ Piece rendering adapted from [Thomas Mauran's chess-tui](https://github.com/thomas-mauran/chess-tui).
26
+ Full renderer credits and license information are below.
27
+
28
+ ## Install and run
29
+
30
+ Requires Python 3.11+.
31
+
32
+ Once published on PyPI, install the `chess-analyzer-tui` package with either tool
33
+ manager. Both install the `chess-analyzer` command (the command name is not a
34
+ separate PyPI package):
35
+
36
+ ```sh
37
+ uv tool install chess-analyzer-tui # or: pipx install chess-analyzer-tui
38
+ chess-analyzer
39
+ chess-analyzer --pgn game.pgn
40
+ chess-analyzer --white "Supi" --black "Carlsen" "2kr2nr/1pp2ppp/3b4/1P3q2/2Pp1B2/5Q1P/RP3PP1/R5K1 w - - 0 1"
41
+ ```
42
+
43
+ The command works from any directory. If it isn't on PATH, use
44
+ `uv tool update-shell` (or `pipx ensurepath`) and restart your shell.
45
+
46
+ Alternatively, pass a quoted FEN.
47
+
48
+ Options:
49
+
50
+ - `--ascii` — use ASCII pieces
51
+ - `--time 0.5` — set analysis time
52
+ - `--lines 3` — show multiple lines
53
+ - `--threads 2` — set engine threads, if supported
54
+ - `--hash 256` — set engine hash size, if supported
55
+ - `--engine /path/to/engine` — choose a UCI engine executable
56
+ - `--white "Supi"` / `--black "Carlsen"` — label the players (override PGN names)
57
+
58
+ ## Stockfish
59
+
60
+ Stockfish is the default engine and is installed separately. If it isn't found,
61
+ the app detects your OS (and Linux distribution) and suggests an installation command:
62
+
63
+ | Platform | Command |
64
+ | --- | --- |
65
+ | Debian/Ubuntu and derivatives (e.g. Linux Mint) | `sudo apt install stockfish` |
66
+ | Arch and derivatives (e.g. Manjaro) | `sudo pacman -S stockfish` |
67
+ | Fedora | `sudo dnf install stockfish` |
68
+ | macOS (Homebrew) | `brew install stockfish` |
69
+ | Windows (WinGet) | `winget install --id Stockfish.Stockfish --exact` |
70
+
71
+ Other distros/platforms get the [official download link](https://stockfishchess.org/download/).
72
+
73
+ After installation, make sure `stockfish` (`stockfish.exe` on Windows) is on
74
+ PATH: add the executable’s directory to PATH and restart your shell if needed.
75
+ Alternatively, pass an executable with `chess-analyzer --engine /path/to/stockfish`
76
+ (on Windows: `chess-analyzer --engine "C:\path\to\stockfish.exe"`).
77
+
78
+ Stockfish is found on PATH or beside the application module as `stockfish`
79
+ (`stockfish.exe` on Windows). Other UCI engines can be selected with
80
+ `--engine /path/to/engine`—for example, Leela Chess Zero (Lc0) with
81
+ `--engine /path/to/lc0`. Configure engine-specific files and settings, such as
82
+ Lc0's network weights and backend, separately. The app applies thread, hash,
83
+ and multiple-line settings only when the engine supports them.
84
+
85
+ ## Navigation
86
+
87
+ PGNs open at the final position and use their `White`/`Black` headers for player labels
88
+ when present. Use `--white` and `--black` to set or override names, including for FENs.
89
+
90
+ - **↑/↓** — choose an original move or engine alternative
91
+ - **→/Enter** — follow the selected move; **←** — step back
92
+ - **Esc** — return from an explored line to its game position
93
+ - **f** — flip board; **r** — reanalyze; **q** — quit
94
+
95
+ Moves, the return link, and any original-game move (or Start) are clickable.
96
+ Branches are retained, and navigation never waits for analysis.
97
+
98
+ Use at least 40×24 terminal cells. Larger boards use multiline pieces; smaller
99
+ ones use chess glyphs. Narrow layouts stack the panels; scroll with the mouse
100
+ wheel or Page Up/Down. Blue highlights the selected move, yellow the previous
101
+ move, and red a checked king.
102
+
103
+ ## Renderer credits
104
+
105
+ The piece artwork and size-adaptive rendering approach are adapted from
106
+ [Thomas Mauran's chess-tui](https://github.com/thomas-mauran/chess-tui), using its
107
+ [piece designs](https://github.com/thomas-mauran/chess-tui/tree/fc1d4841532bf72f5a25c5cb45abe82ec25e056b/src/pieces).
108
+ The artwork is used under the MIT License; the full notice is preserved in
109
+ [licenses/chess-tui-MIT.txt](licenses/chess-tui-MIT.txt). The rest of this project
110
+ is released under [MIT](LICENSE).
111
+
112
+ ## Verify
113
+
114
+ With Stockfish installed: `uv run python -m unittest discover -s tests -v`.
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ chess_tui.py
4
+ piece_art.py
5
+ pyproject.toml
6
+ chess_analyzer_tui.egg-info/PKG-INFO
7
+ chess_analyzer_tui.egg-info/SOURCES.txt
8
+ chess_analyzer_tui.egg-info/dependency_links.txt
9
+ chess_analyzer_tui.egg-info/entry_points.txt
10
+ chess_analyzer_tui.egg-info/requires.txt
11
+ chess_analyzer_tui.egg-info/top_level.txt
12
+ licenses/chess-tui-MIT.txt
13
+ tests/test_chess_tui.py
14
+ tests/test_cli.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ chess-analyzer = chess_tui:main
@@ -0,0 +1,2 @@
1
+ chess>=1.11.2
2
+ textual>=8.2.8
@@ -0,0 +1,2 @@
1
+ chess_tui
2
+ piece_art
@@ -0,0 +1,600 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import asyncio
6
+ import math
7
+ import os
8
+ import platform
9
+ import shutil
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+
13
+ import chess
14
+ import chess.engine
15
+ import chess.pgn
16
+ from rich.style import Style
17
+ from rich.text import Text
18
+ from textual import work
19
+ from textual.app import App, ComposeResult
20
+ from textual.binding import Binding
21
+ from textual.containers import Horizontal, Vertical, VerticalScroll
22
+ from textual.events import Resize
23
+ from textual.widget import Widget
24
+ from textual.widgets import Footer, Header, Static
25
+
26
+ from piece_art import PIECE_ART
27
+
28
+
29
+ @dataclass
30
+ class Candidate:
31
+ move: chess.Move
32
+ score: str
33
+ pv: str
34
+
35
+
36
+ @dataclass
37
+ class Node:
38
+ board: chess.Board
39
+ parent: Node | None = None
40
+ move_from_parent: chess.Move | None = None
41
+ mainline_next: Node | None = None
42
+ is_mainline: bool = False
43
+ candidates: list[Candidate] = field(default_factory=list)
44
+ selected: int = 0
45
+ children: dict[chess.Move, Node] = field(default_factory=dict)
46
+ analyzed: bool = False
47
+
48
+ def child(self, move: chess.Move) -> Node:
49
+ if move not in self.children:
50
+ board = self.board.copy()
51
+ board.push(move)
52
+ self.children[move] = Node(board, parent=self, move_from_parent=move)
53
+ return self.children[move]
54
+
55
+
56
+ def find_stockfish() -> str | None:
57
+ local = Path(__file__).with_name("stockfish.exe" if os.name == "nt" else "stockfish")
58
+ paths = [shutil.which("stockfish"), str(local), "/usr/games/stockfish",
59
+ "/usr/bin/stockfish", "/usr/local/bin/stockfish"]
60
+ return next((p for p in paths if p and os.path.isfile(p) and os.access(p, os.X_OK)), None)
61
+
62
+
63
+ def missing_engine_message() -> str:
64
+ system = platform.system()
65
+ label = "macOS" if system == "Darwin" else system
66
+ suggestion = "Download an executable from https://stockfishchess.org/download/"
67
+ if system == "Windows":
68
+ suggestion = "winget install --id Stockfish.Stockfish --exact"
69
+ elif system == "Darwin":
70
+ suggestion = "brew install stockfish (requires Homebrew)"
71
+ elif system == "Linux":
72
+ try:
73
+ release = platform.freedesktop_os_release()
74
+ except OSError:
75
+ release = {}
76
+ if release.get("PRETTY_NAME"):
77
+ label += f" ({release['PRETTY_NAME']})"
78
+ families = {release.get("ID"), *release.get("ID_LIKE", "").split()}
79
+ if families & {"debian", "ubuntu"}:
80
+ suggestion = "sudo apt install stockfish"
81
+ elif "arch" in families:
82
+ suggestion = "sudo pacman -S stockfish"
83
+ elif release.get("ID") == "fedora":
84
+ suggestion = "sudo dnf install stockfish"
85
+ executable = '"C:\\path\\to\\stockfish.exe"' if system == "Windows" else "/path/to/stockfish"
86
+ return (
87
+ "No engine detected on PATH or beside the application.\n"
88
+ f"OS detection: {label}\n"
89
+ f"Suggested Stockfish installation: {suggestion}\n"
90
+ "After installation, make sure Stockfish is on PATH; restart your shell if needed.\n"
91
+ f"Or pass any UCI engine: chess-analyzer --engine {executable}"
92
+ )
93
+
94
+
95
+ def side_label(color: str, name: str) -> str:
96
+ return color if name == color else f"{color} · {name}"
97
+
98
+
99
+ def format_score(score: chess.engine.PovScore) -> str:
100
+ """Evaluation in pawns, from White's perspective."""
101
+ white = score.white()
102
+ mate = white.mate()
103
+ if mate is not None:
104
+ sign = "" if white > chess.engine.Cp(0) else "-"
105
+ return f"{sign}M{abs(mate)}"
106
+ cp = white.score()
107
+ return "?" if cp is None else f"{cp / 100:+.2f}"
108
+
109
+
110
+ def history_to_san(node: Node) -> str:
111
+ moves = []
112
+ while node.parent:
113
+ moves.append(node.move_from_parent)
114
+ node = node.parent
115
+ return node.board.variation_san(reversed(moves)) if moves else "(starting position)"
116
+
117
+
118
+ class EvaluationBar(Widget):
119
+ def render(self) -> Text:
120
+ candidate = next(iter(self.app.current.candidates), None)
121
+ score = candidate.score if candidate else "0.00"
122
+ if score.startswith("M"):
123
+ white_share = 1.0
124
+ elif score.startswith("-M"):
125
+ white_share = 0.0
126
+ else:
127
+ white_share = 1 / (1 + math.exp(-float(score) / 1.5)) if score != "?" else 0.5
128
+ height = max(1, self.size.height)
129
+ white_rows = round(height * white_share)
130
+ return Text("\n").join(
131
+ Text(" ", style=f"on {'#f0f0e8' if row < white_rows else '#30343b'}")
132
+ for row in range(height)
133
+ )
134
+
135
+
136
+ class ChessBoard(Widget):
137
+ """Native terminal cells; piece art is credited in piece_art.py and README.md."""
138
+
139
+ def render(self) -> Text:
140
+ app = self.app
141
+ board = app.current.board
142
+ selected = app.selected_move()
143
+ last = app.current.move_from_parent
144
+ files = list(range(7, -1, -1) if app.flipped else range(8))
145
+ ranks = range(8) if app.flipped else range(7, -1, -1)
146
+ cell_width = max(2, min(10, (self.size.width - 3) // 8))
147
+ cell_height = max(1, min(5, (self.size.height - 1) // 8))
148
+ art = {}
149
+ if not app.ascii_pieces:
150
+ for (height, width), sprites in PIECE_ART.items():
151
+ if cell_height >= height and cell_width >= width:
152
+ art = sprites
153
+ text = Text(no_wrap=True)
154
+ for rank in ranks:
155
+ for row in range(cell_height):
156
+ text.append(f"{rank + 1} " if row == cell_height // 2 else " ")
157
+ for file in files:
158
+ square = chess.square(file, rank)
159
+ piece = board.piece_at(square)
160
+ background = "#899779" if (rank + file) % 2 else "#536747"
161
+ if last and square in (last.from_square, last.to_square):
162
+ background = "#898e3c"
163
+ if selected and square in (selected.from_square, selected.to_square):
164
+ background = "#4c809c"
165
+ if board.is_check() and square == board.king(board.turn):
166
+ background = "#ad4b4b"
167
+ symbol = " "
168
+ if piece:
169
+ lines = art[piece.piece_type] if art else (
170
+ piece.symbol() if app.ascii_pieces else piece.unicode_symbol(),
171
+ )
172
+ piece_row = row - (cell_height // 2 - len(lines) // 2)
173
+ if 0 <= piece_row < len(lines):
174
+ symbol = lines[piece_row]
175
+ color = "#ffffff" if art and piece and piece.color else "#121212"
176
+ text.append(symbol.center(cell_width), style=f"bold {color} on {background}")
177
+ text.append("\n")
178
+ text.append(" " + "".join(chess.FILE_NAMES[file].center(cell_width) for file in files))
179
+ return text
180
+
181
+
182
+ class ChessAnalysisApp(App):
183
+ TITLE = "Chess Analysis"
184
+ CSS = """
185
+ Widget { link-style: none; link-style-hover: none; }
186
+ Screen { background: #0d1117; color: #e6edf3; }
187
+ Header, Footer { background: #161b22; }
188
+ Static { height: auto; }
189
+ #main { height: 1fr; padding: 0 1; }
190
+ #board-side { width: 1fr; min-width: 22; padding: 0 1; }
191
+ #analysis-side {
192
+ width: 1fr; min-width: 27; border: round #30363d;
193
+ background: #161b22; padding: 0 1;
194
+ }
195
+ #position-info, #engine-title, .player-name { text-style: bold; }
196
+ .player-name { color: #c9d1d9; }
197
+ #board-area, #board {
198
+ width: 1fr; height: 1fr; min-height: 9; content-align: center middle;
199
+ }
200
+ #evaluation-bar { width: 2; height: 1fr; margin: 1 0; }
201
+ #fen, #status { color: #8b949e; }
202
+ #fen { max-height: 3; }
203
+ .narrow #main { layout: vertical; }
204
+ .narrow #board-side { width: 1fr; height: 14; }
205
+ .narrow #analysis-side { width: 1fr; height: 1fr; }
206
+ #engine-title, #return-game, #candidates { margin-bottom: 1; }
207
+ #return-game { color: #e3b341; }
208
+ #pv, #history { border-top: solid #30363d; padding-top: 1; margin-top: 1; }
209
+ #history { color: #c9d1d9; }
210
+ #status { margin-top: 1; }
211
+ """
212
+ BINDINGS = [
213
+ Binding("left", "previous_position", "Back", priority=True),
214
+ Binding("right", "next_position", "Follow", priority=True),
215
+ Binding("enter", "next_position", "Follow", show=False, priority=True),
216
+ Binding("up", "select_move(-1)", "Choose", priority=True),
217
+ Binding("down", "select_move(1)", "Choose", priority=True),
218
+ Binding("escape", "return_to_game", "Original game", priority=True),
219
+ ("f", "flip_board", "Flip"),
220
+ ("r", "reanalyze", "Re-analyze"),
221
+ ("q", "quit", "Quit"),
222
+ ]
223
+
224
+ def __init__(self, board: chess.Board, engine: chess.engine.UciProtocol,
225
+ think_time: float, multipv: int, ascii_pieces: bool = False,
226
+ moves: list[chess.Move] | None = None, engine_name: str = "Engine",
227
+ white_name: str = "White", black_name: str = "Black"):
228
+ super().__init__()
229
+ self.engine = engine
230
+ self.think_time = think_time
231
+ self.multipv = multipv
232
+ self.ascii_pieces = ascii_pieces
233
+ self.engine_name = engine_name
234
+ self.white_name = white_name
235
+ self.black_name = black_name
236
+ self.has_pgn = moves is not None
237
+ self.root = Node(board.copy(), is_mainline=self.has_pgn)
238
+ self.current = self.root
239
+ self.return_position: Node | None = None
240
+ for move in moves or []:
241
+ child = self.current.child(move)
242
+ child.is_mainline = True
243
+ self.current.mainline_next = child
244
+ self.current = child
245
+ self.flipped = False
246
+ self.analysis_requested = asyncio.Event()
247
+
248
+ def compose(self) -> ComposeResult:
249
+ yield Header()
250
+ with Horizontal(id="main"):
251
+ with Vertical(id="board-side"):
252
+ yield Static(id="position-info")
253
+ yield Static(id="top-player", classes="player-name")
254
+ with Horizontal(id="board-area"):
255
+ yield ChessBoard(id="board")
256
+ yield EvaluationBar(id="evaluation-bar")
257
+ yield Static(id="bottom-player", classes="player-name")
258
+ yield Static(id="fen")
259
+ with VerticalScroll(id="analysis-side"):
260
+ for name in ("engine-title", "return-game", "candidates", "pv", "history", "status"):
261
+ yield Static(id=name)
262
+ yield Footer()
263
+
264
+ def on_mount(self) -> None:
265
+ self.refresh_ui()
266
+ self.analyze_requested_position()
267
+ self.analysis_loop()
268
+
269
+ def on_resize(self, event: Resize) -> None:
270
+ self.screen.set_class(event.size.width < 64, "narrow")
271
+
272
+ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool:
273
+ return action != "return_to_game" or self.return_position is not None
274
+
275
+ def refresh_ui(self) -> None:
276
+ self.refresh_board()
277
+ self.refresh_analysis_panel()
278
+ board = self.current.board
279
+ info = Text("White to move" if board.turn else "Black to move", style="bold")
280
+ if board.is_check():
281
+ info.append(" CHECKMATE" if board.is_checkmate() else " CHECK", style="bold red")
282
+ if self.current.is_mainline:
283
+ info.append(" Original game", style="bold #e3b341")
284
+ elif self.return_position:
285
+ branch = self.return_position.board
286
+ turn = "." if branch.turn else "..."
287
+ info.append(f" Exploring from {branch.fullmove_number}{turn} · Esc: game",
288
+ style="bold #58a6ff")
289
+ self.query_one("#position-info", Static).update(info)
290
+ self.query_one("#fen", Static).update(Text(f"FEN {board.fen()}", style="dim"))
291
+
292
+ def move_choices(self, node: Node) -> list[chess.Move | None]:
293
+ original = node.mainline_next.move_from_parent if node.mainline_next else None
294
+ # A non-playable end row prevents jumping from the PGN into an engine line.
295
+ moves = [original] if node.is_mainline else []
296
+ return moves + [c.move for c in node.candidates if c.move != original]
297
+
298
+ def selected_move(self) -> chess.Move | None:
299
+ moves = self.move_choices(self.current)
300
+ if not moves:
301
+ return None
302
+ self.current.selected %= len(moves)
303
+ return moves[self.current.selected]
304
+
305
+ def refresh_board(self) -> None:
306
+ self.query_one("#board", ChessBoard).refresh()
307
+ self.query_one("#evaluation-bar", EvaluationBar).refresh()
308
+ top_color, top_name, bottom_color, bottom_name = (
309
+ ("White", self.white_name, "Black", self.black_name) if self.flipped
310
+ else ("Black", self.black_name, "White", self.white_name)
311
+ )
312
+ self.query_one("#top-player", Static).update(side_label(top_color, top_name))
313
+ self.query_one("#bottom-player", Static).update(side_label(bottom_color, bottom_name))
314
+
315
+ def refresh_analysis_panel(self) -> None:
316
+ node = self.current
317
+ board = node.board
318
+ candidates = {c.move: c for c in node.candidates}
319
+ self.query_one("#engine-title", Static).update(
320
+ Text(f"{self.engine_name} {self.think_time:g}s / {self.multipv} lines", style="bold")
321
+ )
322
+ return_link = self.query_one("#return-game", Static)
323
+ return_link.display = self.return_position is not None
324
+ return_link.update(Text("← Back to original game [Esc]", style=Style(
325
+ color="#e3b341", meta={"@click": "app.return_to_game"},
326
+ )))
327
+ selected_move = self.selected_move()
328
+ lines = Text("Next move · ↑/↓ choose\n", style="bold")
329
+ for index, move in enumerate(self.move_choices(node)):
330
+ original = node.is_mainline and index == 0
331
+ candidate = candidates.get(move)
332
+ san = board.san(move) if move else "End of original game"
333
+ label = "Original" if original else "Engine"
334
+ row = f"{'▶' if index == node.selected else ' '} {label:<9} {san}"
335
+ if candidate:
336
+ row += f" {candidate.score}"
337
+ lines.append(row + "\n", style=Style(
338
+ color="#e3b341" if original else "#58a6ff", reverse=index == node.selected,
339
+ meta={"@click": f"app.follow_choice({index})"},
340
+ ))
341
+ lines.append("→ / Enter follows selection\n", style="dim")
342
+ if board.is_game_over():
343
+ lines.append(f"Game over: {board.result()}\n", style="dim")
344
+ elif not node.candidates:
345
+ lines.append("No engine lines. Press r to retry." if node.analyzed
346
+ else "Engine is thinking…", style="dim")
347
+ self.query_one("#candidates", Static).update(lines)
348
+ candidate = candidates.get(selected_move)
349
+ pv = Text()
350
+ if selected_move is None and not board.is_game_over():
351
+ pv.append("↑/↓ choose an engine move to keep exploring.", style="dim")
352
+ elif candidate:
353
+ pv.append("Selected continuation\n\n", style="bold")
354
+ pv.append(candidate.pv)
355
+ elif node.mainline_next:
356
+ pv.append("Continue the original game with → or Enter.", style="dim")
357
+ self.query_one("#pv", Static).update(pv)
358
+
359
+ history = Text()
360
+ if self.has_pgn:
361
+ history.append("Original game · click a move\n", style="bold #e3b341")
362
+ anchor = node if node.is_mainline else self.return_position
363
+ history.append("Start", style=Style(
364
+ color="#e3b341", reverse=anchor is self.root,
365
+ meta={"@click": "app.game_position(0)"},
366
+ ))
367
+ cursor = self.root
368
+ index = 0
369
+ while cursor.mainline_next:
370
+ parent = cursor
371
+ cursor = cursor.mainline_next
372
+ index += 1
373
+ prefix = f"{parent.board.fullmove_number}. " if parent.board.turn else ""
374
+ if index == 1 and not parent.board.turn:
375
+ prefix = f"{parent.board.fullmove_number}... "
376
+ history.append(" " + prefix + parent.board.san(cursor.move_from_parent), style=Style(
377
+ color="#e3b341", reverse=cursor is anchor,
378
+ meta={"@click": f"app.game_position({index})"},
379
+ ))
380
+ if not node.is_mainline:
381
+ history.append("\n\nExplored line\n", style="bold #58a6ff")
382
+ history.append(history_to_san(node))
383
+ else:
384
+ history.append("Current line\n\n", style="bold")
385
+ history.append(history_to_san(node))
386
+ self.query_one("#history", Static).update(history)
387
+
388
+ def set_status(self, message: str, style: str = "dim") -> None:
389
+ self.query_one("#status", Static).update(Text(message, style=style))
390
+
391
+ def analyze_requested_position(self) -> None:
392
+ self.analysis_requested.set()
393
+
394
+ @work(group="engine")
395
+ async def analysis_loop(self) -> None:
396
+ while True:
397
+ await self.analysis_requested.wait()
398
+ self.analysis_requested.clear()
399
+ node = self.current
400
+ if node.analyzed or node.board.is_game_over():
401
+ continue
402
+ self.set_status("Engine is thinking…", "yellow")
403
+ try:
404
+ analysis = await self.engine.analysis(
405
+ node.board.copy(), chess.engine.Limit(time=self.think_time),
406
+ multipv=self.multipv,
407
+ )
408
+ with analysis:
409
+ finished = asyncio.create_task(analysis.wait())
410
+ changed = asyncio.create_task(self.analysis_requested.wait())
411
+ try:
412
+ done, _ = await asyncio.wait(
413
+ (finished, changed), return_when=asyncio.FIRST_COMPLETED,
414
+ )
415
+ if changed in done:
416
+ analysis.stop()
417
+ await finished
418
+ finally:
419
+ changed.cancel()
420
+ await asyncio.gather(changed, return_exceptions=True)
421
+ if changed in done:
422
+ continue
423
+ except chess.engine.EngineError as exc:
424
+ if node is self.current:
425
+ self.set_status(f"Engine error: {exc} · r to retry", "bold red")
426
+ continue
427
+ previous = self.move_choices(node)
428
+ selected = previous[node.selected] if node.selected < len(previous) else None
429
+ node.candidates = [
430
+ Candidate(info["pv"][0], format_score(info["score"]),
431
+ node.board.variation_san(info["pv"]))
432
+ for info in analysis.multipv if info.get("pv")
433
+ ]
434
+ node.analyzed = True
435
+ choices = self.move_choices(node)
436
+ node.selected = choices.index(selected) if selected in choices else 0
437
+ if node is self.current:
438
+ self.set_status("Analysis ready.")
439
+ self.refresh_ui()
440
+
441
+ def action_select_move(self, direction: int) -> None:
442
+ moves = self.move_choices(self.current)
443
+ if moves:
444
+ self.current.selected = (self.current.selected + direction) % len(moves)
445
+ self.refresh_board()
446
+ self.refresh_analysis_panel()
447
+
448
+ def action_next_position(self) -> None:
449
+ move = self.selected_move()
450
+ if move is not None:
451
+ if self.current.is_mainline:
452
+ original = self.current.mainline_next
453
+ self.return_position = (
454
+ None if original and original.move_from_parent == move else self.current
455
+ )
456
+ self.show_position(self.current.child(move))
457
+
458
+ def action_follow_choice(self, index: int) -> None:
459
+ self.current.selected = index
460
+ self.action_next_position()
461
+
462
+ def action_game_position(self, index: int) -> None:
463
+ node = self.root
464
+ for _ in range(index):
465
+ if node.mainline_next is None:
466
+ break
467
+ node = node.mainline_next
468
+ self.return_position = None
469
+ node.selected = 0
470
+ self.show_position(node)
471
+
472
+ def show_position(self, node: Node) -> None:
473
+ self.current = node
474
+ self.refresh_bindings()
475
+ self.set_status("Analysis ready." if node.analyzed else "")
476
+ self.refresh_ui()
477
+ self.query_one("#candidates").scroll_visible(animate=False)
478
+ self.analyze_requested_position()
479
+
480
+ def action_return_to_game(self) -> None:
481
+ if self.return_position:
482
+ node = self.return_position
483
+ self.return_position = None
484
+ node.selected = 0
485
+ self.show_position(node)
486
+
487
+ def action_previous_position(self) -> None:
488
+ node = self.current.parent
489
+ if node:
490
+ if node.is_mainline:
491
+ self.return_position = None
492
+ self.show_position(node)
493
+
494
+ def action_flip_board(self) -> None:
495
+ self.flipped = not self.flipped
496
+ self.refresh_board()
497
+
498
+ def action_reanalyze(self) -> None:
499
+ self.current.analyzed = False
500
+ self.analyze_requested_position()
501
+
502
+
503
+ async def run_app(args, board: chess.Board, engine_path: str, moves: list[chess.Move],
504
+ white_name: str, black_name: str) -> None:
505
+ transport, engine = await chess.engine.popen_uci(engine_path)
506
+ try:
507
+ settings = {}
508
+ for name, value in {"Threads": args.threads, "Hash": args.hash}.items():
509
+ option = engine.options.get(name)
510
+ if option and option.type == "spin":
511
+ if option.min is not None:
512
+ value = max(value, option.min)
513
+ if option.max is not None:
514
+ value = min(value, option.max)
515
+ settings[name] = value
516
+ await engine.configure(settings)
517
+ multipv_option = engine.options.get("MultiPV")
518
+ multipv = args.lines if multipv_option else 1
519
+ if multipv_option:
520
+ if multipv_option.min is not None:
521
+ multipv = max(multipv, multipv_option.min)
522
+ if multipv_option.max is not None:
523
+ multipv = min(multipv, multipv_option.max)
524
+ engine_name = engine.id.get("name") or Path(engine_path).name
525
+ app = ChessAnalysisApp(board, engine, args.time, multipv, args.ascii,
526
+ moves=moves if args.pgn else None, engine_name=engine_name,
527
+ white_name=white_name, black_name=black_name)
528
+ await app.run_async()
529
+ finally:
530
+ try:
531
+ await asyncio.wait_for(engine.quit(), timeout=3)
532
+ finally:
533
+ transport.close()
534
+
535
+
536
+ def player_name(value: str | None, fallback: str) -> str:
537
+ name = (value or "").strip()
538
+ return fallback if name in {"", "?"} else name
539
+
540
+
541
+ def load_pgn(path: str) -> tuple[chess.Board, list[chess.Move], str, str]:
542
+ try:
543
+ with open(path, encoding="utf-8") as pgn_file:
544
+ game = chess.pgn.read_game(pgn_file)
545
+ except OSError as exc:
546
+ raise SystemExit(f"Could not read PGN: {exc}") from exc
547
+ if game is None:
548
+ raise SystemExit("The PGN file does not contain a game.")
549
+ if game.errors:
550
+ raise SystemExit(f"Could not parse PGN: {game.errors[0]}")
551
+ return (game.board(), list(game.mainline_moves()),
552
+ player_name(game.headers.get("White"), "White"),
553
+ player_name(game.headers.get("Black"), "Black"))
554
+
555
+
556
+ def main() -> None:
557
+ parser = argparse.ArgumentParser(
558
+ prog="chess-analyzer", description="Interactive UCI chess engine analyzer.",
559
+ )
560
+ parser.add_argument("fen", nargs="?", help="FEN position (mutually exclusive with --pgn)")
561
+ parser.add_argument("--pgn", help="PGN file to analyze from its final position")
562
+ parser.add_argument("--white", help="White player's display name (overrides PGN header)")
563
+ parser.add_argument("--black", help="Black player's display name (overrides PGN header)")
564
+ parser.add_argument("-t", "--time", type=float, default=1.0, help="Thinking time per position")
565
+ parser.add_argument("-n", "--lines", type=int, default=5, help="Number of engine continuations")
566
+ parser.add_argument("--threads", type=int, default=2, help="Engine threads (if supported)")
567
+ parser.add_argument("--hash", type=int, default=256, help="Engine hash size in MB (if supported)")
568
+ parser.add_argument("--engine", help="Path to a UCI engine executable (default: Stockfish)")
569
+ parser.add_argument("--ascii", action="store_true", help="Use letters instead of chess glyphs")
570
+ args = parser.parse_args()
571
+ if not math.isfinite(args.time) or args.time <= 0:
572
+ parser.error("--time must be a positive, finite number")
573
+ if min(args.lines, args.threads, args.hash) < 1:
574
+ parser.error("--lines, --threads and --hash must be positive")
575
+ if args.fen and args.pgn:
576
+ parser.error("provide either a FEN or --pgn, not both")
577
+ moves = []
578
+ if args.pgn:
579
+ board, moves, pgn_white, pgn_black = load_pgn(args.pgn)
580
+ else:
581
+ pgn_white, pgn_black = "White", "Black"
582
+ try:
583
+ board = chess.Board(args.fen or chess.STARTING_FEN)
584
+ except ValueError as exc:
585
+ raise SystemExit(f"Invalid FEN: {exc}") from exc
586
+ white_name = player_name(args.white, pgn_white)
587
+ black_name = player_name(args.black, pgn_black)
588
+ if not board.is_valid():
589
+ raise SystemExit("The starting position is invalid.")
590
+ engine_path = args.engine or find_stockfish()
591
+ if not engine_path:
592
+ raise SystemExit(missing_engine_message())
593
+ try:
594
+ asyncio.run(run_app(args, board, engine_path, moves, white_name, black_name))
595
+ except (OSError, chess.engine.EngineError, asyncio.TimeoutError) as exc:
596
+ raise SystemExit(f"Engine error: {exc}") from exc
597
+
598
+
599
+ if __name__ == "__main__":
600
+ main()
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Thomas Mauran
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,36 @@
1
+ """Piece art adapted from Thomas Mauran's chess-tui (MIT).
2
+
3
+ Source: https://github.com/thomas-mauran/chess-tui/tree/fc1d4841532bf72f5a25c5cb45abe82ec25e056b/src/pieces
4
+ Copyright (c) 2023 Thomas Mauran. See licenses/chess-tui-MIT.txt.
5
+ """
6
+
7
+ import chess
8
+
9
+ # (required rows, required columns), ordered from compact to large.
10
+ PIECE_ART = {
11
+ (3, 5): {
12
+ chess.PAWN: (" ▂ ", " ▆█▆ ", " ▔▔▔ "),
13
+ chess.KNIGHT: (" ▄▟▟▖", " ▂█▛▘", "▝▀▀▀▘"),
14
+ chess.BISHOP: (" ▆▖▆ ", " ▐▙▌ ", " ▀▀▀ "),
15
+ chess.ROOK: (" ▅ ▅ ", " ███ ", "▝▀▀▀▘"),
16
+ chess.QUEEN: (" ▆▄▆ ", " ▗█▖ ", " ▀▀▀ "),
17
+ chess.KING: ("▗▂╋▂▖", " ▀█▀ ", " ▀▀▀ "),
18
+ },
19
+ (4, 5): {
20
+ chess.PAWN: (" ", " ▝█▘ ", " ▟█▙ ", " ▔▔▔ "),
21
+ chess.KNIGHT: (" ▖▗ ", "▗▇▟█▌", " ▟█▛ ", "▝▀▀▀▘"),
22
+ chess.BISHOP: (" ▄▁▗ ", " ██▟ ", " ▟█▙ ", "▝▀▀▀▘"),
23
+ chess.ROOK: ("▄ ▄ ▄", "█████", " ███ ", "▀▀▀▀▀"),
24
+ chess.QUEEN: ("▂ ▄ ▂", "▜▙█▟▛", " ▜█▛ ", "▝▀▀▀▘"),
25
+ chess.KING: (" ▂╋▂ ", "▜███▛", " ▜█▛ ", "▝▀▀▀▘"),
26
+ },
27
+ (5, 7): {
28
+ chess.PAWN: (" ", " ▄▇▄ ", " ▜█▛ ", "▄███▄", "▔▔▔▔▔"),
29
+ chess.KNIGHT: (" ▅ ▅", " ▟▛███▖", "▝▀▜███▊", " ▗███▛ ", " ▀▀▀▀▀ "),
30
+ chess.BISHOP: ("▗▅ ▖", "██▍ █", "███▍█", "▝███▘", "▀▀▀▀▀"),
31
+ chess.ROOK: ("▗▄ ▃ ▄▖", "▐█▄█▄█▌", "▝▜███▛▘", " ▟███▙ ", "▝▀▀▀▀▀▘"),
32
+ chess.QUEEN: ("▗ ▂ ▖", "▐▙▟█▙▟▌", " ▜███▛ ", " ▗███▖ ", "▝▀▀▀▀▀▘"),
33
+ # Remove one column of outer padding to fit the shared seven-column size.
34
+ chess.KING: (" ▂▃╋▃▂ ", "▐█████▋", " ▜███▛ ", " ▟█▙ ", " ▀▀▀▀▀ "),
35
+ },
36
+ }
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "chess-analyzer-tui"
7
+ version = "0.1.1"
8
+ description = "Terminal chess analysis TUI with Stockfish."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE", "licenses/chess-tui-MIT.txt"]
12
+ keywords = ["chess", "stockfish", "terminal", "tui", "analysis"]
13
+ classifiers = [
14
+ "Environment :: Console",
15
+ "Programming Language :: Python :: 3",
16
+ "Topic :: Games/Entertainment :: Board Games",
17
+ ]
18
+ requires-python = ">=3.11"
19
+ dependencies = [
20
+ "chess>=1.11.2",
21
+ "textual>=8.2.8",
22
+ ]
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/leolaurindo/chess-analyzer-tui"
26
+ Source = "https://github.com/leolaurindo/chess-analyzer-tui"
27
+ Issues = "https://github.com/leolaurindo/chess-analyzer-tui/issues"
28
+
29
+ [project.scripts]
30
+ chess-analyzer = "chess_tui:main"
31
+
32
+ [tool.setuptools]
33
+ py-modules = ["chess_tui", "piece_art"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,152 @@
1
+ import asyncio
2
+ import tempfile
3
+ import unittest
4
+ import xml.etree.ElementTree as ET
5
+ from pathlib import Path
6
+
7
+ import chess
8
+ import chess.engine
9
+
10
+ from chess_tui import ChessAnalysisApp, find_stockfish, load_pgn
11
+
12
+
13
+ def screen_text(app):
14
+ svg = ET.fromstring(app.export_screenshot())
15
+ return "".join("".join(e.itertext()) for e in svg.iter("{http://www.w3.org/2000/svg}text"))
16
+
17
+
18
+ async def wait_for_analysis(app, pilot):
19
+ async def ready():
20
+ while not app.current.analyzed:
21
+ await asyncio.sleep(0.01)
22
+ await pilot.pause()
23
+
24
+ await asyncio.wait_for(ready(), timeout=4)
25
+
26
+
27
+ class ChessTuiTests(unittest.IsolatedAsyncioTestCase):
28
+ async def asyncSetUp(self):
29
+ path = find_stockfish()
30
+ if not path:
31
+ self.skipTest("Stockfish is required for TUI integration tests")
32
+ self.transport, self.engine = await chess.engine.popen_uci(path)
33
+ await self.engine.configure({"Threads": 1, "Hash": 16})
34
+
35
+ async def asyncTearDown(self):
36
+ try:
37
+ await asyncio.wait_for(self.engine.quit(), timeout=3)
38
+ finally:
39
+ self.transport.close()
40
+
41
+ async def test_board_fits_and_large_pawns_stay_straight(self):
42
+ app = ChessAnalysisApp(chess.Board(), self.engine, 0.05, 3)
43
+ async with app.run_test(size=(80, 24)) as pilot:
44
+ await wait_for_analysis(app, pilot)
45
+ for width, height in [(80, 24), (42, 28), (160, 50), (144, 50)]:
46
+ with self.subTest(size=(width, height)):
47
+ await pilot.resize_terminal(width, height)
48
+ await pilot.pause()
49
+ for selector in ("#board", "#analysis-side"):
50
+ region = app.query_one(selector).region
51
+ self.assertTrue(0 <= region.x < region.right <= width)
52
+ self.assertTrue(1 <= region.y < region.bottom <= height - 1)
53
+ visible = "".join(screen_text(app).split())
54
+ self.assertIn("abcdefgh", visible)
55
+ if width < 144:
56
+ self.assertIn("♜♞♝♛♚♝♞♜", visible)
57
+ self.assertIn("♖♘♗♕♔♗♘♖", visible)
58
+ continue
59
+ rows = app.query_one("#board").render().plain.splitlines()
60
+ heads = [row for row in rows if "▄▇▄" in row]
61
+ necks = [row for row in rows if "▜█▛" in row]
62
+ bases = [row for row in rows if "▄███▄" in row]
63
+ self.assertEqual((len(heads), len(necks), len(bases)), (2, 2, 2))
64
+ for head, neck, base in zip(heads, necks, bases):
65
+ centers = [i for i, char in enumerate(head) if char == "▇"]
66
+ self.assertEqual(len(centers), 8)
67
+ for center in centers:
68
+ self.assertEqual(neck[center], "█")
69
+ self.assertEqual(base[center - 2:center + 3], "▄███▄")
70
+
71
+ async def test_ascii_and_flip_work_even_with_room_for_art(self):
72
+ app = ChessAnalysisApp(chess.Board(), self.engine, 0.05, 3, ascii_pieces=True)
73
+ async with app.run_test(size=(160, 50)) as pilot:
74
+ await wait_for_analysis(app, pilot)
75
+ await pilot.pause()
76
+ visible = "".join(screen_text(app).split())
77
+ self.assertIn("8rnbqkbnr", visible)
78
+ self.assertIn("1RNBQKBNR", visible)
79
+ self.assertNotIn("╋", visible)
80
+ await pilot.press("f")
81
+ visible = "".join(screen_text(app).split())
82
+ self.assertIn("1RNBKQBNR", visible)
83
+ self.assertIn("hgfedcba", visible)
84
+
85
+ async def test_rapid_navigation_during_analysis_keeps_engine_usable(self):
86
+ moves = [chess.Move.from_uci(move) for move in
87
+ ("e2e4", "e7e5", "g1f3", "b8c6", "f1c4", "g8f6")]
88
+ app = ChessAnalysisApp(chess.Board(), self.engine, 30, 5, moves=moves)
89
+ async with app.run_test(size=(80, 24)) as pilot:
90
+ for _ in range(12):
91
+ await asyncio.wait_for(pilot.press("left", "right"), timeout=2)
92
+ app.think_time = 0.05
93
+ await asyncio.wait_for(pilot.press("left"), timeout=2)
94
+ await wait_for_analysis(app, pilot)
95
+ self.assertTrue(app.current.candidates)
96
+ await asyncio.wait_for(pilot.press("right"), timeout=2)
97
+ await wait_for_analysis(app, pilot)
98
+ self.assertTrue(app.current.candidates)
99
+
100
+ async def test_pgn_navigation_and_exploration(self):
101
+ with tempfile.TemporaryDirectory() as directory:
102
+ path = Path(directory) / "game.pgn"
103
+ path.write_text('[Event "Test"]\n[White "Supi"]\n[Black "Carlsen"]\n\n1. e4 h5 *\n', encoding="utf-8")
104
+ board, moves, white_name, black_name = load_pgn(path)
105
+ final = board.copy()
106
+ for move in moves:
107
+ final.push(move)
108
+ app = ChessAnalysisApp(board, self.engine, 0.05, 3, moves=moves,
109
+ white_name=white_name, black_name=black_name)
110
+ async with app.run_test(size=(120, 42)) as pilot:
111
+ await wait_for_analysis(app, pilot)
112
+ self.assertEqual(app.query_one("#top-player").render().plain, "Black · Carlsen")
113
+ self.assertEqual(app.query_one("#bottom-player").render().plain, "White · Supi")
114
+ await pilot.press("f")
115
+ self.assertEqual(app.query_one("#top-player").render().plain, "White · Supi")
116
+ self.assertEqual(app.query_one("#bottom-player").render().plain, "Black · Carlsen")
117
+ await pilot.press("f")
118
+ await pilot.press("right", "enter")
119
+ self.assertEqual(app.current.board.fen(), final.fen()) # Stop at the PGN's end.
120
+ app.think_time = 30
121
+ await pilot.press("left")
122
+ anchor = app.current.board.fen()
123
+ self.assertIn("Original", str(app.query_one("#candidates").render()))
124
+ await asyncio.wait_for(pilot.press("enter"), timeout=2)
125
+ self.assertEqual(app.current.board.fen(), final.fen())
126
+ app.think_time = 0.05
127
+ await pilot.press("left")
128
+ await wait_for_analysis(app, pilot)
129
+ await pilot.press("down", "right")
130
+ branch = app.current.board.fen()
131
+ self.assertFalse(app.current.is_mainline)
132
+ await wait_for_analysis(app, pilot)
133
+ self.assertIn("1...", str(app.query_one("#position-info").render()))
134
+ await pilot.press("right", "escape")
135
+ self.assertEqual(app.current.board.fen(), anchor)
136
+ await pilot.press("down", "right")
137
+ self.assertEqual(app.current.board.fen(), branch)
138
+ await pilot.click("#return-game")
139
+ self.assertEqual(app.current.board.fen(), anchor)
140
+ await pilot.press("enter")
141
+ self.assertEqual(app.current.board.fen(), final.fen())
142
+ await pilot.press("left")
143
+ await pilot.pause()
144
+ await pilot.click("#candidates", offset=(4, 2))
145
+ self.assertFalse(app.current.is_mainline)
146
+ await pilot.pause()
147
+ await pilot.click("#history", offset=(2, 3))
148
+ self.assertEqual(app.current.board.fen(), chess.STARTING_FEN)
149
+
150
+
151
+ if __name__ == "__main__":
152
+ unittest.main()
@@ -0,0 +1,40 @@
1
+ import unittest
2
+ from unittest.mock import patch
3
+
4
+ from chess_tui import main
5
+
6
+
7
+ class CliTests(unittest.TestCase):
8
+ def test_missing_engine_explains_installation_for_the_detected_os(self):
9
+ cases = [
10
+ ("Windows", {}, "Windows", "winget install --id Stockfish.Stockfish --exact"),
11
+ ("Darwin", {}, "macOS", "brew install stockfish"),
12
+ ("Linux", {"ID": "ubuntu"}, "Linux", "sudo apt install stockfish"),
13
+ ("Linux", {"ID": "linuxmint", "ID_LIKE": "ubuntu debian", "PRETTY_NAME": "Linux Mint"},
14
+ "Linux (Linux Mint)", "sudo apt install stockfish"),
15
+ ("Linux", {"ID": "manjaro", "ID_LIKE": "arch"}, "Linux", "sudo pacman -S stockfish"),
16
+ ("Linux", {"ID": "fedora"}, "Linux", "sudo dnf install stockfish"),
17
+ ("Linux", {"ID": "unknown"}, "Linux", "https://stockfishchess.org/download/"),
18
+ ("Linux", OSError("No os-release file"), "Linux", "https://stockfishchess.org/download/"),
19
+ ("FreeBSD", {}, "FreeBSD", "https://stockfishchess.org/download/"),
20
+ ]
21
+ for system, release, label, suggestion in cases:
22
+ with self.subTest(system=system, release=release):
23
+ with (
24
+ patch("sys.argv", ["chess-analyzer"]),
25
+ patch("chess_tui.find_stockfish", return_value=None),
26
+ patch("platform.system", return_value=system),
27
+ patch("platform.freedesktop_os_release", side_effect=[release]),
28
+ self.assertRaises(SystemExit) as error,
29
+ ):
30
+ main()
31
+ message = str(error.exception)
32
+ self.assertIn("No engine detected", message)
33
+ self.assertIn(f"OS detection: {label}", message)
34
+ self.assertIn(suggestion, message)
35
+ self.assertIn("make sure Stockfish is on PATH", message)
36
+ self.assertIn("chess-analyzer --engine", message)
37
+
38
+
39
+ if __name__ == "__main__":
40
+ unittest.main()