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.
- chess_analyzer_tui-0.1.1/LICENSE +21 -0
- chess_analyzer_tui-0.1.1/PKG-INFO +114 -0
- chess_analyzer_tui-0.1.1/README.md +94 -0
- chess_analyzer_tui-0.1.1/chess_analyzer_tui.egg-info/PKG-INFO +114 -0
- chess_analyzer_tui-0.1.1/chess_analyzer_tui.egg-info/SOURCES.txt +14 -0
- chess_analyzer_tui-0.1.1/chess_analyzer_tui.egg-info/dependency_links.txt +1 -0
- chess_analyzer_tui-0.1.1/chess_analyzer_tui.egg-info/entry_points.txt +2 -0
- chess_analyzer_tui-0.1.1/chess_analyzer_tui.egg-info/requires.txt +2 -0
- chess_analyzer_tui-0.1.1/chess_analyzer_tui.egg-info/top_level.txt +2 -0
- chess_analyzer_tui-0.1.1/chess_tui.py +600 -0
- chess_analyzer_tui-0.1.1/licenses/chess-tui-MIT.txt +21 -0
- chess_analyzer_tui-0.1.1/piece_art.py +36 -0
- chess_analyzer_tui-0.1.1/pyproject.toml +33 -0
- chess_analyzer_tui-0.1.1/setup.cfg +4 -0
- chess_analyzer_tui-0.1.1/tests/test_chess_tui.py +152 -0
- chess_analyzer_tui-0.1.1/tests/test_cli.py +40 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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,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()
|