Add experimental new TUI (bench 5739439)

This commit is contained in:
2026-04-08 17:31:08 +02:00
parent 7d860c86d8
commit cd75a6ea8e
62 changed files with 6255 additions and 37 deletions

102
README.md
View File

@@ -152,6 +152,8 @@ Heimdall supports the following UCI options:
### Notes on command-line usage
For the fancy terminal interface, see [here](#built-in-tui)
To make command-line usage more friendly to us fleshy things, Heimdall implements a so-called "mixed mode": if it detects that it's connected to a TTY (a terminal)
it will start up a user interface that supports both UCI commands (with some slight tweaks) and a set of custom commands (type `help` for more info). The following
environment variables control the behavior of mixed mode:
@@ -163,6 +165,104 @@ Once `uci` is sent, Heimdall will switch to UCI mode: colored output will be tur
The mixed mode interface can be exited from by pressing either Ctrl+C, Ctrl+D (these also work for UCI) or Esc and then confirming when prompted (mixed mode only)
### Built-in TUI
Thanks to the power of AI (Claude Opus 4.6 to be specific), Heimdall now features an advanced text user interface to perform game analysis and playing against the engine,
right from the terminal. It uses [Kitty](http://github.com/kovidgoyal/kitty/)'s graphics protocol to render a pretty chessboard to the screen, so this will only work on
terminal emulators that implement it (just use kitty, it's great). Once you launch it, type `:help` to learn how to use it!
Many thanks to whoever runs [this](https://sashite.dev/assets/chess/) website: the chess assets are beautiful! <3
P.S.: This part of heimdall does not respect `NO_COLOR` (sorry!)
Here follows a brief usage guide for the TUI generated by Claude:
#### Getting Started
Launch the TUI with `heimdall tui`. You'll see a chessboard on the left and an info panel on the right. Type `:help` to see all commands.
#### Making Moves
There are five ways to input moves:
- **Mouse click**: Click a piece to select it (legal moves are highlighted), then click the destination
- **Drag and drop**: Drag a piece to a legal destination square
- **UCI notation**: Type `e2e4` and press Enter
- **SAN notation**: Type `Nf3`, `O-O`, `e8=Q`, etc.
- **Square selection**: Type `e2` to select the piece, then `e4` to move it
Promotions default to queen. Press `Shift+Q` to toggle auto-queen off; you'll then be prompted to choose Q/R/B/N when a pawn promotes. Dragging uses a floating piece preview while the cursor is moving. The board size scales down automatically to fit smaller terminals; if the window drops below the supported minimum size, the TUI shows a resize warning. Global keyboard shortcuts always use `Shift`, so plain `f`, `q`, and `s` are treated as normal text input.
#### Analysis
- `:go` starts/stops continuous engine analysis on the current position
- `:set multipv 3` shows multiple analysis lines (sorted by strength, with WDL probabilities)
- `:stop` halts the current search
- Left/Right arrow keys undo/redo moves; the engine restarts analysis on each position change
- Press `Shift+S` to enter board setup mode.
- In board setup mode you can drag pieces freely between squares.
- Dropping a piece off the board deletes it.
- Type `p/n/b/r/q/k` to arm spawning a black piece; use `Shift+<key>` to arm the white version.
- Press `Esc` to validate the edited position and exit back to analysis.
- Invalid setups are rejected gracefully and keep you in board setup mode so you can fix them.
#### Playing Against the Engine
- `:play` starts a game setup wizard: choose variant, side, time controls (format: `5m+3s`, `10m`, `1h`, `none`), and whether takeback is allowed
- `:resign` forfeits the game, `:takeback` (or `:tb`) undoes your last move (if enabled)
- `:exit` leaves play mode
- While the engine is thinking, you can queue premoves by dragging one of your pieces, by square selection (`e2` then `e4`), or by typing a UCI move such as `e2e4`. Premoves resolve in queue order, with the first several highlighted using different colors on the board and the palette cycling after that. If the next premove becomes illegal after an engine move, the remaining queued premoves are cleared. Click a highlighted premove square to remove the most recently queued premove that touches that square.
#### Engine vs Engine
- `:watch` starts an engine-vs-engine game. Choose variant, then optionally configure each side separately
#### PGN Support
- `:load game.pgn` loads a PGN for replay. If the file contains multiple games, specify which one: `:load game.pgn 3`
- Navigate with Left/Right arrows, Home/End to jump to start/end
- `:pgn output.pgn` exports the current move history as a PGN file with metadata
#### Chess960 / DFRC
- `:frc 518` loads a Chess960 position by Scharnagl number (0-959)
- `:dfrc 123 456` loads a Double Fischer Random position
- `:chess960 on/off` toggles Chess960 mode manually
#### Engine Settings
`:set <option> <value>` configures the engine. Autocomplete is available (type `:set ` and use Tab/arrows). Options include:
`hash`, `threads`, `multipv`, `depth`, `contempt`, `moveoverhead`, `ponder`, `normalizescore`, `evalfile`, `chess960`
Hash accepts human-readable sizes: `:set hash 1 GB`, `:set hash 256 MiB`, or bare numbers (interpreted as MiB).
`:clear` resets all engine state (transposition table, move ordering histories).
#### Keyboard Shortcuts
| Key | Action |
|-----|--------|
| `Shift+F` | Flip board |
| `Shift+Q` | Toggle auto-queen promotion |
| `Shift+S` | Enter board setup mode (analysis only) |
| Left/Right | Undo/redo moves |
| Home/End | Go to first/last position |
| Ctrl+C | Quit immediately |
| Ctrl+D (x2) | Quit with confirmation |
| ESC | Cancel current action |
| Tab | Accept autocomplete suggestion |
All global keyboard shortcuts use `Shift`, regardless of whether the command buffer is active.
#### Other Commands
- `:fen` copies the current FEN to clipboard; `:fen <fen>` loads a position
- `:reset` resets to the starting position
- `:flip` flips the board
- `:threats` toggles threat square highlighting
## Search
Heimdall implements [negamax](https://en.wikipedia.org/wiki/Negamax) search with [alpha-beta pruning](https://en.wikipedia.org/wiki/Alpha%E2%80%93beta_pruning) in a [PVS](https://en.wikipedia.org/wiki/Principal_variation_search) framework to search the game tree
@@ -269,4 +369,4 @@ Y'all are awesome! <3
**P.P.S**: If you read this far, congrats! Here's a free easter egg (there's more :)): set the environment variable `FUNNY_ESC` before starting
heimdall and press Esc :>
heimdall and press Esc :>

View File

@@ -22,3 +22,4 @@ requires "struct == 0.2.3"
requires "nimsimd == 1.2.13"
requires "noise >= 0.1.10"
requires "illwill >= 0.4.1"

View File

@@ -15,6 +15,7 @@ import std/[os, math, times, atomics, parseopt, strutils, strformat, options, ra
import heimdall/[uci, moves, board, search, movegen, position, transpositions, eval]
import heimdall/util/[magics, limits, tunables, book_augment, logs]
import heimdall/tui/app
randomize()
@@ -45,10 +46,10 @@ proc runBench(depth: int = 13, threads: int = 1, silent: bool = false) =
let line = mgr.search(silent=silent)[0]
if not silent:
if line[1] == nullMove():
echo &"bestmove {line[0].toUCI()}"
if line.moves[1] == nullMove():
echo &"bestmove {line.moves[0].toUCI()}"
else:
echo &"bestmove {line[0].toUCI()} ponder {line[1].toUCI()}"
echo &"bestmove {line.moves[0].toUCI()} ponder {line.moves[1].toUCI()}"
let
move = mgr.statistics.bestMove.load(moRelaxed)
totalNodes = mgr.limiter.totalNodes()
@@ -100,7 +101,8 @@ when isMainModule:
skip = 0
rounds = 1
const subcommands = ["magics", "testonly", "bench", "spsa", "chonk"]
var runTUI = false
const subcommands = ["magics", "testonly", "bench", "spsa", "chonk", "tui"]
for kind, key, value in parser.getopt():
case kind:
of cmdArgument:
@@ -141,6 +143,9 @@ when isMainModule:
of "chonk":
# Hehe me make chonky book
augment = true
of "tui":
runUCI = false
runTUI = true
else:
discard
prevSubCmd = key
@@ -220,7 +225,9 @@ when isMainModule:
of cmdEnd:
break
if not magicGen and not augment:
if runUCI:
if runTUI:
startTUI()
elif runUCI:
startUCISession()
if bench:
runBench(benchDepth, benchThreads, benchSilent)

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 4096 4096" xmlns="http://www.w3.org/2000/svg">
<title>Chess Black Bishop</title>
<path d="m2048 3456.3q-30.762 125.24-74.707 173.58t-116.46 101.07q-79.102 54.932-186.77 92.285-107.67 39.551-239.5 19.775l-617.43-85.693q-37.354-4.3945-68.115 0-28.564 4.3946-54.932 4.3946-46.143 0-118.65 19.775-70.312 21.973-109.86 61.523l-213.13-349.37q39.551-43.945 70.312-61.524t72.51-37.354q127.44-59.326 272.46-72.51 61.523-4.3945 120.85-2.1972 59.326 2.1972 123.05-4.3946 118.65 19.775 237.3 37.354 120.85 15.381 241.7 32.959 131.84 0 177.98-26.367 24.17-13.184 76.904-48.34t105.47-103.27q-116.46-13.184-237.3-43.945-120.85-32.959-213.13-68.115l228.52-566.89q-171.39-98.877-239.5-158.2-68.115-61.523-107.67-140.62-57.129-101.07-74.707-195.56-15.381-94.482-13.184-169.19 2.1973-131.84 61.523-290.04 61.524-160.4 228.52-285.64 138.43-105.47 270.26-217.53 131.84-112.06 261.47-261.47-162.6-83.496-162.6-265.87 0-123.05 85.693-210.94 87.891-87.891 213.13-87.891 123.05 0 210.94 87.891 87.891 87.891 87.891 210.94 0 180.18-162.6 265.87 127.44 149.41 257.08 261.47 131.84 112.06 274.66 217.53 164.79 125.24 224.12 285.64 59.326 158.2 63.721 290.04 0 74.707-15.381 169.19-15.381 94.482-70.312 195.56-43.945 79.102-112.06 140.62-65.918 59.326-235.11 158.2l228.52 566.89q-96.68 35.156-217.53 68.115-120.85 30.762-232.91 43.945 50.537 68.115 103.27 103.27 52.734 35.156 79.102 48.34 46.143 26.367 177.98 26.367 118.65-17.578 237.3-32.959 120.85-17.578 241.7-37.354 59.326 6.5918 118.65 4.3946 61.523-2.1973 125.24 2.1972 140.62 13.184 272.46 72.51 39.551 19.775 70.312 37.354 32.959 17.578 72.51 61.524l-215.33 349.37q-39.551-39.551-112.06-61.523-70.312-19.775-114.26-19.775-28.564 0-59.326-4.3946-28.564-4.3945-65.918 0l-615.23 85.693q-131.84 19.775-246.09-17.578-112.06-37.354-184.57-98.877-72.51-59.326-116.46-103.27-41.748-41.748-70.312-166.99z"/>
<path d="m1966.7 1821.5v186.77q0 81.299 81.299 81.299t81.299-81.299v-188.96h197.75q76.904 0 76.904-79.102t-76.904-79.102h-197.75v-197.75q0-81.299-81.299-81.299t-81.299 81.299v197.75h-193.36q-76.904 0-76.904 79.102t76.904 79.102z" fill="#fff"/>
<path d="m1918.4 3416.7q50.537-107.67 50.537-208.74h-70.312q-85.693 140.62-177.98 208.74z" fill="#fff"/>
<path d="m2371 3416.7q-87.891-65.918-175.78-208.74h-70.312q0 107.67 50.537 208.74z" fill="#fff"/>
<path d="m2447.9 2586.2-63.721-153.81v-59.326q-166.99-24.17-336.18-24.17-164.79 0-333.98 24.17l-2.1972 59.326-59.326 153.81q186.77-32.959 395.51-32.959 210.94 0 399.9 32.959z" fill="#fff"/>
<path d="m2632.5 3038.8-92.285-224.12q-224.12-48.34-492.19-48.34-265.87 0-487.79 48.34l-92.285 221.92q272.46-68.115 580.08-68.115 303.22 0 584.47 70.312z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 4096 4096" xmlns="http://www.w3.org/2000/svg">
<title>Chess Black King</title>
<path d="m815.83 3151.1-72.51-410.89q-2.1973 0-6.5918-8.789-10.986-15.381-63.721-39.551-50.537-26.367-118.65-87.891-96.68-81.299-151.61-131.84-52.734-50.537-96.68-109.86-134.03-184.57-149.41-446.04-21.973-252.69 204.35-503.17 228.52-250.49 617.43-235.11 145.02 8.789 340.58 70.312 63.721 26.367 129.64 52.734 68.115 24.17 134.03 50.537 35.156 17.578 61.523 35.156-10.986-46.143-10.986-92.285 0-171.39 120.85-292.24 123.05-120.85 294.43-123.05 171.39 0 292.24 123.05 120.85 120.85 120.85 290.04 0 35.156-8.789 92.285 30.762-19.775 59.326-32.959 101.07-43.945 265.87-103.27 188.96-63.721 340.58-72.51 388.92-17.578 615.23 235.11 221.92 250.49 206.54 503.17-17.578 261.47-151.61 446.04-43.945 59.326-98.877 112.06-54.932 50.537-147.22 129.64-72.51 61.523-123.05 87.891-50.537 24.17-59.326 39.551-2.1973 4.3945-4.3946 6.5918-2.1972 2.1973-2.1972 4.3945l-70.312 413.09 145.02 542.72q-109.86 98.877-492.19 162.6t-883.3 63.721q-509.77 0-896.48-68.115-386.72-65.918-485.6-166.99z"/>
<path d="m2048.5 2015.1q15.381-61.524 26.367-85.693 21.973-85.693 50.537-145.02 13.184-37.354 37.354-85.693 26.367-50.537 54.932-114.26 17.578-37.354 37.354-90.088 19.775-54.932 39.551-107.67 17.578-43.945 17.578-94.482 0-107.67-76.904-182.37-76.904-76.904-186.77-76.904-261.47 0-261.47 261.47 0 50.537 17.578 94.482 48.34 142.82 74.707 197.75 28.564 63.721 52.734 114.26 24.17 48.34 41.748 85.693 28.564 72.51 48.34 145.02 4.3945 13.184 26.367 83.496z" fill="#fff"/>
<path d="m2048.5 1639.3q-4.3945-21.973-10.986-37.354-13.184-43.945-21.973-63.721-6.5918-15.381-17.578-37.354-10.986-24.17-21.973-50.537-6.5918-15.381-17.578-39.551-8.7891-26.367-15.381-48.34-6.5918-19.775-6.5918-41.748 0-116.46 112.06-116.46 116.46 0 116.46 114.26 0 28.564-8.7891 41.748-30.762 83.496-32.959 90.088-32.959 65.918-41.748 85.693-15.381 35.156-19.775 63.721-6.5918 13.184-8.7891 24.17-2.1972 8.7891-4.3945 15.381z"/>
<path d="m1969.4 2566.6q0-87.891-4.3945-241.7-4.3945-156.01-32.959-259.28-90.088-294.43-272.46-474.61-94.482-92.285-290.04-171.39-224.12-87.891-424.07-87.891-347.17 0-514.16 248.29-94.482 131.84-94.482 329.59 0 215.33 105.47 353.76 54.932 79.102 210.94 195.56 156.01 116.46 263.67 215.33 191.16-41.748 430.66-72.51 241.7-30.762 621.83-35.156z" fill="#fff"/>
<path d="m1802.4 2397.4q-274.66 4.3945-503.17 28.564-226.32 24.17-351.56 68.115-65.918-81.299-153.81-162.6-85.693-83.496-151.61-156.01-109.86-112.06-109.86-246.09 0-164.79 54.932-241.7 59.326-87.891 182.37-129.64 123.05-43.945 248.29-43.945 158.2 0 303.22 70.312 142.82 74.707 188.96 118.65 149.41 151.61 232.91 329.59 28.564 65.918 41.748 184.57 15.381 116.46 17.578 180.18z"/>
<path d="m2125.4 872.49h-156.01v-287.84h-182.37q-74.707 0-74.707-72.51v-2.1973q0-72.51 74.707-72.51h182.37v-186.77q0-76.904 79.102-76.904 76.904 0 76.904 76.904v186.77h188.96q72.51 0 72.51 72.51v2.1973q0 72.51-72.51 72.51l-186.77 2.1973z"/>
<path d="m3191 3605.9-65.918-259.28q-428.47-98.877-1076.7-98.877-641.6 0-1072.3 98.877l-70.312 261.47q417.48-127.44 1144.8-127.44 349.37 0 648.19 35.156 298.83 35.156 492.19 90.088z" fill="#fff"/>
<path d="m3133.9 2948.9q-404.3-112.06-1076.7-112.06-676.76 0-1089.8 114.26l32.959 221.92q415.28-107.67 1056.9-107.67 639.4 0 1041.5 105.47z" fill="#fff"/>
<path d="m2127.6 2568.8q377.93 6.5918 619.63 37.354 241.7 30.762 435.06 72.51 120.85-118.65 268.07-226.32t206.54-184.57q105.47-142.82 105.47-355.96 0-195.56-94.482-327.39-169.19-248.29-516.36-248.29-202.15 0-421.88 87.891-199.95 79.102-290.04 171.39-186.77 180.18-272.46 474.61-32.959 101.07-37.354 257.08-2.1972 156.01-2.1972 241.7z" fill="#fff"/>
<path d="m2288 2397.4q0-63.721 13.184-180.18 15.381-118.65 46.143-184.57 81.299-177.98 232.91-329.59 43.945-43.945 188.96-118.65 142.82-70.312 305.42-70.312 123.05 0 243.9 43.945 123.05 41.748 184.57 129.64 54.932 74.707 54.932 241.7 0 131.84-107.67 246.09-70.312 72.51-151.61 147.22-81.299 72.51-156.01 171.39-127.44-43.945-355.96-68.115-226.32-24.17-498.78-28.564z"/>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 4096 4096" xmlns="http://www.w3.org/2000/svg">
<title>Chess Black Knight</title>
<path d="m2166.7 613.11q344.97 21.973 639.4 177.98 294.43 156.01 500.98 399.9 145.02 171.39 272.46 413.09 129.64 241.7 206.54 511.96 87.891 314.21 109.86 661.38 24.17 344.97 24.17 639.4v479t-342.77 0q-340.58 0-885.5 0h-1474.4q-19.775 0-19.775-107.67 2.1972-107.67 15.381-173.58 8.7891-52.734 41.748-149.41t109.86-235.11q35.156-70.312 166.99-204.35 131.84-136.23 303.22-314.21 98.877-101.07 153.81-254.88 57.129-153.81 48.34-279.05-81.299 65.918-177.98 107.67-465.82 166.99-674.56 483.4-15.381 19.775-98.877 177.98-43.945 83.496-74.707 114.26-41.748 41.748-120.85 46.143-123.05 6.5918-191.16-118.65-92.285 26.367-164.79 21.973-123.05-46.143-177.98-98.877-112.06-112.06-147.22-224.12-32.959-112.06-32.959-241.7 0-184.57 228.52-487.79 268.07-349.37 285.64-531.74 0-79.102 15.381-177.98 13.184-68.115 54.932-131.84 28.564-43.945 37.354-59.326 10.986-17.578 37.354-50.537 19.775-26.367 32.959-39.551 13.184-15.381 32.959-39.551 24.17-28.564 61.523-65.918-116.46-320.8-94.482-661.38 437.26 156.01 733.89 489.99 72.51-248.29 285.64-402.1 175.78 123.05 279.05 325.2z"/>
<path d="m1237.2 1355.8q48.34-24.17 48.34-24.17 65.918-26.367 41.748-96.68-26.367-65.918-101.07-43.945-259.28 94.482-358.15 347.17-15.381 72.51 52.734 98.877 68.115 21.973 92.285-48.34 17.578-37.354 26.367-48.34 24.17 19.775 63.721 26.367 134.03 21.973 156.01-118.65 6.5918-48.34-21.973-92.285z" fill="#fff"/>
<path d="m683.47 2450q63.721-43.945 13.184-105.47-52.734-50.537-116.46-8.789-134.03 87.891-142.82 230.71 2.1973 72.51 85.693 68.115 79.102-6.5918 76.904-81.299 17.578-70.312 83.496-103.27z" fill="#fff"/>
<path d="m3654.2 3739.8q-2.1972 0 4.3946-120.85 6.5918-118.65 6.5918-257.08 2.1972-274.66 0-566.89-2.1973-294.43-79.102-586.67-74.707-281.25-164.79-479-90.088-199.95-195.56-344.97-158.2-237.3-430.66-402.1-272.46-164.79-569.09-215.33 8.789 50.537 4.3945 103.27-2.1973 52.734 2.1973 101.07 213.13 72.51 402.1 180.18 188.96 107.67 276.86 232.91 105.47 145.02 195.56 344.97 90.088 197.75 164.79 479 74.707 292.24 76.904 586.67 2.1973 292.24 2.1973 566.89 0 138.43-6.5918 257.08-6.5918 120.85 0 120.85z" fill="#fff"/>
<path d="m1595.4 953.71q-10.986-79.102 4.3946-171.39-131.84 26.367-248.29 123.05-70.312 37.353-32.959 103.27 37.354 68.115 103.27 21.973 46.142-24.17 83.496-43.945 39.551-21.973 90.088-32.959z" fill="#fff"/>
<path d="m2168.9 2026q153.81-202.15 151.61-450.44-8.7891-72.51-83.496-72.51-101.07 0-79.102 74.707 6.5918 120.85-24.17 199.95-50.537 125.24-112.06 186.77-32.959 65.918 39.551 96.68 70.312 32.959 107.67-35.156z" fill="#fff"/>
<path d="m872.46 2841.2q8.789-19.775 28.564-59.326 37.354-92.285 37.354-109.86-4.3945-61.523-63.721-61.523-43.945 0-103.27 125.24-8.7891 17.578-26.367 30.762-59.326 61.524 17.578 103.27 70.312 41.748 109.86-28.564z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 4096 4096" xmlns="http://www.w3.org/2000/svg">
<title>Chess Black Pawn</title>
<path d="m2049.1 3846.5h-1186.5q-87.891-219.73-87.891-459.23 0-406.49 230.71-729.49 230.71-323 593.26-461.43-156.01-72.51-254.88-217.53-96.68-147.22-96.68-331.79 0-230.71 153.81-399.9 153.81-169.19 377.93-195.56-177.98-134.03-177.98-353.76 0-184.57 129.64-316.41 131.84-131.84 318.6-131.84 184.57 0 316.41 131.84 131.84 131.84 131.84 316.41 0 219.73-177.98 353.76 224.12 26.367 377.93 195.56 153.81 169.19 153.81 399.9 0 184.57-101.07 331.79-98.877 145.02-252.69 217.53 362.55 138.43 593.26 461.43 230.71 323 230.71 729.49 0 237.3-85.693 459.23z" dominant-baseline="central" aria-label="♟"/>
</svg>

After

Width:  |  Height:  |  Size: 756 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 4096 4096" xmlns="http://www.w3.org/2000/svg">
<title>Chess Black Queen</title>
<path d="m2045.9 767.01q-125.24 0-213.13-87.891-85.693-87.891-85.693-213.13 0-123.05 85.693-210.94 87.891-87.891 213.13-87.891 123.05 0 210.94 87.891 90.088 87.891 90.088 210.94 0 125.24-90.088 213.13-87.891 87.891-210.94 87.891z"/>
<path d="m3399.4 3706.9q-107.67 94.482-483.4 158.2t-865.72 63.721q-498.78 0-876.71-65.918t-476.81-164.8l138.43-527.34-61.523-344.97-193.36-336.18-186.77-1366.7 107.67-41.748 602.05 1015.1 13.184-1208.5 149.41-26.367 459.23 1215.1 246.09-1307.4h151.61l246.09 1303 454.83-1210.7 151.61 26.367 13.184 1208.5 604.25-1017.3 103.27 48.34-182.37 1360.1-195.56 336.18-61.523 349.37z"/>
<path d="m1123 876.8q-125.24 0-213.13-85.693-87.891-87.891-87.891-213.13 0-123.05 87.891-210.94 87.891-87.891 213.13-87.891 123.05 0 210.94 87.891 87.891 87.891 87.891 210.94 0 125.24-87.891 213.13-87.891 85.693-210.94 85.693z"/>
<path d="m2968.7 876.8q-125.24 0-213.13-85.693-85.693-87.891-85.693-213.13 0-123.05 85.693-210.94 87.891-87.891 213.13-87.891 125.24 0 213.13 87.891 87.891 87.891 87.891 210.94 0 125.24-87.891 213.13-87.891 85.693-213.13 85.693z"/>
<path d="m314.38 1118.5q-125.24 0-213.13-85.693-85.693-87.891-85.693-213.13 0-123.05 85.693-210.94 87.891-90.088 213.13-90.088t213.13 90.088q87.891 87.891 87.891 210.94 0 125.24-87.891 213.13-87.891 85.693-213.13 85.693z"/>
<path d="m3781.7 1118.5q-125.24 0-213.13-85.693-87.891-87.891-87.891-213.13 0-123.05 87.891-210.94 87.891-90.088 213.13-90.088 123.05 0 210.94 90.088 87.891 87.891 87.891 210.94 0 125.24-87.891 213.13-87.891 85.693-210.94 85.693z"/>
<path d="m3131.3 2979.6q-404.3-112.06-1076.7-112.06-676.76 0-1089.8 114.26l32.959 221.92q415.28-107.67 1056.9-107.67 639.4 0 1041.5 105.47z" fill="#fff"/>
<path d="m3188.4 3636.6-65.918-259.28q-428.47-98.877-1076.7-98.877-641.6 0-1072.3 98.877l-70.312 261.47q417.48-127.44 1144.8-127.44 349.37 0 648.19 35.156 298.83 35.156 492.19 90.088z" fill="#fff"/>
<path d="m3285.1 2586.3q-182.37-65.918-514.16-107.67-329.59-43.945-729.49-43.945-391.11 0-716.31 41.748-325.2 39.551-511.96 105.47l109.86 199.95q184.57-54.932 479-79.102t643.8-24.17 646 24.17 481.2 81.299z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 4096 4096" xmlns="http://www.w3.org/2000/svg">
<title>Chess Black Rook</title>
<path d="m2350.1 549.46h373.54v-301.03h602.05v823.97l-487.79 375.73v1050.3l373.54 373.54v450.44h336.18v525.15h-2999.3v-525.15h336.18v-450.44l375.73-373.54v-1050.3l-487.79-375.73v-823.97h599.85v301.03h375.73v-301.03h602.05z"/>
<path d="m2049 2836.8h-909.67l-101.07 94.482v127.44h2021.5v-127.44l-101.07-94.482z" fill="#fff"/>
<path d="m2049 2458.9h-637.21v101.07l-131.84 127.44h1538.1l-131.84-127.44v-101.07z" fill="#fff"/>
<path d="m2049 1259.2h-769.04l131.84 103.27v125.24h1274.4v-125.24l131.84-103.27z" fill="#fff"/>
<path d="m2049 887.86h-1125v101.07l160.4 120.85h1931.4l156.01-120.85v-101.07z" fill="#fff"/>
<path d="m1038.3 3287.3v224.12h2021.5v-224.12z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 845 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 4096 4096" xmlns="http://www.w3.org/2000/svg">
<title>Chess board Black perspective</title>
<rect x="3584" y="3584" width="512" height="512" fill="#edb"/>
<rect x="3072" y="3584" width="512" height="512" fill="#b86"/>
<rect x="2560" y="3584" width="512" height="512" fill="#edb"/>
<rect x="2048" y="3584" width="512" height="512" fill="#b86"/>
<rect x="1536" y="3584" width="512" height="512" fill="#edb"/>
<rect x="1024" y="3584" width="512" height="512" fill="#b86"/>
<rect x="512" y="3584" width="512" height="512" fill="#edb"/>
<rect y="3584" width="512" height="512" fill="#b86"/>
<rect x="3584" y="3072" width="512" height="512" fill="#b86"/>
<rect x="3072" y="3072" width="512" height="512" fill="#edb"/>
<rect x="2560" y="3072" width="512" height="512" fill="#b86"/>
<rect x="2048" y="3072" width="512" height="512" fill="#edb"/>
<rect x="1536" y="3072" width="512" height="512" fill="#b86"/>
<rect x="1024" y="3072" width="512" height="512" fill="#edb"/>
<rect x="512" y="3072" width="512" height="512" fill="#b86"/>
<rect y="3072" width="512" height="512" fill="#edb"/>
<rect x="3584" y="2560" width="512" height="512" fill="#edb"/>
<rect x="3072" y="2560" width="512" height="512" fill="#b86"/>
<rect x="2560" y="2560" width="512" height="512" fill="#edb"/>
<rect x="2048" y="2560" width="512" height="512" fill="#b86"/>
<rect x="1536" y="2560" width="512" height="512" fill="#edb"/>
<rect x="1024" y="2560" width="512" height="512" fill="#b86"/>
<rect x="512" y="2560" width="512" height="512" fill="#edb"/>
<rect y="2560" width="512" height="512" fill="#b86"/>
<rect x="3584" y="2048" width="512" height="512" fill="#b86"/>
<rect x="3072" y="2048" width="512" height="512" fill="#edb"/>
<rect x="2560" y="2048" width="512" height="512" fill="#b86"/>
<rect x="2048" y="2048" width="512" height="512" fill="#edb"/>
<rect x="1536" y="2048" width="512" height="512" fill="#b86"/>
<rect x="1024" y="2048" width="512" height="512" fill="#edb"/>
<rect x="512" y="2048" width="512" height="512" fill="#b86"/>
<rect y="2048" width="512" height="512" fill="#edb"/>
<rect x="3584" y="1536" width="512" height="512" fill="#edb"/>
<rect x="3072" y="1536" width="512" height="512" fill="#b86"/>
<rect x="2560" y="1536" width="512" height="512" fill="#edb"/>
<rect x="2048" y="1536" width="512" height="512" fill="#b86"/>
<rect x="1536" y="1536" width="512" height="512" fill="#edb"/>
<rect x="1024" y="1536" width="512" height="512" fill="#b86"/>
<rect x="512" y="1536" width="512" height="512" fill="#edb"/>
<rect y="1536" width="512" height="512" fill="#b86"/>
<rect x="3584" y="1024" width="512" height="512" fill="#b86"/>
<rect x="3072" y="1024" width="512" height="512" fill="#edb"/>
<rect x="2560" y="1024" width="512" height="512" fill="#b86"/>
<rect x="2048" y="1024" width="512" height="512" fill="#edb"/>
<rect x="1536" y="1024" width="512" height="512" fill="#b86"/>
<rect x="1024" y="1024" width="512" height="512" fill="#edb"/>
<rect x="512" y="1024" width="512" height="512" fill="#b86"/>
<rect y="1024" width="512" height="512" fill="#edb"/>
<rect x="3584" y="512" width="512" height="512" fill="#edb"/>
<rect x="3072" y="512" width="512" height="512" fill="#b86"/>
<rect x="2560" y="512" width="512" height="512" fill="#edb"/>
<rect x="2048" y="512" width="512" height="512" fill="#b86"/>
<rect x="1536" y="512" width="512" height="512" fill="#edb"/>
<rect x="1024" y="512" width="512" height="512" fill="#b86"/>
<rect x="512" y="512" width="512" height="512" fill="#edb"/>
<rect y="512" width="512" height="512" fill="#b86"/>
<rect x="3584" width="512" height="512" fill="#b86"/>
<rect x="3072" width="512" height="512" fill="#edb"/>
<rect x="2560" width="512" height="512" fill="#b86"/>
<rect x="2048" width="512" height="512" fill="#edb"/>
<rect x="1536" width="512" height="512" fill="#b86"/>
<rect x="1024" width="512" height="512" fill="#edb"/>
<rect x="512" width="512" height="512" fill="#b86"/>
<rect width="512" height="512" fill="#edb"/>
<g>
<path d="m4053.5 4028.1h0.6391l2.8306-2.1001q3.9263-3.0132 5.6611-6.209 1.9175-3.3784 1.9175-8.1264 0-5.4786-3.561-8.4917-3.3784-3.1045-8.7656-3.1045-5.2046 0-8.4917 3.0131-3.5611 3.1045-3.5611 7.9439 0 7.1221 9.77 14.609zm-4.2002 9.9526h-0.2739l-3.7437 2.7393q-6.8481 5.7524-6.8481 14.244 0 6.3003 4.5654 10.957 4.8393 4.748 11.14 4.748 5.6612 0 8.9483-3.2871 3.1958-3.1958 3.1958-8.8569 0-6.209-5.9351-11.779-2.6479-2.3741-5.5698-4.7481zm-7.8526-6.1176q-2.7392-1.9175-5.4785-5.1133-4.5654-5.1133-4.5654-12.144 0-8.8569 6.209-14.153 5.6611-4.8394 14.883-4.8394 8.6743 0 14.062 3.835 6.7568 4.7481 6.7568 12.601-0.1826 7.9438-6.6655 13.514-2.9219 2.5566-5.4785 3.9262l-3.0132 1.461h-0.1826l4.2002 3.1045q4.1089 3.0131 7.1221 6.3916 3.1958 3.561 4.5654 7.7612 1.187 3.1958 1.187 6.9395-0.1826 9.8613-7.0308 15.157-6.3916 4.8393-16.07 4.6567-9.1308 0-15.157-4.8393-7.1221-5.7525-7.1221-14.336 0-3.4697 1.3697-6.9394 2.5566-6.8482 11.596-12.144l2.9218-1.7348h-0.1826z" fill="#b86" aria-label="8"/>
<path d="m4032.3 3486h42.732l-25.384 75.877h-7.7612l22.553-65.925v-0.3652h-22.188l-4.0176 0.2739q-5.2959 0.4566-7.5786 4.2915l-2.7392 4.2002-2.648-0.9131z" fill="#edb" aria-label="7"/>
<path d="m4038.7 3012.2-0.8218 4.1089q-0.3652 1.8261-0.3652 5.2958 0 7.8526 1.8261 14.792 0.6392 3.1045 2.9219 6.483 3.3784 5.4785 9.4048 5.2959 11.87 0.3652 12.053-18.627 0-7.9439-2.9218-13.788-4.0176-7.6699-10.957-7.6699-4.8393 0-11.14 4.1089zm1.6435-5.8438q8.2178-4.3828 14.609-4.3828 6.9395 0 11.688 3.835 5.2959 4.3828 7.1221 10.318 1.2783 4.0176 1.2783 9.0396 0 15.705-10.5 23.192-2.5566 2.0088-5.5698 2.9218-3.835 1.2784-7.9439 1.2784-11.688 0-18.992-10.5-5.8438-8.4004-5.8438-21.549 0-16.527 8.4004-28.123 3.7436-5.3872 9.8613-9.8613 5.4785-4.1089 11.322-6.4829 6.3916-2.5567 13.422-2.9219l4.1088 0.091v2.5566l-3.1958 0.7305q-3.7436 1.0044-8.8569 3.0132-14.335 6.209-19.631 22.279z" fill="#b86" aria-label="6"/>
<path d="m4045.4 2462.1h29.675l-4.9307 9.6787h-24.014v0.274l-4.4741 8.9482v-0.091l4.1088 0.7305q27.393 5.2959 27.575 29.036 0 12.874-9.3134 21.184-8.7657 7.9438-21.732 7.9438-5.022 0-8.7656-1.187-5.5698-1.7348-5.5698-6.0263 0-4.8394 5.3872-5.022 2.8305 0 8.7656 3.6523 3.9263 2.4654 7.8525 2.4654 6.4829 0 10.866-6.0264 3.7436-5.1133 3.7436-12.053 0-19.175-26.936-23.74l-6.209-0.9131z" fill="#edb" aria-label="5"/>
<path d="m4030.5 2001.1v-0.274h25.475v-36.158h0.1826zm29.858-50.859h5.2046v50.585h11.414v8.7657h-11.414v18.262h-9.5874v-18.262h-29.675v-10.044z" fill="#b86" aria-label="4"/>
<path d="m4032.7 1453.7q1.2783-3.835 3.4697-6.6655 2.2828-2.9219 5.1133-4.7481 2.8306-1.9175 5.9351-2.8306 3.1958-1.0043 6.3003-1.0043 3.8349 0 7.0307 1.187t5.3872 3.2871q2.2828 2.0088 3.4698 4.8393 1.2783 2.7393 1.2783 5.9351 0 4.4741-1.9175 7.9438-1.8262 3.3785-5.1133 6.1177l-2.5566 2.1001q3.8349 1.9175 6.4829 4.1089 2.6479 2.1914 4.2915 4.8394 1.6436 2.5566 2.374 5.5698 0.7305 3.0132 0.8218 6.5742 0 6.1177-2.374 11.14-2.374 4.9307-6.6655 8.4004-4.2002 3.4698-9.9527 5.3872-5.7524 1.9175-12.509 1.9175-6.3003 0-9.2221-1.6435-2.9219-1.6436-2.9219-4.8394 0-0.7305 0.2739-1.5522 0.3653-0.8218 0.9131-1.5523 0.6392-0.7304 1.4609-1.187 0.9131-0.5478 2.1001-0.5478 0.6392 0 1.5523 0.2739 0.9131 0.1826 1.8262 0.5478 1.0044 0.3653 1.8261 0.7305 0.9131 0.3652 1.461 0.6392 2.5566 1.2783 3.8349 1.9174 1.2783 0.6392 2.1001 0.9131 0.8218 0.274 1.461 0.274 0.7304 0 2.1001 0 2.7392 0 5.2046-1.0957 2.5566-1.0958 4.4741-3.1045 1.9175-2.1001 3.1045-4.9307 1.187-2.9219 1.2783-6.5742-0.1826-3.4698-1.2783-6.5742-1.0044-3.1958-3.6524-5.5699-2.5566-2.374-7.122-3.7436-4.4742-1.461-11.505-1.5523v-2.6479q9.6787-2.9219 14.427-7.2134 4.7481-4.3828 4.7481-10.044-0.091-2.3741-1.0957-4.4742-0.9131-2.1001-2.5567-3.6523-1.6435-1.5522-3.9262-2.4653-2.2828-0.9131-5.022-0.9131-2.4653 0-4.4741 0.4565-1.9175 0.3653-3.7437 1.6436-1.8262 1.187-3.6523 3.4697-1.7349 2.1914-3.9263 5.7524l-2.1914-1.0044z" fill="#edb" aria-label="3"/>
<path d="m4059.1 996.31 5.2046-0.0913q3.4697-0.0913 5.2046-2.0088l2.1914-2.5566 1.7348-2.2827 1.6436 0.82178-6.1177 15.705h-44.467v-1.8262l9.9527-10.592 9.0395-9.5874q5.1133-5.9351 8.2178-10.044 3.835-5.1133 5.3872-9.3135 1.3696-4.3828 1.3696-10.135 0-7.4873-3.3784-11.961-4.2915-5.3872-11.048-5.3872-11.048 0-16.436 11.688l-0.9131 2.2827-2.1914-1.0044 0.5479-2.1001q2.2827-8.4004 8.0351-13.331 3.0132-2.7393 7.3047-4.4741 4.7481-1.8262 9.3135-1.8262 8.8569 0.18262 14.518 5.9351 5.6611 6.1177 5.6611 14.701 0 10.044-9.5874 22.188-5.3872 6.3916-10.318 11.505l-7.5786 7.8525-5.4785 5.4785v0.36524z" fill="#b86" aria-label="2"/>
<path d="m4064.6 416.98v66.107q0 5.6611 2.7393 7.4873 2.0088 1.4609 7.7612 1.4609v2.5566h-30.497v-2.5566q5.9351 0 7.8526-1.5522 2.5566-2.374 2.5566-7.9438v-49.398q0-1.5522-0.5478-2.9219l-0.6392-1.0957q-0.5479-0.82178-1.9175-0.82178-1.0044 0-3.2871 0.91309l-4.0176 1.7349v-2.648z" fill="#edb" aria-label="1"/>
</g>
<g>
<path d="m3629.7 52.223-5.6611 2.374q-11.962 5.1133-11.779 12.966 0 3.4697 1.9175 5.6611 2.0088 2.5566 5.2959 2.5566 4.5655 0 8.6743-3.561l1.5523-1.6436zm0-7.4873v-2.8306q0-2.6479-0.3652-4.6567-0.8218-6.6655-7.3047-6.8481-6.9395 0-7.1221 5.4785v3.6523q-0.1826 5.7524-5.3872 5.7524-2.648 0-3.9263-1.7349-1.187-1.6436-1.187-4.1089 0-3.6523 3.2871-7.1221 2.5567-2.6479 6.7569-4.1089 3.6523-1.2783 7.6699-1.2783 3.6523 0 7.122 1.0957 7.0308 2.2827 9.0396 8.1265 1.0044 2.9219 1.0044 5.4785v30.041q0.3652 4.1089 3.1958 4.1089 1.8262 0 4.6567-1.7349v3.4697l-0.9131 1.0044q-2.4653 2.2827-4.5654 3.1958-2.1914 1.0044-4.4741 1.0044-5.3872 0.18262-6.9395-5.4785l-0.5478-2.374-2.7393 2.374q-6.8481 5.4785-13.057 5.4785-5.5698 0-8.7656-3.3784-3.2871-3.2871-3.2871-8.6743 0-4.2002 1.8261-7.2134 2.0088-3.1958 5.2959-5.6611 5.2046-3.7437 14.336-7.396l6.3916-2.5566z" fill="#edb" aria-label="a"/>
<path d="m3104.4 74.959 0.7305 1.0957q3.1958 3.9263 8.8569 3.9263 5.7525 0 9.5874-4.1089 2.374-2.5566 3.3784-5.6611 1.8262-5.5698 1.8262-12.327 0-8.4917-2.8306-14.792-3.8349-8.4917-11.048-8.4917-7.8525 0.36523-9.9526 7.4873-0.5479 1.6436-0.5479 4.2915zm2.7393-41.819q5.2959-5.4785 12.601-5.4785 3.3784 0 6.5742 1.3696 3.1958 1.4609 5.5698 4.0176 3.6523 3.7437 5.2959 8.4004 2.1001 6.3003 2.1001 12.144-0.1826 9.6787-4.2915 16.527-4.9307 8.2178-12.327 11.322-4.748 2.0088-9.6787 2.0088-4.4741 0-9.2221-1.2783-4.2915-1.0957-6.3916-2.8306l-2.648-2.2827v-58.803l-0.091-2.374q-0.4565-3.835-5.3872-4.0176l-2.374 0.09131v-2.648l17.531-5.2046v32.323z" fill="#b86" aria-label="b"/>
<path d="m2623.4 65.554-1.1871 2.5566q-2.2827 4.2002-5.4785 8.0352-3.8349 4.2915-8.0351 5.9351-3.4698 1.4609-7.9439 1.4609-5.3872 0-9.1308-2.0088-4.0176-2.1914-6.4829-5.6611-3.0132-4.2915-4.2916-9.77-1.0957-4.6567-1.0957-9.6787 0.091-13.422 6.483-21.366 5.5698-7.0308 16.253-7.396 6.209 0 11.048 2.7393 6.3916 3.561 6.3916 9.0396-0.1826 4.8394-5.1133 4.8394-5.1132 0-6.2089-6.7568-0.9131-3.561-2.1001-4.748-1.5523-1.5522-4.8394-1.5522-6.6655 0-10.5 6.7568-2.6479 4.748-2.6479 13.057 0 5.4785 1.0044 9.6787 2.7392 13.24 14.975 13.24 8.9482 0 15.157-7.396l1.6436-2.1914z" fill="#edb" aria-label="c"/>
<path d="m2099 71.854v-26.388q0-3.7437-0.9131-6.9395-0.9131-2.7393-3.9263-5.2046-2.7393-2.1914-6.1177-2.1914-5.1133 0-8.2177 3.835-2.2828 2.2827-3.3785 5.8438-1.8261 5.1133-1.8261 11.596 0 6.8481 2.1001 12.783 3.7436 11.048 13.148 11.322 5.9351-0.09131 9.1309-4.6567zm0 5.4785-1.9175 1.7349q-5.6612 4.3828-12.509 4.3828-7.5786 0-13.422-6.1177-3.561-3.4697-4.8393-7.4873-2.1914-5.4785-2.1914-12.053 0-11.322 5.935-20.088 7.0308-10.135 17.531-10.135 5.8437 0 10.226 3.3784l1.1871 1.0044v-13.788l-0.091-2.374q-0.4565-3.7437-5.2958-3.9263h-2.4654v-2.5566l17.44-5.2959v66.016q0 3.9263 1.187 4.748l0.8217 0.54785q0.9131 0.45654 2.9219 0.45654l2.9219-0.18262v2.6479l-17.44 5.2046z" fill="#b86" aria-label="d"/>
<path d="m1587.2 45.192-0.274-2.4653q-1.187-7.5786-5.2046-10.044-2.374-1.4609-4.748-1.4609-4.8394 0-7.8525 4.2002-3.0132 4.2002-3.4698 8.4004l-0.2739 1.3696zm13.97 20.453-1.187 2.5566q-8.0352 15.34-22.005 15.34-10.318 0-16.344-8.4004-5.022-7.0308-5.022-18.353 0-6.9395 2.1914-13.514 2.2827-7.0308 7.4873-11.231 5.4785-4.2915 12.601-4.2915 8.9482 0.18262 14.518 5.9351 5.3872 5.5698 5.9351 14.701l0.091 1.187h-33.967l-0.091 1.6436q0 6.5742 1.3696 10.957 3.3785 11.779 14.975 11.87 9.3135 0 15.614-7.4873l1.7349-2.1914z" fill="#edb" aria-label="e"/>
<path d="m1056.5 30.4 0.2739-5.2046q0.4566-8.4004 5.3872-14.153 5.022-5.7524 13.696-5.9351 11.779 0.18262 12.053 7.396 0 4.8394-4.748 4.8394-3.1045 0-5.6611-4.6567-2.3741-3.9263-5.2959-4.1089-5.8438 0-5.8438 8.583l-0.1826 3.7437-0.091 3.6523v5.8438h13.148v4.3828h-13.148v33.784q0 5.022 0.8218 7.8525 0.8218 3.561 6.3916 3.6523l2.374 0.09131v2.5566h-27.027v-2.5566q5.3872 0 6.9394-3.3784 0.9131-1.9175 0.9131-8.2178v-33.784h-8.6743v-4.3828z" fill="#b86" aria-label="f"/>
<path d="m545.56 41.905q0 5.8438 1.8262 11.414 2.5566 8.1265 8.9482 8.1265 8.4004-0.18262 8.4004-11.048 0-6.3003-2.648-11.961-3.3784-7.5786-8.6743-7.5786-8.0352 0.36524-7.8525 11.048zm-0.27392 39.628-2.8306 3.1045q-3.0132 3.2871-3.0132 6.209 0 8.1265 17.349 8.1265 6.0264 0 11.961-2.0088 8.2178-2.8306 8.2178-8.4004 0-5.1133-7.2134-5.4785l-16.801-0.73047-4.3828-0.45654zm35.976-46.293h-9.4048v0.18262l1.4609 4.8394q1.0044 3.6523 1.0044 6.3003 0 4.8394-2.7393 9.5874-2.648 4.748-7.3047 6.8481-4.1089 1.9175-7.9438 1.9175-2.5566 0-6.4829-0.82178l-2.8306 2.4653q-2.2827 2.2827-2.374 4.1089-0.18262 2.2827 4.1089 2.5566 1.6436 0.18262 3.4697 0.36523 1.8262 0.09131 3.7436 0.09131l9.4961 0.27393q14.975 0.82178 14.975 12.783-0.18262 8.6743-11.231 14.609-8.4917 4.5654-18.444 4.5654-6.9394 0-11.87-2.5566-7.3047-3.835-7.3047-9.3135 0-2.0088 2.7393-5.3872l2.2827-2.5566 5.3872-5.4785q-5.2046-2.0088-5.2046-5.7524 0-3.0132 3.6523-6.5742l3.835-3.3784 2.1001-1.8262q-5.8438-3.3784-7.8525-6.5742-2.5566-4.0176-2.5566-9.6787 0-8.583 5.4785-13.97 5.7524-5.4785 14.062-5.4785 5.3872 0 10.318 2.374l1.2783 0.54785v-0.09131q1.7349 0.63916 5.4785 0.63916h8.6743z" fill="#edb" aria-label="g"/>
<path d="m17.199 80.163q4.748-0.09131 6.4829-2.8306 1.3696-2.1001 1.3696-7.2134v-50.859l-0.09131-2.374q-0.18262-3.6523-5.3872-4.0176l-2.374 0.09131v-2.648l17.44-5.2046v32.323l3.1045-3.0132q6.3003-5.7524 12.875-5.7524 9.77 0 12.418 8.8569 0.91309 2.7393 1.0957 6.209l0.18262 3.561v21.64q0 6.0264 0.91309 7.9438 1.5522 3.2871 6.9395 3.2871v2.5566h-25.292v-2.5566q5.4785-0.09131 6.7568-3.1958 1.0957-2.9219 1.0957-8.0352v-21.823q0-0.45654-0.45654-4.5654-1.2783-7.7612-8.2178-7.7612-4.2915 0.18262-8.0352 3.3784l-3.3784 3.4697v28.488q0 5.4785 1.187 7.2134 1.8262 2.4653 6.6655 2.8306v2.5566h-25.292z" fill="#b86" aria-label="h"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 4096 4096" xmlns="http://www.w3.org/2000/svg">
<title>Chess board White perspective</title>
<rect width="512" height="512" fill="#edb"/>
<rect x="512" width="512" height="512" fill="#b86"/>
<rect x="1024" width="512" height="512" fill="#edb"/>
<rect x="1536" width="512" height="512" fill="#b86"/>
<rect x="2048" width="512" height="512" fill="#edb"/>
<rect x="2560" width="512" height="512" fill="#b86"/>
<rect x="3072" width="512" height="512" fill="#edb"/>
<rect x="3584" width="512" height="512" fill="#b86"/>
<rect y="512" width="512" height="512" fill="#b86"/>
<rect x="512" y="512" width="512" height="512" fill="#edb"/>
<rect x="1024" y="512" width="512" height="512" fill="#b86"/>
<rect x="1536" y="512" width="512" height="512" fill="#edb"/>
<rect x="2048" y="512" width="512" height="512" fill="#b86"/>
<rect x="2560" y="512" width="512" height="512" fill="#edb"/>
<rect x="3072" y="512" width="512" height="512" fill="#b86"/>
<rect x="3584" y="512" width="512" height="512" fill="#edb"/>
<rect y="1024" width="512" height="512" fill="#edb"/>
<rect x="512" y="1024" width="512" height="512" fill="#b86"/>
<rect x="1024" y="1024" width="512" height="512" fill="#edb"/>
<rect x="1536" y="1024" width="512" height="512" fill="#b86"/>
<rect x="2048" y="1024" width="512" height="512" fill="#edb"/>
<rect x="2560" y="1024" width="512" height="512" fill="#b86"/>
<rect x="3072" y="1024" width="512" height="512" fill="#edb"/>
<rect x="3584" y="1024" width="512" height="512" fill="#b86"/>
<rect y="1536" width="512" height="512" fill="#b86"/>
<rect x="512" y="1536" width="512" height="512" fill="#edb"/>
<rect x="1024" y="1536" width="512" height="512" fill="#b86"/>
<rect x="1536" y="1536" width="512" height="512" fill="#edb"/>
<rect x="2048" y="1536" width="512" height="512" fill="#b86"/>
<rect x="2560" y="1536" width="512" height="512" fill="#edb"/>
<rect x="3072" y="1536" width="512" height="512" fill="#b86"/>
<rect x="3584" y="1536" width="512" height="512" fill="#edb"/>
<rect y="2048" width="512" height="512" fill="#edb"/>
<rect x="512" y="2048" width="512" height="512" fill="#b86"/>
<rect x="1024" y="2048" width="512" height="512" fill="#edb"/>
<rect x="1536" y="2048" width="512" height="512" fill="#b86"/>
<rect x="2048" y="2048" width="512" height="512" fill="#edb"/>
<rect x="2560" y="2048" width="512" height="512" fill="#b86"/>
<rect x="3072" y="2048" width="512" height="512" fill="#edb"/>
<rect x="3584" y="2048" width="512" height="512" fill="#b86"/>
<rect y="2560" width="512" height="512" fill="#b86"/>
<rect x="512" y="2560" width="512" height="512" fill="#edb"/>
<rect x="1024" y="2560" width="512" height="512" fill="#b86"/>
<rect x="1536" y="2560" width="512" height="512" fill="#edb"/>
<rect x="2048" y="2560" width="512" height="512" fill="#b86"/>
<rect x="2560" y="2560" width="512" height="512" fill="#edb"/>
<rect x="3072" y="2560" width="512" height="512" fill="#b86"/>
<rect x="3584" y="2560" width="512" height="512" fill="#edb"/>
<rect y="3072" width="512" height="512" fill="#edb"/>
<rect x="512" y="3072" width="512" height="512" fill="#b86"/>
<rect x="1024" y="3072" width="512" height="512" fill="#edb"/>
<rect x="1536" y="3072" width="512" height="512" fill="#b86"/>
<rect x="2048" y="3072" width="512" height="512" fill="#edb"/>
<rect x="2560" y="3072" width="512" height="512" fill="#b86"/>
<rect x="3072" y="3072" width="512" height="512" fill="#edb"/>
<rect x="3584" y="3072" width="512" height="512" fill="#b86"/>
<rect y="3584" width="512" height="512" fill="#b86"/>
<rect x="512" y="3584" width="512" height="512" fill="#edb"/>
<rect x="1024" y="3584" width="512" height="512" fill="#b86"/>
<rect x="1536" y="3584" width="512" height="512" fill="#edb"/>
<rect x="2048" y="3584" width="512" height="512" fill="#b86"/>
<rect x="2560" y="3584" width="512" height="512" fill="#edb"/>
<rect x="3072" y="3584" width="512" height="512" fill="#b86"/>
<rect x="3584" y="3584" width="512" height="512" fill="#edb"/>
<g>
<path d="m44.761 53.367h0.63916l2.8306-2.1001q3.9263-3.0132 5.6611-6.209 1.9175-3.3784 1.9175-8.1265 0-5.4785-3.561-8.4917-3.3784-3.1045-8.7656-3.1045-5.2046 0-8.4917 3.0132-3.561 3.1045-3.561 7.9438 0 7.1221 9.77 14.609zm-4.2002 9.9526h-0.27393l-3.7437 2.7393q-6.8481 5.7524-6.8481 14.244 0 6.3003 4.5654 10.957 4.8394 4.748 11.14 4.748 5.6611 0 8.9482-3.2871 3.1958-3.1958 3.1958-8.8569 0-6.209-5.9351-11.779-2.648-2.374-5.5698-4.748zm-7.8525-6.1177q-2.7393-1.9175-5.4785-5.1133-4.5654-5.1133-4.5654-12.144 0-8.8569 6.209-14.153 5.6611-4.8394 14.883-4.8394 8.6743 0 14.062 3.835 6.7568 4.748 6.7568 12.601-0.18262 7.9438-6.6655 13.514-2.9219 2.5566-5.4785 3.9263l-3.0132 1.4609h-0.18262l4.2002 3.1045q4.1089 3.0132 7.1221 6.3916 3.1958 3.561 4.5654 7.7612 1.187 3.1958 1.187 6.9395-0.18262 9.8613-7.0308 15.157-6.3916 4.8394-16.07 4.6567-9.1309 0-15.157-4.8394-7.1221-5.7524-7.1221-14.335 0-3.4697 1.3696-6.9395 2.5566-6.8481 11.596-12.144l2.9219-1.7349h-0.18262z" fill="#b86" aria-label="8"/>
<path d="m23.577 534.6h42.732l-25.384 75.877h-7.7612l22.553-65.925v-0.36523h-22.188l-4.0176 0.27392q-5.2959 0.45654-7.5786 4.2915l-2.7393 4.2002-2.6479-0.91309z" fill="#edb" aria-label="7"/>
<path d="m29.969 1083-0.82178 4.1089q-0.36524 1.8262-0.36524 5.2959 0 7.8526 1.8262 14.792 0.63916 3.1045 2.9219 6.4829 3.3784 5.4785 9.4048 5.2959 11.87 0.3652 12.053-18.627 0-7.9439-2.9219-13.788-4.0176-7.67-10.957-7.67-4.8394 0-11.14 4.1089zm1.6436-5.8437q8.2178-4.3828 14.609-4.3828 6.9395 0 11.688 3.8349 5.2959 4.3828 7.1221 10.318 1.2783 4.0176 1.2783 9.0395 0 15.705-10.5 23.192-2.5566 2.0088-5.5698 2.9219-3.835 1.2783-7.9438 1.2783-11.688 0-18.992-10.5-5.8438-8.4004-5.8438-21.549 0-16.527 8.4004-28.123 3.7437-5.3872 9.8613-9.8614 5.4785-4.1089 11.322-6.4829 6.3916-2.5566 13.422-2.9219l4.1089 0.091v2.5566l-3.1958 0.7305q-3.7437 1.0044-8.8569 3.0132-14.335 6.2089-19.631 22.279z" fill="#b86" aria-label="6"/>
<path d="m36.634 1556.3h29.675l-4.9307 9.6787h-24.014v0.2739l-4.4741 8.9483v-0.091l4.1089 0.7304q27.393 5.2959 27.575 29.036 0 12.874-9.3135 21.184-8.7656 7.9439-21.731 7.9439-5.022 0-8.7656-1.187-5.5698-1.7349-5.5698-6.0264 0-4.8393 5.3872-5.022 2.8306 0 8.7656 3.6524 3.9263 2.4653 7.8525 2.4653 6.4829 0 10.866-6.0263 3.7437-5.1133 3.7437-12.053 0-19.175-26.936-23.74l-6.209-0.9131z" fill="#edb" aria-label="5"/>
<path d="m21.751 2117.6v-0.2739h25.475v-36.158h0.18262zm29.858-50.859h5.2046v50.585h11.414v8.7656h-11.414v18.262h-9.5874v-18.262h-29.675v-10.044z" fill="#b86" aria-label="4"/>
<path d="m23.942 2593.5q1.2783-3.8349 3.4697-6.6655 2.2827-2.9219 5.1133-4.7481 2.8306-1.9174 5.9351-2.8305 3.1958-1.0044 6.3003-1.0044 3.835 0 7.0308 1.187t5.3872 3.2871q2.2827 2.0088 3.4697 4.8394 1.2783 2.7392 1.2783 5.935 0 4.4741-1.9175 7.9439-1.8262 3.3784-5.1133 6.1176l-2.5566 2.1001q3.835 1.9175 6.4829 4.1089 2.6479 2.1914 4.2915 4.8394 1.6436 2.5566 2.374 5.5698t0.82178 6.5742q0 6.1177-2.374 11.14-2.374 4.9306-6.6655 8.4004-4.2002 3.4697-9.9526 5.3872-5.7524 1.9174-12.509 1.9174-6.3003 0-9.2222-1.6435-2.9219-1.6436-2.9219-4.8394 0-0.7304 0.27393-1.5522 0.36524-0.8218 0.91309-1.5523 0.63916-0.7304 1.4609-1.187 0.91309-0.5478 2.1001-0.5478 0.63916 0 1.5522 0.2739 0.91309 0.1826 1.8262 0.5479 1.0044 0.3652 1.8262 0.7304 0.91309 0.3653 1.4609 0.6392 2.5566 1.2783 3.835 1.9175 1.2783 0.6391 2.1001 0.9131 0.82178 0.2739 1.4609 0.2739 0.73047 0 2.1001 0 2.7393 0 5.2046-1.0957 2.5566-1.0957 4.4741-3.1045 1.9175-2.1001 3.1045-4.9307 1.187-2.9219 1.2783-6.5742-0.18262-3.4697-1.2783-6.5742-1.0044-3.1958-3.6523-5.5698-2.5566-2.3741-7.1221-3.7437-4.4741-1.4609-11.505-1.5522v-2.648q9.6787-2.9219 14.427-7.2134 4.748-4.3828 4.748-10.044-0.09131-2.374-1.0957-4.4741-0.91309-2.1001-2.5566-3.6524-1.6436-1.5522-3.9263-2.4653t-5.022-0.9131q-2.4653 0-4.4741 0.4565-1.9175 0.3653-3.7437 1.6436-1.8262 1.187-3.6523 3.4697-1.7349 2.1914-3.9263 5.7525l-2.1914-1.0044z" fill="#edb" aria-label="3"/>
<path d="m50.331 3158.4 5.2046-0.091q3.4697-0.091 5.2046-2.0088l2.1914-2.5566 1.7349-2.2828 1.6436 0.8218-6.1177 15.705h-44.467v-1.8262l9.9526-10.592 9.0396-9.5874q5.1133-5.935 8.2178-10.044 3.835-5.1133 5.3872-9.3135 1.3696-4.3828 1.3696-10.135 0-7.4873-3.3784-11.961-4.2915-5.3872-11.048-5.3872-11.048 0-16.436 11.688l-0.91309 2.2827-2.1914-1.0044 0.54785-2.1001q2.2827-8.4004 8.0352-13.331 3.0132-2.7393 7.3047-4.4741 4.748-1.8262 9.3135-1.8262 8.8569 0.1826 14.518 5.9351 5.6611 6.1176 5.6611 14.701 0 10.044-9.5874 22.188-5.3872 6.3916-10.318 11.505l-7.5786 7.8525-5.4785 5.4786v0.3652z" fill="#b86" aria-label="2"/>
<path d="m55.809 3601.4v66.107q0 5.6611 2.7393 7.4873 2.0088 1.461 7.7612 1.461v2.5566h-30.497v-2.5566q5.9351 0 7.8525-1.5523 2.5566-2.374 2.5566-7.9438v-49.398q0-1.5522-0.54785-2.9219l-0.63916-1.0957q-0.54785-0.8217-1.9175-0.8217-1.0044 0-3.2871 0.913l-4.0176 1.7349v-2.6479z" fill="#edb" aria-label="1"/>
</g>
<g>
<path d="m476.7 4038.6-5.6611 2.374q-11.961 5.1133-11.779 12.966 0 3.4698 1.9175 5.6612 2.0088 2.5566 5.2959 2.5566 4.5654 0 8.6743-3.561l1.5522-1.6436zm0-7.4873v-2.8306q0-2.6479-0.36523-4.6567-0.82178-6.6655-7.3047-6.8482-6.9395 0-7.1221 5.4786v3.6523q-0.18262 5.7524-5.3872 5.7524-2.648 0-3.9263-1.7348-1.187-1.6436-1.187-4.1089 0-3.6524 3.2871-7.1221 2.5566-2.6479 6.7568-4.1089 3.6524-1.2783 7.6699-1.2783 3.6523 0 7.1221 1.0957 7.0308 2.2827 9.0396 8.1265 1.0044 2.9219 1.0044 5.4785v30.04q0.36524 4.1089 3.1958 4.1089 1.8262 0 4.6567-1.7349v3.4698l-0.91308 1.0044q-2.4653 2.2827-4.5654 3.1958-2.1914 1.0044-4.4741 1.0044-5.3872 0.1826-6.9394-5.4786l-0.54785-2.374-2.7393 2.374q-6.8481 5.4786-13.057 5.4786-5.5698 0-8.7656-3.3785-3.2871-3.2871-3.2871-8.6743 0-4.2002 1.8262-7.2133 2.0088-3.1958 5.2959-5.6612 5.2046-3.7436 14.335-7.396l6.3916-2.5566z" fill="#edb" aria-label="a"/>
<path d="m974.83 4061.3 0.73047 1.0957q3.1958 3.9263 8.8569 3.9263 5.7524 0 9.5874-4.1089 2.374-2.5566 3.3784-5.6611 1.8262-5.5698 1.8262-12.327 0-8.4917-2.8306-14.792-3.835-8.4917-11.048-8.4917-7.8525 0.3653-9.9526 7.4873-0.54786 1.6436-0.54786 4.2915zm2.7393-41.819q5.2959-5.4785 12.601-5.4785 3.3784 0 6.5742 1.3696 3.1958 1.4609 5.5698 4.0176 3.6523 3.7436 5.2959 8.4004 2.1001 6.3003 2.1001 12.144-0.1827 9.6787-4.2915 16.527-4.9307 8.2178-12.327 11.322-4.748 2.0088-9.6787 2.0088-4.4741 0-9.2222-1.2783-4.2915-1.0957-6.3916-2.8306l-2.648-2.2827v-58.803l-0.0913-2.374q-0.45654-3.835-5.3872-4.0176l-2.374 0.091v-2.6479l17.531-5.2046v32.323z" fill="#b86" aria-label="b"/>
<path d="m1516.1 4051.9-1.187 2.5567q-2.2827 4.2002-5.4785 8.0351-3.835 4.2916-8.0351 5.9351-3.4698 1.4609-7.9439 1.4609-5.3872 0-9.1309-2.0087-4.0175-2.1915-6.4829-5.6612-3.0131-4.2915-4.2915-9.77-1.0957-4.6567-1.0957-9.6787 0.091-13.422 6.4829-21.366 5.5699-7.0308 16.253-7.396 6.209 0 11.048 2.7392 6.3916 3.5611 6.3916 9.0396-0.1826 4.8393-5.1133 4.8393-5.1133 0-6.209-6.7568-0.913-3.561-2.1001-4.748-1.5522-1.5523-4.8393-1.5523-6.6655 0-10.5 6.7569-2.6479 4.748-2.6479 13.057 0 5.4785 1.0043 9.6787 2.7393 13.24 14.975 13.24 8.9482 0 15.157-7.396l1.6435-2.1914z" fill="#edb" aria-label="c"/>
<path d="m2014.1 4058.2v-26.388q0-3.7437-0.9131-6.9395-0.9131-2.7393-3.9262-5.2046-2.7393-2.1914-6.1177-2.1914-5.1133 0-8.2178 3.835-2.2827 2.2827-3.3784 5.8437-1.8262 5.1133-1.8262 11.596 0 6.8482 2.1001 12.783 3.7437 11.048 13.148 11.322 5.9351-0.091 9.1309-4.6568zm0 5.4786-1.9175 1.7348q-5.6611 4.3828-12.509 4.3828-7.5787 0-13.422-6.1176-3.561-3.4698-4.8394-7.4873-2.1914-5.4786-2.1914-12.053 0-11.322 5.9351-20.088 7.0307-10.135 17.531-10.135 5.8438 0 10.227 3.3784l1.187 1.0044v-13.788l-0.091-2.374q-0.4565-3.7437-5.2959-3.9263h-2.4653v-2.5566l17.44-5.2959v66.016q0 3.9262 1.187 4.748l0.8218 0.5479q0.9131 0.4565 2.9219 0.4565l2.9218-0.1826v2.6479l-17.44 5.2046z" fill="#b86" aria-label="d"/>
<path d="m2525.7 4031.5-0.274-2.4653q-1.187-7.5786-5.2045-10.044-2.3741-1.461-4.7481-1.461-4.8393 0-7.8525 4.2002t-3.4698 8.4004l-0.2739 1.3696zm13.97 20.453-1.187 2.5566q-8.0352 15.34-22.005 15.34-10.318 0-16.344-8.4003-5.022-7.0308-5.022-18.353 0-6.9394 2.1914-13.514 2.2827-7.0308 7.4873-11.231 5.4785-4.2915 12.601-4.2915 8.9482 0.1826 14.518 5.9351 5.3872 5.5698 5.935 14.701l0.091 1.187h-33.967l-0.091 1.6436q0 6.5742 1.3696 10.957 3.3785 11.779 14.975 11.87 9.3134 0 15.614-7.4873l1.7349-2.1914z" fill="#edb" aria-label="e"/>
<path d="m3017.4 4016.7 0.2739-5.2045q0.4565-8.4004 5.3872-14.153 5.022-5.7524 13.696-5.935 11.779 0.1826 12.053 7.396 0 4.8393-4.748 4.8393-3.1045 0-5.6612-4.6567-2.374-3.9263-5.2959-4.1089-5.8437 0-5.8437 8.583l-0.1826 3.7436-0.091 3.6524v5.8437h13.148v4.3829h-13.148v33.784q0 5.022 0.8217 7.8526 0.8218 3.561 6.3916 3.6523l2.3741 0.091v2.5567h-27.027v-2.5567q5.3872 0 6.9395-3.3784 0.9131-1.9175 0.9131-8.2178v-33.784h-8.6744v-4.3829z" fill="#b86" aria-label="f"/>
<path d="m3528.8 4028.2q0 5.8438 1.8262 11.414 2.5566 8.1265 8.9482 8.1265 8.4004-0.1827 8.4004-11.048 0-6.3003-2.6479-11.961-3.3785-7.5786-8.6744-7.5786-8.0351 0.3652-7.8525 11.048zm-0.2739 39.628-2.8306 3.1044q-3.0132 3.2872-3.0132 6.209 0 8.1265 17.349 8.1265 6.0263 0 11.961-2.0088 8.2178-2.8306 8.2178-8.4004 0-5.1133-7.2134-5.4785l-16.801-0.7305-4.3828-0.4565zm35.976-46.294h-9.4048v0.1826l1.4609 4.8394q1.0044 3.6523 1.0044 6.3003 0 4.8393-2.7393 9.5874-2.6479 4.748-7.3046 6.8481-4.1089 1.9175-7.9439 1.9175-2.5566 0-6.4829-0.8218l-2.8306 2.4653q-2.2827 2.2828-2.374 4.1089-0.1826 2.2827 4.1089 2.5567 1.6436 0.1826 3.4697 0.3652 1.8262 0.091 3.7437 0.091l9.4961 0.2739q14.975 0.8218 14.975 12.783-0.1826 8.6744-11.231 14.609-8.4917 4.5654-18.444 4.5654-6.9395 0-11.87-2.5566-7.3047-3.835-7.3047-9.3135 0-2.0088 2.7392-5.3872l2.2828-2.5566 5.3872-5.4785q-5.2046-2.0088-5.2046-5.7525 0-3.0132 3.6523-6.5742l3.835-3.3784 2.1001-1.8262q-5.8438-3.3784-7.8526-6.5742-2.5566-4.0176-2.5566-9.6787 0-8.583 5.4785-13.97 5.7524-5.4785 14.062-5.4785 5.3872 0 10.318 2.374l1.2783 0.5478v-0.091q1.7349 0.6392 5.4785 0.6392h8.6744z" fill="#edb" aria-label="g"/>
<path d="m4023.8 4066.5q4.748-0.091 6.4829-2.8305 1.3696-2.1001 1.3696-7.2134v-50.859l-0.091-2.374q-0.1826-3.6524-5.3872-4.0176l-2.374 0.091v-2.6479l17.44-5.2046v32.323l3.1045-3.0132q6.3003-5.7524 12.874-5.7524 9.7701 0 12.418 8.8569 0.9131 2.7393 1.0957 6.209l0.1826 3.561v21.64q0 6.0263 0.9131 7.9438 1.5523 3.2871 6.9395 3.2871v2.5567h-25.292v-2.5567q5.4785-0.091 6.7568-3.1958 1.0957-2.9219 1.0957-8.0351v-21.823q0-0.4565-0.4565-4.5654-1.2783-7.7612-8.2178-7.7612-4.2915 0.1826-8.0351 3.3784l-3.3785 3.4697v28.488q0 5.4785 1.187 7.2134 1.8262 2.4653 6.6656 2.8305v2.5567h-25.292z" fill="#b86" aria-label="h"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 4096 4096" xmlns="http://www.w3.org/2000/svg">
<title>Chess White Bishop</title>
<path d="m2048 3456.4q-30.762 125.24-74.707 173.58t-116.46 101.07q-79.102 54.932-186.77 92.285-107.67 39.551-239.5 19.775l-617.43-85.693q-37.354-4.3945-68.115 0-28.564 4.3946-54.932 4.3946-46.143 0-118.65 19.775-70.312 21.973-109.86 61.523l-213.13-349.37q39.551-43.945 70.312-61.524t72.51-37.354q127.44-59.326 272.46-72.51 61.523-4.3945 120.85-2.1972 59.326 2.1972 123.05-4.3946 118.65 19.775 237.3 37.354 120.85 15.381 241.7 32.959 131.84 0 177.98-26.367 24.17-13.184 76.904-48.34t105.47-103.27q-116.46-13.184-237.3-43.945-120.85-32.959-213.13-68.115l228.52-566.89q-171.39-98.877-239.5-158.2-68.115-61.523-107.67-140.62-57.129-101.07-74.707-195.56-15.381-94.482-13.184-169.19 2.1973-131.84 61.523-290.04 61.524-160.4 228.52-285.64 138.43-105.47 270.26-217.53 131.84-112.06 261.47-261.47-162.6-83.496-162.6-265.87 0-123.05 85.693-210.94 87.891-87.891 213.13-87.891 123.05 0 210.94 87.891 87.891 87.891 87.891 210.94 0 180.18-162.6 265.87 127.44 149.41 257.08 261.47 131.84 112.06 274.66 217.53 164.79 125.24 224.12 285.64 59.326 158.2 63.721 290.04 0 74.707-15.381 169.19-15.381 94.482-70.312 195.56-43.945 79.102-112.06 140.62-65.918 59.326-235.11 158.2l228.52 566.89q-96.68 35.156-217.53 68.115-120.85 30.762-232.91 43.945 50.537 68.115 103.27 103.27 52.734 35.156 79.102 48.34 46.143 26.367 177.98 26.367 118.65-17.578 237.3-32.959 120.85-17.578 241.7-37.354 59.326 6.5918 118.65 4.3946 61.523-2.1973 125.24 2.1972 140.62 13.184 272.46 72.51 39.551 19.775 70.312 37.354 32.959 17.578 72.51 61.524l-215.33 349.37q-39.551-39.551-112.06-61.523-70.312-19.775-114.26-19.775-28.564 0-59.326-4.3946-28.564-4.3945-65.918 0l-615.23 85.693q-131.84 19.775-246.09-17.578-112.06-37.354-184.57-98.877-72.51-59.326-116.46-103.27-41.748-41.748-70.312-166.99z"/>
<path d="m1968.9 3208.1h-70.312q-123.05 197.75-235.11 248.29-52.734 26.367-116.46 48.34-63.721 21.973-147.22 21.973-15.381 0-235.11-35.156-105.47-15.381-166.99-32.959t-96.68-21.973q-125.24-15.381-292.24 13.184-101.07 17.578-188.96 63.721l105.47 171.39q26.367-26.367 68.115-35.156 43.945-6.5918 79.102-15.381 92.285-15.381 175.78-4.3946 28.564 6.5918 112.06 15.381 83.496 10.986 221.92 32.959 164.8 24.17 224.12 24.17 230.71 0 353.76-87.891 74.707-57.129 140.62-160.4 68.115-101.07 68.115-246.09z" fill="#fff"/>
<path d="m2048 2379.7q213.13 0 408.69 32.959 215.33-76.904 312.01-239.5 83.496-140.62 83.496-309.81 0-101.07-50.537-221.92-50.537-123.05-164.79-221.92-129.64-107.67-283.45-235.11-153.81-129.64-305.42-301.03-153.81 171.39-307.62 301.03-153.81 127.44-281.25 235.11-116.46 98.877-166.99 221.92-48.34 120.85-48.34 221.92 0 169.19 81.299 309.81 94.482 162.6 314.21 239.5 193.36-32.959 408.69-32.959z" fill="#fff"/>
<path d="m2048 2779.6q257.08 0 494.38 50.537l-105.47-270.26q-193.36-30.762-388.92-30.762-199.95 0-391.11 30.762l-105.47 270.26q235.11-50.537 496.58-50.537z" fill="#fff"/>
<path d="m2048 694.4q149.41 0 149.41-149.41t-149.41-149.41-149.41 149.41 149.41 149.41z" fill="#fff"/>
<path d="m2048 3087.2q149.41 0 290.04-24.17 142.82-26.367 274.66-61.523-257.08-68.115-564.7-68.115-312.01 0-564.7 68.115 127.44 35.156 270.26 61.523 142.82 24.17 294.43 24.17z" fill="#fff"/>
<path d="m1968.9 1817.2-182.37-2.1973q-74.707 0-74.707-74.707t74.707-74.707h182.37v-188.96q0-76.904 79.102-76.904 76.904 0 76.904 76.904v188.96h188.96q72.51 0 72.51 74.707t-72.51 74.707h-188.96v180.18q0 79.102-76.904 79.102-79.102 0-79.102-79.102z"/>
<path d="m2124.9 3208.1q0 145.02 65.918 246.09 68.115 103.27 145.02 160.4 120.85 87.891 353.76 87.891 57.129 0 224.12-24.17 136.23-21.973 219.73-32.959 83.496-8.7891 112.06-15.381 83.496-10.986 175.78 4.3946 35.156 8.789 76.904 15.381 43.945 8.7891 72.51 35.156l105.47-171.39q-90.088-46.143-191.16-63.721-166.99-28.564-292.24-13.184-37.354 4.3945-98.877 21.973-59.326 17.578-162.6 32.959-221.92 35.156-235.11 35.156-85.693 0-149.41-21.973-63.721-21.973-114.26-48.34-116.46-52.734-237.3-248.29z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 4096 4096" xmlns="http://www.w3.org/2000/svg">
<title>Chess White King</title>
<path d="m2125.3 872.45h-156.01v-287.84h-182.37q-74.707 0-74.707-72.51v-2.1973q0-72.51 74.707-72.51h182.37v-186.77q0-76.904 79.102-76.904 76.904 0 76.904 76.904v186.77h188.96q72.51 0 72.51 72.51v2.1973q0 72.51-72.51 72.51l-186.77 2.1973z"/>
<path d="m815.81 3151-72.51-410.89q-2.1973 0-6.5918-8.789-10.986-15.381-63.721-39.551-50.537-26.367-118.65-87.891-96.68-81.299-151.61-131.84-52.734-50.537-96.68-109.86-134.03-184.57-149.41-446.04-21.973-252.69 204.35-503.17 228.52-250.49 617.43-235.11 145.02 8.789 340.58 70.312 63.721 26.367 129.64 52.734 68.115 24.17 134.03 50.537 35.156 17.578 61.523 35.156-10.986-46.143-10.986-92.285 0-171.39 120.85-292.24 123.05-120.85 294.43-123.05 171.39 0 292.24 123.05 120.85 120.85 120.85 290.04 0 35.156-8.789 92.285 30.762-19.775 59.326-32.959 101.07-43.945 265.87-103.27 188.96-63.721 340.58-72.51 388.92-17.578 615.23 235.11 221.92 250.49 206.54 503.17-17.578 261.47-151.61 446.04-43.945 59.326-98.877 112.06-54.932 50.537-147.22 129.64-72.51 61.523-123.05 87.891-50.537 24.17-59.326 39.551-2.1973 4.3945-4.3946 6.5918-2.1972 2.1973-2.1972 4.3945l-70.312 413.09 145.02 542.72q-109.86 98.877-492.19 162.6t-883.3 63.721q-509.77 0-896.48-68.115-386.72-65.918-485.6-166.99z"/>
<path d="m2123.2 2423.8q377.93 4.3945 692.14 46.143 314.21 39.551 492.19 101.07 83.496-65.918 182.37-145.02 98.877-81.299 164.79-164.8 105.47-134.03 105.47-353.76 0-197.75-94.482-329.59-169.19-246.09-514.16-246.09-206.54 0-424.07 85.693-191.16 76.904-290.04 171.39-184.57 184.57-272.46 474.61-30.762 103.27-37.354 188.96-4.3946 83.496-4.3946 171.39z" fill="#fff"/>
<path d="m2048.4 2015.1q15.381-61.524 26.367-85.693 21.973-85.693 50.537-145.02 13.184-37.354 37.354-85.693 26.367-50.537 54.932-114.26 17.578-37.354 37.354-90.088 19.775-54.932 39.551-107.67 17.578-43.945 17.578-94.482 0-107.67-76.904-182.37-76.904-76.904-186.77-76.904-261.47 0-261.47 261.47 0 50.537 17.578 94.482 48.34 142.82 74.707 197.75 28.564 63.721 52.734 114.26 24.17 48.34 41.748 85.693 28.564 72.51 48.34 145.02 4.3945 13.184 26.367 83.496z" fill="#fff"/>
<path d="m1971.5 2423.8q-2.1973-85.693-6.5918-169.19-4.3945-85.693-32.959-188.96-90.088-294.43-272.46-474.61-94.482-92.285-290.04-171.39-224.12-87.891-424.07-87.891-347.17 0-514.16 248.29-94.482 131.84-94.482 329.59 0 215.33 105.47 353.76 63.721 81.299 162.6 162.6 98.877 81.299 184.57 147.22 384.52-138.43 1182.1-149.41z" fill="#fff"/>
<path d="m3248.1 3614.6q-424.07-171.39-1190.9-171.39-795.41 0-1204.1 175.78 386.72 153.81 1197.5 153.81 386.72 0 705.32-43.945 318.6-41.748 492.19-114.26z" fill="#fff"/>
<path d="m3191 3408.1-65.918-252.69q-435.06-96.68-1076.7-96.68-639.4 0-1074.5 96.68l-70.312 254.88q419.68-123.05 1147-123.05 722.9 0 1140.4 120.85z" fill="#fff"/>
<path d="m949.84 3017q417.48-105.47 1107.4-105.47 676.76 0 1092 101.07l54.932-323q-441.65-116.46-1155.8-116.46-718.51 0-1153.6 118.65z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 4096 4096" xmlns="http://www.w3.org/2000/svg">
<title>Chess White Knight</title>
<path d="m2166.6 613.2q344.97 21.973 639.4 177.98 294.43 156.01 500.98 399.9 145.02 171.39 272.46 413.09 129.64 241.7 206.54 511.96 87.891 314.21 109.86 661.38 24.17 344.97 24.17 639.4v479t-342.77 0q-340.58 0-885.5 0h-1474.4q-19.775 0-19.775-107.67 2.1972-107.67 15.381-173.58 8.7891-52.734 41.748-149.41t109.86-235.11q35.156-70.312 166.99-204.35 131.84-136.23 303.22-314.21 98.877-101.07 153.81-254.88 57.129-153.81 48.34-279.05-81.299 65.918-177.98 107.67-465.82 166.99-674.56 483.4-15.381 19.775-98.877 177.98-43.945 83.496-74.707 114.26-41.748 41.748-120.85 46.143-123.05 6.5918-191.16-118.65-92.285 26.367-164.79 21.973-123.05-46.143-177.98-98.877-112.06-112.06-147.22-224.12-32.959-112.06-32.959-241.7 0-184.57 228.52-487.79 268.07-349.37 285.64-531.74 0-79.102 15.381-177.98 13.184-68.115 54.932-131.84 28.564-43.945 37.354-59.326 10.986-17.578 37.354-50.537 19.775-26.367 32.959-39.551 13.184-15.381 32.959-39.551 24.17-28.564 61.523-65.918-116.46-320.8-94.482-661.38 437.26 156.01 733.89 489.99 72.51-248.29 285.64-402.1 175.78 123.05 279.05 325.2z"/>
<path d="m3654.1 3739.9q-2.1972 0 4.3946-120.85 6.5918-118.65 6.5918-257.08 2.1972-274.66 0-566.89-2.1973-294.43-79.102-586.67-74.707-281.25-164.79-479-90.088-199.95-195.56-344.97-158.2-237.3-430.66-402.1-272.46-164.79-569.09-215.33 19.775 107.67 17.578 210.94-4.3945 79.102-74.707 79.102-81.299 0-72.51-79.102 6.5918-290.04-206.54-496.58-166.99 175.78-180.18 408.69-4.3946 76.904-79.102 68.115-70.312-2.1973-70.312-81.299 0 0 4.3945-17.578-90.088 28.564-188.96 81.299-63.721 43.945-103.27-21.973-39.551-65.918 35.156-103.27 94.482-48.34 142.82-72.51-188.96-193.36-430.66-312.01 26.367 305.42 166.99 577.88 37.354 57.129-17.578 101.07-61.523 48.34-103.27-15.381-15.381-21.973-43.945-79.102-46.143 46.143-61.523 68.115-15.381 19.775-54.932 87.891-37.354 68.115-54.932 112.06-19.775 54.932-17.578 90.088 4.3945 32.959 6.5918 74.707-19.775 129.64-90.088 243.9-68.115 114.26-175.78 279.05-103.27 158.2-158.2 237.3-54.932 79.102-83.496 202.15-19.775 74.707 0 182.37 19.775 105.47 85.693 164.8 101.07 103.27 195.56 94.482 30.762 0 81.299-24.17 52.734-24.17 81.299-92.285 57.129-125.24 94.482-125.24 54.932 0 59.326 61.523 0 13.184-35.156 109.86-19.775 43.945-52.734 92.285-41.748 57.129-37.354 48.34 35.156 125.24 116.46 43.945 24.17-24.17 54.932-90.088t96.68-177.98q76.904-129.64 164.8-210.94 87.891-81.299 156.01-131.84 39.551-28.564 96.68-65.918t153.81-76.904q76.904-30.762 169.19-74.707 94.482-46.142 166.99-116.46 101.07-98.877 156.01-243.9 28.564-81.299 21.973-199.95-19.775-74.707 74.707-74.707 70.312 0 79.102 72.51 0 248.29-140.62 452.64 46.143 140.62 24.17 307.62-19.775 134.03-94.482 287.84-72.51 151.61-296.63 338.38-454.83 377.93-430.66 777.83 0 0 373.54 0 375.73 0 705.32 0z" fill="#fff"/>
<path d="m674.71 2404q-63.721 39.551-76.904 105.47 2.1973 72.51-68.115 79.102-76.904 8.7891-79.102-65.918 8.7891-145.02 129.64-230.71 57.129-46.142 105.47 8.7891 48.34 59.326-10.986 103.27z"/>
<path d="m1327.3 1355.9q28.564 43.945 21.973 92.285-21.973 140.62-156.01 118.65-39.551-6.5918-63.721-26.367-8.789 10.986-26.367 48.34-24.17 70.312-92.285 48.34-68.115-26.367-52.734-98.877 98.877-252.69 358.15-347.17 74.707-21.973 98.877 43.945 26.367 70.312-39.551 96.68-13.184 6.5918-24.17 13.184-10.986 4.3945-24.17 10.986z"/>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 4096 4096" xmlns="http://www.w3.org/2000/svg">
<title>Chess White Pawn</title>
<path d="m2049.1 3846.4h-1186.5q-87.891-219.73-87.891-459.23 0-406.49 230.71-729.49t593.26-461.43q-156.01-72.51-254.88-217.53-96.68-147.22-96.68-331.79 0-230.71 153.81-399.9t377.93-195.56q-177.98-134.03-177.98-353.76 0-184.57 129.64-316.41 131.84-131.84 318.6-131.84 184.57 0 316.41 131.84t131.84 316.41q0 219.73-177.98 353.76 224.12 26.367 377.93 195.56 153.81 169.19 153.81 399.9 0 184.57-101.07 331.79-98.877 145.02-252.69 217.53 362.55 138.43 593.26 461.43t230.71 729.49q0 237.3-85.693 459.23z"/>
<path d="m2049.1 3697h1078.9q46.143-158.2 46.143-309.81 0-342.77-191.16-621.83-188.96-279.05-487.79-410.89-145.02-10.986-145.02-158.2 0-116.46 147.22-171.39 204.35-142.82 204.35-377.93 0-169.19-114.26-296.63t-276.86-147.22q-131.84-10.986-131.84-149.41 0-61.523 48.34-114.26 118.65-92.285 118.65-241.7 0-123.05-87.891-210.94-87.891-87.891-208.74-87.891-125.24 0-213.13 87.891-85.693 87.891-85.693 210.94 0 147.22 118.65 241.7 48.34 48.34 48.34 114.26 0 138.43-129.64 149.41-164.79 19.775-279.05 147.22-112.06 127.44-112.06 296.63 0 235.11 204.35 377.93 147.22 57.129 147.22 171.39 0 147.22-147.22 158.2-298.83 131.84-487.79 410.89t-188.96 621.83q0 162.6 46.143 309.81z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 4096 4096" xmlns="http://www.w3.org/2000/svg">
<title>Chess White Queen</title>
<path d="m3781.6 1118.5q-125.24 0-213.13-85.693-87.891-87.891-87.891-213.13 0-123.05 87.891-210.94 87.891-90.088 213.13-90.088 123.05 0 210.94 90.088 87.891 87.891 87.891 210.94 0 125.24-87.891 213.13-87.891 85.693-210.94 85.693z"/>
<path d="m314.25 1118.5q-125.24 0-213.13-85.693-85.693-87.891-85.693-213.13 0-123.05 85.693-210.94 87.891-90.088 213.13-90.088t213.13 90.088q87.891 87.891 87.891 210.94 0 125.24-87.891 213.13-87.891 85.693-213.13 85.693z"/>
<path d="m2968.6 876.76q-125.24 0-213.13-85.693-85.693-87.891-85.693-213.13 0-123.05 85.693-210.94 87.891-87.891 213.13-87.891 125.24 0 213.13 87.891 87.891 87.891 87.891 210.94 0 125.24-87.891 213.13-87.891 85.693-213.13 85.693z"/>
<path d="m1122.9 876.76q-125.24 0-213.13-85.693-87.891-87.891-87.891-213.13 0-123.05 87.891-210.94 87.891-87.891 213.13-87.891 123.05 0 210.94 87.891 87.891 87.891 87.891 210.94 0 125.24-87.891 213.13-87.891 85.693-210.94 85.693z"/>
<path d="m3399.2 3706.9q-107.67 94.482-483.4 158.2t-865.72 63.721q-498.78 0-876.71-65.918t-476.81-164.8l138.43-527.34-61.523-344.97-193.36-336.18-186.77-1366.7 107.67-41.748 602.05 1015.1 13.184-1208.5 149.41-26.367 459.23 1215.1 246.09-1307.4h151.61l246.09 1303 454.83-1210.7 151.61 26.367 13.184 1208.5 604.25-1017.3 103.27 48.34-182.37 1360.1-195.56 336.18-61.523 349.37z"/>
<path d="m2045.8 766.99q-125.24 0-213.13-87.891-85.693-87.891-85.693-213.13 0-123.05 85.693-210.94 87.891-87.891 213.13-87.891 123.05 0 210.94 87.891 90.088 87.891 90.088 210.94 0 125.24-90.088 213.13-87.891 87.891-210.94 87.891z"/>
<path d="m3781.6 964.65q145.02 0 145.02-145.02 0-147.22-145.02-147.22-147.22 0-147.22 147.22 0 145.02 147.22 145.02z" fill="#fff"/>
<path d="m314.25 964.65q147.22 0 147.22-145.02 0-147.22-147.22-147.22-145.02 0-145.02 147.22 0 145.02 145.02 145.02z" fill="#fff"/>
<path d="m2968.6 722.95q147.22 0 147.22-145.02t-147.22-145.02q-145.02 0-145.02 145.02t145.02 145.02z" fill="#fff"/>
<path d="m1122.9 722.95q145.02 0 145.02-145.02t-145.02-145.02q-147.22 0-147.22 145.02t147.22 145.02z" fill="#fff"/>
<path d="m3221.3 3627.8q-402.1-166.99-1166.7-166.99-780.03 0-1179.9 171.39 384.52 151.61 1173.3 151.61 377.93 0 689.94-41.748 314.21-41.748 483.4-114.26z" fill="#fff"/>
<path d="m3166.3 3425.6-65.918-248.29q-430.66-94.482-1054.7-94.482-617.43 0-1052.5 94.482l-68.115 250.49q408.69-123.05 1122.8-123.05 696.53 0 1118.4 120.85z" fill="#fff"/>
<path d="m3034.5 2313.8q50.537 15.381 112.06 15.381 134.03 0 250.49-83.496l120.85-760.25z" fill="#fff"/>
<path d="m1050.4 2302.8-472.41-815.19 120.85 742.68q125.24 87.891 243.9 87.891 46.143 0 107.67-15.381z" fill="#fff"/>
<path d="m1608.5 2179.8-360.35-986.57v1004.2q6.5918-8.789 19.775-21.973 43.945-92.285 145.02-92.285 83.496 0 136.23 70.312z" fill="#fff"/>
<path d="m2843.4 2210.5v-1017.3l-362.55 997.56q41.748-15.381 70.312-39.551 43.945-54.932 118.65-54.932 87.891 0 140.62 76.904 6.5918 8.7891 15.381 19.775 8.7891 8.789 17.578 17.578z" fill="#fff"/>
<path d="m2234.8 2168.8-186.77-1074.5-186.77 1061.3q6.5918-4.3946 30.762-21.973 50.537-98.877 153.81-98.877 112.06 0 145.02 98.877 13.184 13.184 43.945 35.156z" fill="#fff"/>
<path d="m3219.1 2693.9 142.82-252.69q-105.47 41.748-215.33 41.748-294.43 0-470.21-239.5-131.84 109.86-294.43 109.86-210.94 0-336.18-164.79-140.62 153.81-336.18 153.81-158.2 0-290.04-107.67-184.57 235.11-476.81 235.11-112.06 0-221.92-41.748l153.81 263.67q426.27-123.05 1171.1-123.05 758.06 0 1173.3 125.24z" fill="#fff"/>
<path d="m3164.2 2825.8q-424.07-107.67-1109.6-107.67-703.12 0-1122.8 109.86l32.959 210.94q428.47-101.07 1089.8-101.07 656.98 0 1072.3 98.877z" fill="#fff"/>
<path d="m2045.8 613.18q147.22 0 147.22-147.22 0-145.02-147.22-145.02-145.02 0-145.02 145.02 0 147.22 145.02 147.22z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 4096 4096" xmlns="http://www.w3.org/2000/svg">
<title>Chess White Rook</title>
<path d="m2350.1 549.46h373.54v-301.03h602.05v823.97l-487.79 375.73v1050.3l373.54 373.54v450.44h336.18v525.15h-2999.3v-525.15h336.18v-450.44l375.73-373.54v-1050.3l-487.79-375.73v-823.97h599.85v301.03h375.73v-301.03h602.05z"/>
<path d="m2763.2 1298.9 279.05-226.32h-1986.3l281.25 226.32z" fill="#fff"/>
<path d="m3004.9 2872.1-226.32-224.12h-1456.8l-230.71 224.12z" fill="#fff"/>
<path d="m3174.1 923.07v-525.15h-301.03v301.03h-676.76v-301.03h-296.63v301.03h-674.56v-301.03h-301.03v525.15z" fill="#fff"/>
<path d="m2686.3 1448.2h-1274.4v1050.3h1274.4z" fill="#fff"/>
<path d="m3059.8 3023.6h-2021.5v298.83h2021.5z" fill="#fff"/>
<path d="m3400.4 3471.9h-2702.6v226.32h2702.6z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 864 B

View File

@@ -96,7 +96,9 @@ type
ScoredMove = tuple[move: Move, data: int32]
ChessVariation* = array[MAX_DEPTH + 1, Move]
ChessVariation* = object
moves*: array[MAX_DEPTH + 1, Move]
score*: Score
SearchManager* = object
state* : SearchState
@@ -117,9 +119,8 @@ type
expired : bool
minNmpPly : int
lmrTable {.align(64).} : LMRTable
pvMoves {.align(64).} : array[MAX_DEPTH + 1, ChessVariation]
previousScores {.align(64).} : array[MAX_MOVES, Score]
previousLines {.align(64).} : array[MAX_MOVES, ChessVariation]
variations {.align(64).} : array[MAX_DEPTH + 1, ChessVariation]
previousVariations* {.align(64).} : array[MAX_MOVES, ChessVariation]
contempt : Score
# Search thread pool implementation
@@ -920,7 +921,7 @@ func storeKillerMove(self: SearchManager, ply: int, move: Move) {.inline.} =
func clearPV(self: var SearchManager, ply: int) {.inline.} =
self.pvMoves[ply][0] = nullMove()
self.variations[ply].moves[0] = nullMove()
func clearKillers(self: SearchManager, ply: int) {.inline.} =
@@ -1331,14 +1332,14 @@ proc search(self: var SearchManager, depth, ply: int, alpha, beta: Score, isPV,
self.statistics.bestMove.store(bestMove, moRelaxed)
if score < beta:
when isPV:
# This loop is why pvMoves has one extra move.
# This loop is why variations has one extra entry.
# We can just do ply + 1 and i + 1 without ever
# fearing about buffer overflows
for i, pvMove in self.pvMoves[ply + 1]:
self.pvMoves[ply][i + 1] = pvMove
for i, pvMove in self.variations[ply + 1].moves:
self.variations[ply].moves[i + 1] = pvMove
if pvMove == nullMove():
break
self.pvMoves[ply][0] = move
self.variations[ply].moves[0] = move
if score >= beta:
# This move was too good for us, opponent will not search it
when not root:
@@ -1501,6 +1502,10 @@ proc search*(self: var SearchManager, searchMoves: seq[Move] = @[], silent=false
self.statistics.bestRootScore.store(0, moRelaxed)
self.statistics.bestMove.store(nullMove(), moRelaxed)
self.statistics.currentVariation.store(0, moRelaxed)
self.statistics.variationCount.store(0, moRelaxed)
for i in 0..<218:
self.statistics.variationScores[i].store(Score(0), moRelaxed)
self.statistics.variationMoves[i].store(nullMove(), moRelaxed)
self.state.stop.store(false, moRelaxed)
self.state.searching.store(true, moRelaxed)
self.state.cancelled.store(false, moRelaxed)
@@ -1525,9 +1530,8 @@ proc search*(self: var SearchManager, searchMoves: seq[Move] = @[], silent=false
result = newSeq[ChessVariation](variations)
for i in 0..<variations:
for j in 0..MAX_DEPTH:
self.previousLines[i][j] = nullMove()
for i in 0..<MAX_MOVES:
self.previousScores[i] = Score(0)
self.previousVariations[i].moves[j] = nullMove()
self.previousVariations[i].score = Score(0)
self.workerPool.startSearch(searchMoves, variations)
@@ -1550,18 +1554,23 @@ proc search*(self: var SearchManager, searchMoves: seq[Move] = @[], silent=false
# alpha-beta bounds and widen them as needed (i.e. when the score
# goes beyond the window) to increase the number of cutoffs
score = self.aspirationSearch(depth, score)
if self.shouldStop() or self.pvMoves[0][0] == nullMove():
if self.shouldStop() or self.variations[0].moves[0] == nullMove():
# Search has likely been interrupted mid-tree:
# cannot trust partial results
lastInfoLine = self.stopped() or self.limiter.hardLimitReached()
break iterativeDeepening
bestMoves.add(self.pvMoves[0][0])
self.previousLines[i - 1] = self.pvMoves[0]
result[i - 1] = self.pvMoves[0]
self.previousScores[i - 1] = score
bestMoves.add(self.variations[0].moves[0])
self.previousVariations[i - 1] = self.variations[0]
self.previousVariations[i - 1].score = score
result[i - 1] = self.variations[0]
result[i - 1].score = score
self.statistics.highestDepth.store(depth, moRelaxed)
# Update atomic per-variation data for live MultiPV polling
self.statistics.variationScores[i - 1].store(score, moRelaxed)
self.statistics.variationMoves[i - 1].store(self.variations[0].moves[0], moRelaxed)
self.statistics.variationCount.store(i, moRelaxed)
if not silent and not minimal:
self.logger.log(self.pvMoves[0], i)
self.logger.log(self.variations[0].moves, i)
if variations > 1:
self.searchMoves = searchMoves
for move in legalMoves:
@@ -1576,7 +1585,7 @@ proc search*(self: var SearchManager, searchMoves: seq[Move] = @[], silent=false
bestMoves.setLen(0)
var stats = self.statistics
var finalScore = self.previousScores[0]
var finalScore = self.previousVariations[0].score
if self.state.isMainThread.load(moRelaxed):
# The main thread is the only one doing time management,
# so we need to explicitly stop all other workers
@@ -1617,11 +1626,11 @@ proc search*(self: var SearchManager, searchMoves: seq[Move] = @[], silent=false
stats = bestSearcher.statistics
finalScore = bestSearcher.statistics.bestRootScore.load(moRelaxed)
for i in 0..<result.len():
result[i] = bestSearcher.previousLines[i]
result[i] = bestSearcher.previousVariations[i]
if not silent and (lastInfoLine or minimal):
# Log final info message
self.logger.log(result[0], 1, some(finalScore), some(stats))
self.logger.log(result[0].moves, 1, some(finalScore), some(stats))
self.state.searching.store(false, moRelaxed)
self.state.pondering.store(false, moRelaxed)

View File

@@ -0,0 +1,337 @@
# Copyright 2025 Mattia Giambirtone & All Contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
## Search integration for the TUI: background search thread + live polling
import std/[atomics, options, monotimes, times]
import heimdall/[board, moves, pieces, search, position, eval]
import heimdall/util/[limits, wdl]
import heimdall/tui/state
proc searchWorkerLoop*(statePtr: ptr AppState) {.thread.} =
## Background search thread. Listens for commands on the channel
## and executes searches.
let state = statePtr[]
while true:
let cmd = state.channels.command.recv()
case cmd.kind
of Shutdown:
state.channels.response.send(Exiting)
break
of StopSearch:
state.searcher.cancel()
if state.watchInitialized:
state.watchSearcher.cancel()
if not state.searcher.isSearching() and
(not state.watchInitialized or not state.watchSearcher.isSearching()):
state.channels.response.send(SearchComplete)
of StartAnalysis:
# Configure for infinite analysis (always uses primary engine)
state.searcher.limiter.clear()
state.searcher.state.mateDepth.store(none(int), moRelaxed)
state.searcher.setBoard(cmd.analysisPositions)
state.searcher.setUCIMode(true)
let pvLines = state.searcher.search(silent=true, variations=cmd.analysisVariations)
var lines: seq[AnalysisLine]
let depth = state.searcher.statistics.highestDepth.load(moRelaxed)
for i, variation in pvLines:
var moves: seq[Move]
for move in variation.moves:
if move == nullMove(): break
moves.add(move)
if moves.len > 0:
lines.add(AnalysisLine(pv: moves, score: variation.score, depth: depth))
state.pvChannel.send(lines)
state.channels.response.send(SearchComplete)
of StartEngineMove:
state.searcher.limiter.clear()
state.searcher.state.mateDepth.store(none(int), moRelaxed)
for limit in cmd.engineLimits:
state.searcher.limiter.addLimit(limit)
state.searcher.setBoard(cmd.enginePositions)
state.searcher.setUCIMode(true)
discard state.searcher.search(silent=true, ponder=cmd.ponder, variations=1)
state.channels.response.send(SearchComplete)
proc watchWorkerLoop*(statePtr: ptr AppState) {.thread.} =
## Background search thread for the second engine in watch mode.
let state = statePtr[]
while true:
let cmd = state.watchChannels.command.recv()
case cmd.kind
of Shutdown:
state.watchChannels.response.send(Exiting)
break
of StopSearch:
state.watchSearcher.cancel()
if not state.watchSearcher.isSearching():
state.watchChannels.response.send(SearchComplete)
of StartAnalysis:
# Not used for watch engine, but handle gracefully
state.watchChannels.response.send(SearchComplete)
of StartEngineMove:
state.watchSearcher.limiter.clear()
state.watchSearcher.state.mateDepth.store(none(int), moRelaxed)
for limit in cmd.engineLimits:
state.watchSearcher.limiter.addLimit(limit)
state.watchSearcher.setBoard(cmd.enginePositions)
state.watchSearcher.setUCIMode(true)
discard state.watchSearcher.search(silent=true, ponder=cmd.ponder, variations=1)
state.watchChannels.response.send(SearchComplete)
proc startSearchWorker*(state: AppState) =
## Spawns the primary background search thread
var statePtr = create(AppState)
statePtr[] = state
createThread(state.searchWorkerThread, searchWorkerLoop, statePtr)
proc startWatchWorker*(state: AppState) =
## Spawns the second background search thread for watch mode
state.watchChannels.command.open()
state.watchChannels.response.open()
var statePtr = create(AppState)
statePtr[] = state
createThread(state.watchWorkerThread, watchWorkerLoop, statePtr)
proc stopSearch*(state: AppState) =
## Cancels any running search
if state.searcher.isSearching():
state.searcher.cancel()
proc startAnalysis*(state: AppState) =
## Starts continuous analysis on the current position
if state.analysisRunning:
# Stop current analysis first
stopSearch(state)
# Wait for the worker to acknowledge
discard state.channels.response.recv()
state.analysisRunning = true
# Clone positions since Position can't be copied
var positions: seq[Position]
for pos in state.board.positions:
positions.add(pos.clone())
let cmd = SearchCommand(
kind: StartAnalysis,
analysisPositions: positions,
analysisVariations: state.multiPV
)
state.channels.command.send(cmd)
proc stopAnalysis*(state: AppState) =
## Stops continuous analysis
if not state.analysisRunning:
return
stopSearch(state)
# Wait for the search to complete
discard state.channels.response.recv()
state.analysisRunning = false
proc toggleAnalysis*(state: AppState) =
## Toggles analysis on/off
if state.analysisRunning:
stopAnalysis(state)
state.setStatus("Analysis stopped")
else:
startAnalysis(state)
state.setStatus("Analysis started")
proc drainPVChannel(state: AppState) =
## Drains any pending PV data from the channel
while true:
let (has, _) = state.pvChannel.tryRecv()
if not has: break
proc restartAnalysis*(state: AppState) =
## Restarts analysis if it's running (e.g. after a position change)
if state.analysisRunning:
stopSearch(state)
discard state.channels.response.recv()
drainPVChannel(state)
state.analysisLines = @[]
var positions: seq[Position]
for pos in state.board.positions:
positions.add(pos.clone())
let cmd = SearchCommand(
kind: StartAnalysis,
analysisPositions: positions,
analysisVariations: state.multiPV
)
state.channels.command.send(cmd)
proc pollSearchResults*(state: AppState) =
## Non-blocking poll of search statistics for live display updates.
## Called every frame from the main event loop.
if not state.analysisRunning and not state.engineThinking:
return
# Read atomic statistics, aggregating node counts from all threads
let stats = state.searcher.statistics
let totalNodes = state.searcher.limiter.totalNodes()
state.analysisNodes = totalNodes
state.analysisDepth = stats.highestDepth.load(moRelaxed)
# Compute NPS from total nodes across all threads
let startTime = state.searcher.state.searchStart.load(moRelaxed)
let elapsedMs = (getMonoTime() - startTime).inMilliseconds()
if elapsedMs > 0:
state.analysisNPS = (totalNodes * 1000) div elapsedMs.uint64
# Read best score
let bestScore = stats.bestRootScore.load(moRelaxed)
let bestMove = stats.bestMove.load(moRelaxed)
# Read live per-variation data from atomics.
# Scores from the search are STM-relative (positive = good for side to move).
# We store them as white-relative for display, but sort by STM-relative (best for current player first).
let varCount = stats.variationCount.load(moRelaxed)
let sideToMove = state.board.sideToMove()
let material = state.board.material()
proc toDisplayScore(rawStmScore: Score, stm: PieceColor, mat: int): Score =
## Converts a raw STM-relative score to a normalized white-relative display score
let normalized = normalizeScore(rawStmScore, mat)
if stm == Black: -normalized else: normalized
if varCount > 0:
# Ensure we have enough slots
while state.analysisLines.len < state.multiPV:
state.analysisLines.add(AnalysisLine())
# Collect raw scores for sorting
type ScoredVar = tuple[idx: int, rawScore: Score]
var scored: seq[ScoredVar]
for i in 0..<varCount:
let vScore = stats.variationScores[i].load(moRelaxed)
let vMove = stats.variationMoves[i].load(moRelaxed)
if vMove != nullMove():
scored.add((idx: i, rawScore: vScore))
# Read the full PV from previousVariations (safe on x86:
# if the atomic variationCount write is visible, preceding
# non-atomic writes to previousVariations are too)
var pvMoves: seq[Move]
for m in state.searcher.previousVariations[i].moves:
if m == nullMove(): break
pvMoves.add(m)
if pvMoves.len == 0:
pvMoves = @[vMove]
state.analysisLines[i] = AnalysisLine(
pv: pvMoves,
score: toDisplayScore(vScore, sideToMove, material),
rawScore: vScore,
depth: state.analysisDepth
)
# Sort by raw STM score descending (best for side to move first)
for i in 0..<scored.len:
for j in i+1..<scored.len:
if scored[j].rawScore > scored[i].rawScore:
swap(scored[i], scored[j])
# Reorder analysisLines to match sorted order
var sorted: seq[AnalysisLine]
for sv in scored:
sorted.add(state.analysisLines[sv.idx])
# Keep any remaining old lines that weren't updated this iteration
for i in sorted.len..<min(state.analysisLines.len, state.multiPV):
sorted.add(state.analysisLines[i])
state.analysisLines = sorted
elif bestMove != nullMove():
let displayScore = toDisplayScore(bestScore, sideToMove, material)
if state.analysisLines.len == 0:
state.analysisLines = @[AnalysisLine(pv: @[bestMove], score: displayScore, rawScore: bestScore, depth: state.analysisDepth)]
else:
state.analysisLines[0] = AnalysisLine(pv: @[bestMove], score: displayScore, rawScore: bestScore, depth: state.analysisDepth)
# Check for full MultiPV results from the worker (richer PV data after search completes)
let (hasPV, pvLines) = state.pvChannel.tryRecv()
if hasPV and pvLines.len > 0:
# pvLines scores are raw STM-relative from the search; sort and normalize
type ScoredPV = tuple[idx: int, rawScore: Score]
var scoredPV: seq[ScoredPV]
for i, line in pvLines:
scoredPV.add((idx: i, rawScore: line.score))
for i in 0..<scoredPV.len:
for j in i+1..<scoredPV.len:
if scoredPV[j].rawScore > scoredPV[i].rawScore:
swap(scoredPV[i], scoredPV[j])
var sortedPV: seq[AnalysisLine]
for sv in scoredPV:
var line = pvLines[sv.idx]
line.rawScore = sv.rawScore
line.score = toDisplayScore(sv.rawScore, sideToMove, material)
sortedPV.add(line)
state.analysisLines = sortedPV
# Check for search completion (non-blocking) on primary channel
let (hasData, response) = state.channels.response.tryRecv()
if hasData:
case response
of SearchComplete:
if state.engineThinking:
state.engineThinking = false
of Exiting:
discard
# Also check watch channel for second engine completion
if state.watchInitialized:
let (hasWatch, watchResp) = state.watchChannels.response.tryRecv()
if hasWatch:
case watchResp
of SearchComplete:
if state.engineThinking:
state.engineThinking = false
of Exiting:
discard
proc shutdownSearchWorker*(state: AppState) =
## Cleanly shuts down the search worker thread
if state.analysisRunning:
stopAnalysis(state)
state.channels.command.send(SearchCommand(kind: Shutdown))
# Wait for the worker to exit
discard state.channels.response.recv()
joinThread(state.searchWorkerThread)

123
src/heimdall/tui/app.nim Normal file
View File

@@ -0,0 +1,123 @@
# Copyright 2025 Mattia Giambirtone & All Contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
## TUI application entry point and main event loop
import std/[os, exitprocs]
import illwill
import heimdall/search
import heimdall/tui/[state, renderer, events, analysis, play, kitty, rawinput]
const
BOARD_MARGIN_X = 1
BOARD_MARGIN_Y = 1
var
gState: AppState
gIllwillInitialized: bool = false
proc resetTerminal() =
## Restores the terminal to a usable state
disableMouseTracking()
deleteImage(1)
deleteImage(2)
if gIllwillInitialized:
illwillDeinit()
gIllwillInitialized = false
showCursor()
proc exitProc() {.noconv.} =
resetTerminal()
if gState != nil:
if gState.searcher.isSearching():
gState.searcher.cancel()
gState.cleanup()
quit(0)
proc startTUI* =
## Main entry point for the TUI mode
gState = newAppState()
let state = gState
addExitProc(proc () = resetTerminal())
# mouse=false: we handle mouse ourselves via rawinput
illwillInit(fullScreen=true, mouse=false)
gIllwillInitialized = true
hideCursor()
# Disable ISIG so Ctrl+C comes through as byte 0x03 to our input
# reader instead of generating SIGINT (which doesn't quit cleanly
# in threaded Nim programs)
disableISIG()
# Enable SGR mouse tracking (our rawinput module parses these)
enableMouseTracking()
# Start the background search worker
startSearchWorker(state)
# Board image position on terminal (1-based for ANSI)
let boardTermRow = BOARD_MARGIN_Y + 1
let boardTermCol = BOARD_MARGIN_X + 1
var wasEngineThinking = false
try:
while not state.shouldQuit:
# Drain all available input events (handles paste, rapid typing)
for inputRound in 0..255:
let event = pollInput()
case event.kind
of ievKey:
handleInput(state, event.key)
of ievMouse:
handleMouseEvent(state, event.mouse, boardTermRow, boardTermCol)
of ievNone:
break
# Poll search results for live updates
pollSearchResults(state)
# Detect engine move completion in play mode
if wasEngineThinking and not state.engineThinking:
if state.mode == ModePlay and state.playPhase == EngineTurn:
onEngineMoveComplete(state)
wasEngineThinking = state.engineThinking
# Tick clocks in play mode
tickClocks(state)
# Render the frame
render(state)
# ~60 FPS
sleep(16)
except CatchableError:
let e = getCurrentException()
resetTerminal()
stderr.writeLine("TUI error: " & e.msg)
stderr.writeLine(e.getStackTrace())
finally:
if state.searcher.isSearching():
state.searcher.cancel()
shutdownSearchWorker(state)
resetTerminal()
state.cleanup()

View File

@@ -0,0 +1,357 @@
# Copyright 2025 Mattia Giambirtone & All Contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
## Board rendering: composites pre-rendered piece images onto the
## board SVG and sends the result via the kitty graphics protocol.
import std/options
from std/posix import STDOUT_FILENO
from std/termios import IOctl_WinSize, TIOCGWINSZ, ioctl
import illwill
import heimdall/[pieces, board, bitboards]
import heimdall/tui/[state, pixel, kitty]
const
BOARD_IMG_ID = 1
DRAG_IMG_ID = 2
DRAG_PLACEMENT_ID = 1
BOARD_MARGIN_X* = 1
BOARD_MARGIN_Y* = 1
BOARD_GAP_COLS* = 2
INFO_PANEL_MIN_WIDTH* = 24
BOARD_MIN_PX* = 320
BOTTOM_UI_ROWS = 3
TRAILING_MARGIN_COLS = 1
proc getCellPixelSize*: tuple[w, h: int]
proc currentBoardPixelSize*(): int =
let cellSize = getCellPixelSize()
let cellW = max(1, cellSize.w)
let cellH = max(1, cellSize.h)
let termW = terminalWidth()
let termH = terminalHeight()
let availableCols = termW - BOARD_MARGIN_X - BOARD_GAP_COLS - INFO_PANEL_MIN_WIDTH - TRAILING_MARGIN_COLS
let availableRows = termH - BOARD_MARGIN_Y - BOTTOM_UI_ROWS
if availableCols <= 0 or availableRows <= 0:
return 0
let maxBoardPx = min(BOARD_PX, min(availableCols * cellW, availableRows * cellH))
result = (maxBoardPx div 8) * 8
if result < BOARD_MIN_PX:
result = 0
proc minimumTerminalSize*(): tuple[w, h: int] =
let cellSize = getCellPixelSize()
let cellW = max(1, cellSize.w)
let cellH = max(1, cellSize.h)
let boardCols = (BOARD_MIN_PX + cellW - 1) div cellW
let boardRows = (BOARD_MIN_PX + cellH - 1) div cellH
result.w = BOARD_MARGIN_X + boardCols + BOARD_GAP_COLS + INFO_PANEL_MIN_WIDTH + TRAILING_MARGIN_COLS
result.h = BOARD_MARGIN_Y + boardRows + BOTTOM_UI_ROWS
proc boardVisible*(): bool =
currentBoardPixelSize() > 0
proc renderBoardImage*(state: AppState): PixelBuffer =
## Composites the full chessboard image: board background + pieces + highlights
let boardPx = currentBoardPixelSize()
if boardPx <= 0:
return newPixelBuffer(0, 0)
let squarePx = boardPx div 8
let pad = max(1, squarePx div 8)
let pieceSize = max(1, squarePx - pad * 2)
result = newPixelBuffer(boardPx, boardPx)
result.blendOverScaled(getBoardImage(state.flipped), 0, 0, boardPx, boardPx)
let dragging = state.dragSourceSquare.isSome()
let draggedSquare = if dragging: state.dragSourceSquare.get() else: Square(0)
let threats = state.board.position.threats
let sideToMove = state.board.sideToMove()
let inCheck = state.board.inCheck()
let kingSquare = if inCheck: state.board.position.pieces(King, sideToMove).toSquare() else: Square(0)
for displayRank in 0..7:
let rank = if state.flipped: 7 - displayRank else: displayRank
for displayFile in 0..7:
let file = if state.flipped: 7 - displayFile else: displayFile
let sq = makeSquare(rank, file)
let piece = state.board.on(sq)
let ox = displayFile * squarePx
let oy = displayRank * squarePx
if state.lastMove.isSome():
let lm = state.lastMove.get()
if sq == lm.fromSq or sq == lm.toSq:
result.tintRect(ox, oy, ox + squarePx - 1, oy + squarePx - 1, LAST_MOVE_TINT)
var premoveIndex = -1
for i, premove in state.pendingPremoves:
if sq == premove.fromSq or sq == premove.toSq:
premoveIndex = i
if premoveIndex >= 0:
result.tintRect(ox, oy, ox + squarePx - 1, oy + squarePx - 1, premoveTint(premoveIndex))
if state.selectedSquare.isSome() and sq == state.selectedSquare.get():
result.tintRect(ox, oy, ox + squarePx - 1, oy + squarePx - 1, SELECTED_TINT)
var isLegalDest = false
for dest in state.legalDestinations:
if sq == dest:
isLegalDest = true
break
if isLegalDest:
if piece.kind == Empty:
result.fillCircle(ox + squarePx div 2, oy + squarePx div 2, max(2, squarePx div 6), LEGAL_DEST_TINT)
else:
result.tintRect(ox, oy, ox + squarePx - 1, oy + squarePx - 1, LEGAL_DEST_TINT)
if state.showThreats and piece.kind != Empty and piece.color == sideToMove:
if sq in threats:
result.tintRect(ox, oy, ox + squarePx - 1, oy + squarePx - 1, THREATENED_TINT)
if inCheck and sq == kingSquare:
result.tintRect(ox, oy, ox + squarePx - 1, oy + squarePx - 1, CHECK_TINT)
if piece.kind != Empty and (not dragging or sq != draggedSquare):
let pieceImg = getPieceImage(piece)
if pieceImg.width > 0:
result.blendOverScaled(pieceImg, ox + pad, oy + pad, pieceSize, pieceSize)
var lastBoardHash: uint64 = 0
var lastDragHash: uint64 = 0
var lastDragPiece: Piece = nullPiece()
var lastDragPieceSize: int = 0
var boardImageVisible: bool = false
var dragImageVisible: bool = false
proc resetBoardHash*() =
## Forces the board to be re-rendered on the next displayBoard call
lastBoardHash = 0
lastDragHash = 0
lastDragPiece = nullPiece()
lastDragPieceSize = 0
proc hideBoardImages*() =
if not boardImageVisible and not dragImageVisible:
return
if boardImageVisible:
deleteImage(BOARD_IMG_ID)
boardImageVisible = false
if dragImageVisible:
deletePlacement(DRAG_IMG_ID, DRAG_PLACEMENT_ID)
deleteImage(DRAG_IMG_ID)
dragImageVisible = false
resetBoardHash()
proc boardChanged*(state: AppState): bool =
let boardPx = currentBoardPixelSize()
var h: uint64 = state.board.zobristKey().uint64
h = h xor (if state.flipped: 1'u64 else: 0'u64)
h = h xor (if state.showThreats: 2'u64 else: 0'u64)
h = h xor (boardPx.uint64 * 0xD6E8FEB86659FD93'u64)
if state.lastMove.isSome():
let lm = state.lastMove.get()
h = h xor (lm.fromSq.uint64 shl 16) xor (lm.toSq.uint64 shl 24)
if state.selectedSquare.isSome():
h = h xor (state.selectedSquare.get().uint64 shl 32)
h = h xor (state.pendingPremoves.len.uint64 * 0x94D049BB133111EB'u64)
for i, premove in state.pendingPremoves:
h = h xor (premove.fromSq.uint64 * (0x94D049BB133111EB'u64 xor i.uint64))
h = h xor (premove.toSq.uint64 * (0x2545F4914F6CDD1D'u64 xor (i.uint64 shl 8)))
h = h xor (state.legalDestinations.len.uint64 * 0xBF58476D1CE4E5B9'u64)
for i, dest in state.legalDestinations:
h = h xor (dest.uint64 * (0x9E3779B185EBCA87'u64 xor i.uint64))
if state.dragSourceSquare.isSome():
h = h xor (state.dragSourceSquare.get().uint64 * 0x9E3779B185EBCA87'u64)
result = h != lastBoardHash
lastBoardHash = h
proc renderDraggedPiece(piece: Piece): PixelBuffer =
let boardPx = currentBoardPixelSize()
if boardPx <= 0:
return newPixelBuffer(0, 0)
let pieceImg = getPieceImage(piece)
let squarePx = boardPx div 8
let pad = max(1, squarePx div 8)
let pieceSize = max(1, squarePx - pad * 2)
result = newPixelBuffer(pieceSize, pieceSize)
if pieceImg.width > 0:
result.blendOverScaled(pieceImg, 0, 0, pieceSize, pieceSize)
proc displayDraggedPiece(state: AppState, termRow, termCol: int) =
let boardPx = currentBoardPixelSize()
let dragging = state.dragSourceSquare.isSome() and state.dragCursor.isSome()
if not dragging or boardPx <= 0:
if dragImageVisible:
deletePlacement(DRAG_IMG_ID, DRAG_PLACEMENT_ID)
lastDragHash = 0
dragImageVisible = false
return
let sourceSq = state.dragSourceSquare.get()
let piece = state.board.on(sourceSq)
if piece.kind == Empty:
if dragImageVisible:
deletePlacement(DRAG_IMG_ID, DRAG_PLACEMENT_ID)
lastDragHash = 0
dragImageVisible = false
return
let squarePx = boardPx div 8
let pad = max(1, squarePx div 8)
let pieceSize = max(1, squarePx - pad * 2)
let dragCursor = state.dragCursor.get()
let topLeftX = max(0, min(boardPx - pieceSize, dragCursor.x - pieceSize div 2))
let topLeftY = max(0, min(boardPx - pieceSize, dragCursor.y - pieceSize div 2))
let cellSize = getCellPixelSize()
let cellW = max(1, cellSize.w)
let cellH = max(1, cellSize.h)
let placementCol = termCol + (topLeftX div cellW)
let placementRow = termRow + (topLeftY div cellH)
let offsetX = topLeftX mod cellW
let offsetY = topLeftY mod cellH
var h = sourceSq.uint64 * 0x9E3779B185EBCA87'u64
h = h xor (piece.kind.uint64 shl 8)
h = h xor (piece.color.uint64 shl 16)
h = h xor (placementCol.uint64 * 0x517CC1B727220A95'u64)
h = h xor (placementRow.uint64 * 0xC2B2AE3D27D4EB4F'u64)
h = h xor (offsetX.uint64 shl 24)
h = h xor (offsetY.uint64 shl 32)
h = h xor (pieceSize.uint64 * 0xDB4F0B9175AE2165'u64)
if h == lastDragHash:
return
if piece != lastDragPiece or pieceSize != lastDragPieceSize:
if lastDragPiece.kind != Empty:
deleteImage(DRAG_IMG_ID)
uploadImage(renderDraggedPiece(piece), DRAG_IMG_ID)
lastDragPiece = piece
lastDragPieceSize = pieceSize
placeImage(DRAG_IMG_ID, DRAG_PLACEMENT_ID, placementRow, placementCol, offsetX, offsetY, z=1)
lastDragHash = h
dragImageVisible = true
proc displayBoard*(state: AppState, termRow, termCol: int) =
## Renders and transmits the board image only when state changes.
if not boardVisible():
hideBoardImages()
return
if not boardChanged(state):
displayDraggedPiece(state, termRow, termCol)
return
let img = renderBoardImage(state)
transmitImage(img, termRow, termCol, BOARD_IMG_ID)
boardImageVisible = true
displayDraggedPiece(state, termRow, termCol)
proc getCellPixelSize*: tuple[w, h: int] =
## Queries the terminal for the actual cell pixel size via TIOCGWINSZ.
## Falls back to 9x18 if unavailable.
var ws: IOctl_WinSize
if ioctl(STDOUT_FILENO.cint, TIOCGWINSZ, addr ws) == 0 and ws.ws_xpixel > 0 and ws.ws_row > 0:
result.w = ws.ws_xpixel.int div ws.ws_col.int
result.h = ws.ws_ypixel.int div ws.ws_row.int
else:
result.w = 9
result.h = 18
proc boardWidth*: int =
## Terminal columns the board image occupies
let boardPx = currentBoardPixelSize()
if boardPx <= 0:
return 0
let cellW = getCellPixelSize().w
if cellW > 0:
return (boardPx + cellW - 1) div cellW
boardPx div 9 + 2
proc boardHeight*: int =
## Terminal rows the board image occupies
let boardPx = currentBoardPixelSize()
if boardPx <= 0:
return 0
let cellH = getCellPixelSize().h
if cellH > 0:
return (boardPx + cellH - 1) div cellH
boardPx div 18 + 1
proc termPixelToSquare*(state: AppState, mouseX, mouseY, boardTermRow, boardTermCol: int): Option[Square] =
## Maps a terminal pixel coordinate to a board square, given the
## board image's top-left terminal position (1-based cells).
let boardPx = currentBoardPixelSize()
if boardPx <= 0:
return none(Square)
let cellSize = getCellPixelSize()
let boardOriginX = (boardTermCol - 1) * cellSize.w
let boardOriginY = (boardTermRow - 1) * cellSize.h
let relX = mouseX - boardOriginX
let relY = mouseY - boardOriginY
if relX < 0 or relX >= boardPx or relY < 0 or relY >= boardPx:
return none(Square)
var file = (relX * 8) div boardPx
var rank = (relY * 8) div boardPx
if state.flipped:
file = 7 - file
rank = 7 - rank
if file in 0..7 and rank in 0..7:
return some(makeSquare(rank, file))
none(Square)
proc termPixelToBoardPixel*(mouseX, mouseY, boardTermRow, boardTermCol: int): tuple[x, y: int] =
## Maps a terminal pixel coordinate to a clamped pixel position inside the board image.
let boardPx = currentBoardPixelSize()
if boardPx <= 0:
return (x: 0, y: 0)
let cellSize = getCellPixelSize()
let boardOriginX = (boardTermCol - 1) * cellSize.w
let boardOriginY = (boardTermRow - 1) * cellSize.h
result.x = max(0, min(boardPx - 1, mouseX - boardOriginX))
result.y = max(0, min(boardPx - 1, mouseY - boardOriginY))

126
src/heimdall/tui/clock.nim Normal file
View File

@@ -0,0 +1,126 @@
# Copyright 2025 Mattia Giambirtone & All Contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
## Chess clock with increment support
import std/[monotimes, times, strformat, strutils]
import heimdall/tui/state
proc newClock*(timeMs: int64, incrementMs: int64 = 0): ChessClock =
result.remainingMs = timeMs
result.incrementMs = incrementMs
result.running = false
result.expired = false
proc tick*(clock: var ChessClock) =
## Called each frame to update the running clock
if not clock.running or clock.expired:
return
let now = getMonoTime()
let elapsed = (now - clock.lastTick).inMilliseconds()
clock.remainingMs -= elapsed
clock.lastTick = now
if clock.remainingMs <= 0:
clock.remainingMs = 0
clock.expired = true
proc start*(clock: var ChessClock) =
clock.lastTick = getMonoTime()
clock.running = true
proc stop*(clock: var ChessClock) =
if clock.running:
clock.tick() # account for final elapsed time
clock.running = false
proc press*(clock: var ChessClock) =
## Called when a move is made: stops the clock and adds increment
clock.stop()
clock.remainingMs += clock.incrementMs
proc formatTime*(clock: ChessClock): string =
if clock.remainingMs <= 0:
return "0:00.0"
let totalMs = clock.remainingMs
let totalSec = totalMs div 1000
let minutes = totalSec div 60
let seconds = totalSec mod 60
let tenths = (totalMs mod 1000) div 100
if totalSec < 10:
return &"{seconds}.{tenths}"
return &"{minutes}:{seconds:02d}"
proc parseDuration(s: string): int64 =
## Parses a duration string like "5m", "1h30m", "90s", "5m3s".
## Returns total milliseconds. Raises ValueError on bad input.
var totalMs: int64 = 0
var numBuf = ""
let s = s.strip().toLowerAscii()
for c in s:
if c.isDigit() or c == '.':
numBuf &= c
elif c == 'h':
if numBuf.len == 0: raise newException(ValueError, "missing number before 'h'")
totalMs += (parseFloat(numBuf) * 3600_000).int64
numBuf = ""
elif c == 'm':
if numBuf.len == 0: raise newException(ValueError, "missing number before 'm'")
totalMs += (parseFloat(numBuf) * 60_000).int64
numBuf = ""
elif c == 's':
if numBuf.len == 0: raise newException(ValueError, "missing number before 's'")
totalMs += (parseFloat(numBuf) * 1000).int64
numBuf = ""
elif c notin {' ', '\t'}:
raise newException(ValueError, &"unexpected character '{c}'")
# If there's a leftover number with no unit, treat as seconds
if numBuf.len > 0:
totalMs += (parseFloat(numBuf) * 1000).int64
if totalMs <= 0:
raise newException(ValueError, "time must be positive")
return totalMs
proc parseTimeControl*(s: string): tuple[timeMs: int64, incMs: int64, ok: bool] =
## Parses a time control string. Format: "<duration>+<increment>"
## Duration: "5m", "5m3s", "1h", "90s", "300" (bare number = seconds)
## Increment: "3s", "5", bare number = seconds
## Special: "none", "inf", "infinite"
let s = s.strip()
if s.toLowerAscii() in ["none", "inf", "infinite"]:
return (0'i64, 0'i64, true)
let parts = s.split("+")
if parts.len notin 1..2:
return (0'i64, 0'i64, false)
try:
let timeMs = parseDuration(parts[0])
var incMs: int64 = 0
if parts.len == 2:
incMs = parseDuration(parts[1])
return (timeMs, incMs, true)
except ValueError:
return (0'i64, 0'i64, false)

832
src/heimdall/tui/events.nim Normal file
View File

@@ -0,0 +1,832 @@
# Copyright 2025 Mattia Giambirtone & All Contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
## Key and mouse event dispatch for the TUI
import std/[options, strutils, strformat]
import illwill
import heimdall/[pieces, movegen, moves, board]
import heimdall/tui/[state, san, input, analysis, play, rawinput, board_view]
proc getLegalMovesFrom(state: AppState, sq: Square): seq[Square] =
## Returns all legal destination squares from the given square
var moves = newMoveList()
state.board.generateMoves(moves)
for move in moves:
if move.startSquare() == sq:
result.add(move.targetSquare())
proc applyMove*(state: AppState, move: Move)
proc isPromotionMove(state: AppState, fromSq, toSq: Square): bool =
## Checks if any legal move from fromSq to toSq is a promotion
var moves = newMoveList()
state.board.generateMoves(moves)
for move in moves:
if move.startSquare() == fromSq and move.targetSquare() == toSq and move.isPromotion():
return true
proc findMove(state: AppState, fromSq, toSq: Square, promotionPiece: PieceKind = Queen): Move =
## Finds the legal move from fromSq to toSq, or returns nullMove.
## For promotions, uses the specified piece.
var moves = newMoveList()
state.board.generateMoves(moves)
for move in moves:
if move.startSquare() == fromSq and move.targetSquare() == toSq:
if move.isPromotion():
if move.flag().promotionToPiece() == promotionPiece:
return move
else:
return move
return nullMove()
proc startPromotionChoice(state: AppState, fromSq, toSq: Square) =
## Enters promotion piece selection mode
state.promotionPending = true
state.promotionFrom = fromSq
state.promotionTo = toSq
state.setStatus("Promote to: [Q]ueen / [R]ook / [B]ishop / [N]knight")
proc maxHelpScroll(): int =
let panelHeight = terminalHeight() - 4
max(0, helpLineCount() - helpViewportHeight(panelHeight))
proc completePromotion*(state: AppState, piece: PieceKind) =
## Completes a pending promotion with the chosen piece
state.promotionPending = false
let move = findMove(state, state.promotionFrom, state.promotionTo, piece)
if move != nullMove():
applyMove(state, move)
else:
state.setError("Invalid promotion!")
proc tryMakeMove(state: AppState, fromSq, toSq: Square) =
## Attempts to make a move, handling promotion if needed
if isPromotionMove(state, fromSq, toSq):
if state.autoQueen:
let move = findMove(state, fromSq, toSq, Queen)
if move != nullMove():
applyMove(state, move)
else:
startPromotionChoice(state, fromSq, toSq)
else:
let move = findMove(state, fromSq, toSq)
if move != nullMove():
applyMove(state, move)
else:
state.setError("Illegal move!")
proc applyMove*(state: AppState, move: Move) =
## Applies a move to the board and updates state
if move == nullMove():
return
# Record SAN before making the move (position must be pre-move)
let sanStr = state.board.toSAN(move)
# Record last move for highlighting
state.lastMove = some((fromSq: move.startSquare(), toSq: move.targetSquare()))
let result = state.board.makeMove(move)
if result == nullMove():
state.setError("Illegal move!")
return
state.moveHistory.add(move)
state.sanHistory.add(sanStr)
state.undoneHistory = @[] # new move clears redo stack
# Audible feedback
stdout.write("\a")
stdout.flushFile()
# Clear selection
state.selectedSquare = none(Square)
state.dragSourceSquare = none(Square)
state.dragCursor = none(tuple[x, y: int])
state.pendingPremoves = @[]
state.legalDestinations = @[]
# Trigger engine turn in play mode, or restart analysis
if state.mode == ModePlay and state.playPhase == PlayerTurn:
onPlayerMove(state)
elif state.analysisRunning:
restartAnalysis(state)
proc selectSquare(state: AppState, sq: Square) =
state.selectedSquare = some(sq)
state.legalDestinations = getLegalMovesFrom(state, sq)
proc clearSelection(state: AppState) =
state.selectedSquare = none(Square)
state.dragSourceSquare = none(Square)
state.dragCursor = none(tuple[x, y: int])
state.legalDestinations = @[]
proc replaceBoardState(state: AppState, pos: Position) =
state.board = newChessboardFromFEN(pos.toFEN(state.chess960))
state.moveHistory = @[]
state.sanHistory = @[]
state.undoneHistory = @[]
state.lastMove = none(tuple[fromSq, toSq: Square])
state.pendingPremoves = @[]
clearSelection(state)
state.startFEN = state.board.toFEN()
proc setupSpawnPieceForKey(key: Key): Option[Piece] =
let keyVal = key.int
if keyVal < 32 or keyVal > 126:
return none(Piece)
let c = chr(keyVal)
let color = if c.isUpperAscii(): White else: Black
let kind = case c.toLowerAscii()
of 'b': Bishop
of 'k': King
of 'n': Knight
of 'p': Pawn
of 'q': Queen
of 'r': Rook
else: return none(Piece)
result = some(Piece(kind: kind, color: color))
proc validateBoardSetupPosition(state: AppState): tuple[ok: bool, error: string] =
if state.board.position.pieces(King, White).count() != 1:
return (false, "board setup requires exactly one white king")
if state.board.position.pieces(King, Black).count() != 1:
return (false, "board setup requires exactly one black king")
for pawn in state.board.position.pieces(Pawn):
if rank(pawn) in [Rank(0), Rank(7)]:
return (false, "pawns cannot be placed on the first or eighth rank")
let whiteKing = state.board.position.kingSquare(White)
let blackKing = state.board.position.kingSquare(Black)
if not (kingMoves(whiteKing) and blackKing.toBitboard()).isEmpty():
return (false, "kings cannot be adjacent")
let nonSideToMove = state.board.position.sideToMove.opposite()
if not state.board.position.attackers(state.board.position.kingSquare(nonSideToMove), state.board.position.sideToMove).isEmpty():
return (false, &"{nonSideToMove} king is in check while it is {state.board.position.sideToMove}'s turn")
return (true, "")
proc finalizeBoardSetupPosition(state: AppState) =
var pos = state.board.position.clone()
pos.castlingAvailability[White] = (nullSquare(), nullSquare())
pos.castlingAvailability[Black] = (nullSquare(), nullSquare())
pos.enPassantSquare = nullSquare()
pos.halfMoveClock = 0
pos.fullMoveCount = max(1'u16, pos.fullMoveCount)
pos.updateChecksAndPins()
pos.hash()
replaceBoardState(state, pos)
proc enterBoardSetupMode(state: AppState) =
if state.mode != ModeAnalysis:
return
state.boardSetupResumeAnalysis = state.analysisRunning
if state.analysisRunning:
stopAnalysis(state)
state.boardSetupMode = true
state.boardSetupSpawnPiece = none(Piece)
replaceBoardState(state, state.board.position.clone())
state.setStatus("Board setup mode: drag pieces, drop off-board to delete, type p/n/b/r/q/k (Shift=White), Esc to validate and exit")
proc tryExitBoardSetupMode(state: AppState) =
let validation = validateBoardSetupPosition(state)
if not validation.ok:
state.setError("Cannot exit board setup: " & validation.error)
return
finalizeBoardSetupPosition(state)
state.boardSetupMode = false
state.boardSetupSpawnPiece = none(Piece)
let resumeAnalysis = state.boardSetupResumeAnalysis
state.boardSetupResumeAnalysis = false
state.setStatus("Board setup applied")
if resumeAnalysis:
restartAnalysis(state)
proc isLegalDestination(state: AppState, sq: Square): bool =
for dest in state.legalDestinations:
if dest == sq:
return true
proc handleBoardClick(state: AppState, clickedSq: Square) =
if state.selectedSquare.isSome():
let fromSq = state.selectedSquare.get()
if clickedSq == fromSq:
clearSelection(state)
return
if isLegalDestination(state, clickedSq):
clearSelection(state)
tryMakeMove(state, fromSq, clickedSq)
else:
let piece = state.board.on(clickedSq)
if piece.kind != Empty and piece.color == state.board.sideToMove():
selectSquare(state, clickedSq)
else:
clearSelection(state)
else:
let piece = state.board.on(clickedSq)
if piece.kind != Empty and piece.color == state.board.sideToMove():
selectSquare(state, clickedSq)
proc handlePremoveMouseEvent(state: AppState, mouse: MouseEvent, boardTermRow, boardTermCol: int) =
let sq = termPixelToSquare(state, mouse.x, mouse.y, boardTermRow, boardTermCol)
case mouse.action
of maPress:
if sq.isNone():
clearSelection(state)
return
let clickedSq = sq.get()
let piece = state.board.on(clickedSq)
if piece.kind != Empty and piece.color == state.playerColor:
state.dragSourceSquare = some(clickedSq)
state.dragCursor = some(termPixelToBoardPixel(mouse.x, mouse.y, boardTermRow, boardTermCol))
state.selectedSquare = some(clickedSq)
state.legalDestinations = @[]
else:
clearSelection(state)
of maRelease:
if state.dragSourceSquare.isSome():
let fromSq = state.dragSourceSquare.get()
state.dragSourceSquare = none(Square)
state.dragCursor = none(tuple[x, y: int])
if sq.isSome():
let targetSq = sq.get()
if targetSq != fromSq:
clearSelection(state)
state.queuePremove(fromSq, targetSq)
elif state.removeLatestPremoveAtSquare(fromSq):
clearSelection(state)
else:
state.selectedSquare = some(fromSq)
else:
clearSelection(state)
elif sq.isNone():
clearSelection(state)
else:
discard state.removeLatestPremoveAtSquare(sq.get())
of maMove:
if state.dragSourceSquare.isSome():
state.dragCursor = some(termPixelToBoardPixel(mouse.x, mouse.y, boardTermRow, boardTermCol))
proc handleBoardSetupMouseEvent(state: AppState, mouse: MouseEvent, boardTermRow, boardTermCol: int) =
let sq = termPixelToSquare(state, mouse.x, mouse.y, boardTermRow, boardTermCol)
case mouse.action
of maPress:
if state.boardSetupSpawnPiece.isSome():
if sq.isSome():
var pos = state.board.position.clone()
let targetSq = sq.get()
if pos.on(targetSq).kind != Empty:
pos.remove(targetSq)
pos.spawn(targetSq, state.boardSetupSpawnPiece.get())
pos.updateChecksAndPins()
pos.hash()
replaceBoardState(state, pos)
let piece = state.boardSetupSpawnPiece.get()
state.boardSetupSpawnPiece = none(Piece)
state.setStatus(&"Placed {piece.toChar()} on {targetSq.toUCI()}")
return
if sq.isNone():
clearSelection(state)
return
let clickedSq = sq.get()
let piece = state.board.on(clickedSq)
if piece.kind != Empty:
state.dragSourceSquare = some(clickedSq)
state.dragCursor = some(termPixelToBoardPixel(mouse.x, mouse.y, boardTermRow, boardTermCol))
state.selectedSquare = some(clickedSq)
state.legalDestinations = @[]
else:
clearSelection(state)
of maRelease:
if state.dragSourceSquare.isSome():
let fromSq = state.dragSourceSquare.get()
state.dragSourceSquare = none(Square)
state.dragCursor = none(tuple[x, y: int])
var pos = state.board.position.clone()
let piece = pos.on(fromSq)
if piece.kind == Empty:
clearSelection(state)
return
if sq.isNone():
pos.remove(fromSq)
pos.updateChecksAndPins()
pos.hash()
replaceBoardState(state, pos)
state.setStatus(&"Removed {piece.toChar()} from {fromSq.toUCI()}")
return
let targetSq = sq.get()
if targetSq != fromSq:
if pos.on(targetSq).kind != Empty:
pos.remove(targetSq)
pos.remove(fromSq)
pos.spawn(targetSq, piece)
pos.updateChecksAndPins()
pos.hash()
replaceBoardState(state, pos)
state.setStatus(&"Moved {piece.toChar()} to {targetSq.toUCI()}")
else:
clearSelection(state)
elif sq.isNone():
clearSelection(state)
of maMove:
if state.dragSourceSquare.isSome():
state.dragCursor = some(termPixelToBoardPixel(mouse.x, mouse.y, boardTermRow, boardTermCol))
proc handleMouseEvent*(state: AppState, mouse: MouseEvent, boardTermRow, boardTermCol: int) =
## Handles mouse clicks and simple drag-and-drop move input
if mouse.button != rawinput.mbLeft:
return
if state.boardSetupMode:
handleBoardSetupMouseEvent(state, mouse, boardTermRow, boardTermCol)
return
if state.mode == ModeReplay:
return
if state.mode == ModePlay and state.playPhase == EngineTurn and not state.watchMode:
handlePremoveMouseEvent(state, mouse, boardTermRow, boardTermCol)
return
if state.mode == ModePlay and state.playPhase in [EngineTurn, GameOver, Setup]:
return
let sq = termPixelToSquare(state, mouse.x, mouse.y, boardTermRow, boardTermCol)
case mouse.action
of maPress:
if sq.isNone():
state.dragSourceSquare = none(Square)
state.dragCursor = none(tuple[x, y: int])
clearSelection(state)
return
let clickedSq = sq.get()
let piece = state.board.on(clickedSq)
if piece.kind != Empty and piece.color == state.board.sideToMove():
state.dragSourceSquare = some(clickedSq)
state.dragCursor = some(termPixelToBoardPixel(mouse.x, mouse.y, boardTermRow, boardTermCol))
selectSquare(state, clickedSq)
else:
state.dragSourceSquare = none(Square)
state.dragCursor = none(tuple[x, y: int])
of maRelease:
if state.dragSourceSquare.isSome():
let fromSq = state.dragSourceSquare.get()
state.dragSourceSquare = none(Square)
state.dragCursor = none(tuple[x, y: int])
if sq.isNone():
selectSquare(state, fromSq)
return
let targetSq = sq.get()
if targetSq == fromSq:
selectSquare(state, fromSq)
return
if isLegalDestination(state, targetSq):
clearSelection(state)
tryMakeMove(state, fromSq, targetSq)
else:
let piece = state.board.on(targetSq)
if piece.kind != Empty and piece.color == state.board.sideToMove():
selectSquare(state, targetSq)
else:
selectSquare(state, fromSq)
elif sq.isNone():
clearSelection(state)
else:
handleBoardClick(state, sq.get())
of maMove:
if state.dragSourceSquare.isSome():
state.dragCursor = some(termPixelToBoardPixel(mouse.x, mouse.y, boardTermRow, boardTermCol))
proc handleTextInput(state: AppState, key: Key) =
## Handles text character input to the input buffer
let c = chr(key.int)
state.inputBuffer.insert($c, state.inputCursorPos)
inc state.inputCursorPos
proc handleBackspace(state: AppState) =
if state.inputCursorPos > 0:
let idx = state.inputCursorPos - 1
state.inputBuffer = state.inputBuffer[0..<idx] & state.inputBuffer[idx+1..^1]
dec state.inputCursorPos
proc toggleAutoQueen(state: AppState) =
state.autoQueen = not state.autoQueen
state.setStatus("Auto-queen: " & (if state.autoQueen: "ON" else: "OFF"))
proc handleInput*(state: AppState, key: Key) =
## Main input dispatcher
# Ctrl+C always quits immediately, never intercepted
if key == Key.CtrlC:
state.shouldQuit = true
return
# Handle pending promotion piece selection
if state.promotionPending:
case key
of Key.Q, Key.ShiftQ:
completePromotion(state, Queen)
of Key.R, Key.ShiftR:
completePromotion(state, Rook)
of Key.B, Key.ShiftB:
completePromotion(state, Bishop)
of Key.N, Key.ShiftN:
completePromotion(state, Knight)
of Key.Escape:
state.promotionPending = false
state.setStatus("")
else:
state.setStatus("Promote to: [Q]ueen / [R]ook / [B]ishop / [N]knight")
return
# Handle single-key setup prompts (no Enter needed)
if state.mode == ModePlay and state.playPhase == Setup and state.inputBuffer.len == 0:
case state.setupStep
of ChooseVariant:
case key
of Key.S, Key.ShiftS, Key.Enter:
state.dismissStatus()
handlePlaySetup(state, "s")
return
of Key.F, Key.ShiftF:
state.dismissStatus()
handlePlaySetup(state, "f")
return
of Key.D, Key.ShiftD:
state.dismissStatus()
handlePlaySetup(state, "d")
return
of Key.C, Key.ShiftC:
state.dismissStatus()
handlePlaySetup(state, "c")
return
else: discard
of ChooseSide:
case key
of Key.W, Key.ShiftW:
state.dismissStatus()
handlePlaySetup(state, "w")
return
of Key.B, Key.ShiftB:
state.dismissStatus()
handlePlaySetup(state, "b")
return
of Key.R, Key.ShiftR, Key.Enter:
state.dismissStatus()
handlePlaySetup(state, "r")
return
else: discard
of ChooseTakeback:
case key
of Key.Y, Key.ShiftY:
state.dismissStatus()
handlePlaySetup(state, "y")
return
of Key.N, Key.ShiftN, Key.Enter:
state.dismissStatus()
handlePlaySetup(state, "n")
return
else: discard
of ChoosePonder:
case key
of Key.Y, Key.ShiftY:
state.dismissStatus()
handlePlaySetup(state, "y")
return
of Key.N, Key.ShiftN, Key.Enter:
state.dismissStatus()
handlePlaySetup(state, "n")
return
else: discard
of ChooseWatchSeparate:
case key
of Key.Y, Key.ShiftY:
state.dismissStatus()
handlePlaySetup(state, "y")
return
of Key.N, Key.ShiftN, Key.Enter:
state.dismissStatus()
handlePlaySetup(state, "n")
return
else: discard
of ChooseWatchPonder, ChooseWatchWhitePonder, ChooseWatchBlackPonder:
case key
of Key.Y, Key.ShiftY:
state.dismissStatus()
handlePlaySetup(state, "y")
return
of Key.N, Key.ShiftN, Key.Enter:
state.dismissStatus()
handlePlaySetup(state, "n")
return
else: discard
else:
discard # Multi-char inputs (TC, hash, threads) need Enter
# Dismiss persistent status on any keypress (but not during setup - those prompts need input)
if state.statusPersistent and key != Key.None:
if not (state.mode == ModePlay and state.playPhase == Setup) and not state.boardSetupMode:
state.dismissStatus()
return
# Help overlay owns input while visible.
if state.helpVisible and key != Key.None:
let maxScroll = maxHelpScroll()
case key
of Key.Escape:
state.helpVisible = false
state.helpScroll = 0
of Key.Up:
state.helpScroll = max(0, state.helpScroll - 1)
of Key.Down:
state.helpScroll = min(maxScroll, state.helpScroll + 1)
of Key.PageUp:
state.helpScroll = max(0, state.helpScroll - helpViewportHeight(terminalHeight() - 4))
of Key.PageDown:
state.helpScroll = min(maxScroll, state.helpScroll + helpViewportHeight(terminalHeight() - 4))
of Key.Home:
state.helpScroll = 0
of Key.End:
state.helpScroll = maxScroll
else:
discard
return
# Any key other than Ctrl+D cancels the pending exit
if state.ctrlDPending and key != Key.CtrlD:
state.ctrlDPending = false
state.setStatus("")
case key
of Key.CtrlC:
discard # handled above, never reached
of Key.CtrlD:
if state.ctrlDPending:
state.shouldQuit = true
else:
state.ctrlDPending = true
state.setStatus("Press Ctrl+D again to exit")
of Key.Escape:
# ESC cancels the current action, never exits the GUI directly.
# Use Ctrl+C / Ctrl+D or :quit to exit.
if state.boardSetupMode:
tryExitBoardSetupMode(state)
elif state.statusPersistent:
state.dismissStatus()
elif state.acActive:
state.acActive = false
elif state.pendingPremoves.len > 0:
state.clearPremoves("Premoves cleared")
elif state.selectedSquare.isSome():
state.selectedSquare = none(Square)
state.legalDestinations = @[]
elif state.inputBuffer.len > 0:
state.inputBuffer = ""
state.inputCursorPos = 0
elif state.analysisRunning:
stopAnalysis(state)
state.setStatus("Analysis stopped")
elif state.mode == ModePlay and state.playPhase == Setup:
exitPlayMode(state)
elif state.mode == ModeReplay:
state.mode = ModeAnalysis
state.setStatus("Exited replay mode")
of Key.Tab:
# Accept autocomplete selection into input buffer
if state.acActive and state.acSelected >= 0 and state.acSelected < state.acSuggestions.len:
state.inputBuffer = ":" & state.acSuggestions[state.acSelected].cmd
state.inputCursorPos = state.inputBuffer.len
state.acActive = false
of Key.Up:
if state.acActive and state.acSuggestions.len > 0:
if state.acSelected > 0:
dec state.acSelected
else:
state.acSelected = state.acSuggestions.len - 1
return
# else fall through to default
of Key.Down:
if state.acActive and state.acSuggestions.len > 0:
if state.acSelected < state.acSuggestions.len - 1:
inc state.acSelected
else:
state.acSelected = 0
return
# else fall through to default
of Key.Enter:
if state.acActive and state.acSelected >= 0 and state.acSelected < state.acSuggestions.len:
# Execute the selected autocomplete command directly
let cmd = ":" & state.acSuggestions[state.acSelected].cmd
state.inputBuffer = ""
state.inputCursorPos = 0
state.acActive = false
processInput(state, cmd)
elif state.inputBuffer.len > 0:
let cmd = state.inputBuffer
state.inputBuffer = ""
state.inputCursorPos = 0
state.acActive = false
processInput(state, cmd)
elif state.mode == ModePlay and state.playPhase == Setup:
# Empty Enter during setup = accept default
state.dismissStatus()
handlePlaySetup(state, "")
of Key.Backspace:
handleBackspace(state)
updateAutocomplete(state)
of Key.Left:
if state.inputBuffer.len == 0:
# Undo last move (works in analysis, play, and PGN replay)
if state.moveHistory.len > 0:
let lastMove = state.moveHistory[^1]
let lastSan = state.sanHistory[^1]
state.board.unmakeMove()
discard state.moveHistory.pop()
discard state.sanHistory.pop()
# Save for redo
state.undoneHistory.add((move: lastMove, san: lastSan))
if state.mode == ModeReplay:
dec state.pgnMoveIndex
if state.moveHistory.len > 0:
let m = state.moveHistory[^1]
state.lastMove = some((fromSq: m.startSquare(), toSq: m.targetSquare()))
else:
state.lastMove = none(tuple[fromSq, toSq: Square])
state.selectedSquare = none(Square)
state.legalDestinations = @[]
if state.analysisRunning:
restartAnalysis(state)
elif state.inputCursorPos > 0:
dec state.inputCursorPos
of Key.Right:
if state.inputBuffer.len == 0:
if state.mode == ModeReplay and state.pgnMoveIndex < state.pgnMoves.len:
# Navigate forward in PGN (use the PGN's moves)
let move = state.pgnMoves[state.pgnMoveIndex]
let sanStr = state.board.toSAN(move)
state.lastMove = some((fromSq: move.startSquare(), toSq: move.targetSquare()))
discard state.board.makeMove(move)
state.moveHistory.add(move)
state.sanHistory.add(sanStr)
inc state.pgnMoveIndex
state.undoneHistory = @[] # clear redo stack on forward PGN nav
if state.analysisRunning:
restartAnalysis(state)
elif state.undoneHistory.len > 0:
# Redo an undone move
let (move, san) = state.undoneHistory.pop()
state.lastMove = some((fromSq: move.startSquare(), toSq: move.targetSquare()))
discard state.board.makeMove(move)
state.moveHistory.add(move)
state.sanHistory.add(san)
state.selectedSquare = none(Square)
state.legalDestinations = @[]
if state.analysisRunning:
restartAnalysis(state)
elif state.inputCursorPos < state.inputBuffer.len:
inc state.inputCursorPos
of Key.Home:
if state.inputBuffer.len == 0 and state.moveHistory.len > 0:
# Go to start - undo all moves
while state.moveHistory.len > 0:
let m = state.moveHistory[^1]
let s = state.sanHistory[^1]
state.board.unmakeMove()
discard state.moveHistory.pop()
discard state.sanHistory.pop()
state.undoneHistory.add((move: m, san: s))
if state.mode == ModeReplay:
dec state.pgnMoveIndex
state.lastMove = none(tuple[fromSq, toSq: Square])
state.selectedSquare = none(Square)
state.legalDestinations = @[]
if state.analysisRunning:
restartAnalysis(state)
of Key.End:
if state.inputBuffer.len == 0:
# Go to end - redo all undone moves (or PGN moves)
if state.mode == ModeReplay:
while state.pgnMoveIndex < state.pgnMoves.len:
let move = state.pgnMoves[state.pgnMoveIndex]
let sanStr = state.board.toSAN(move)
state.lastMove = some((fromSq: move.startSquare(), toSq: move.targetSquare()))
discard state.board.makeMove(move)
state.moveHistory.add(move)
state.sanHistory.add(sanStr)
inc state.pgnMoveIndex
state.undoneHistory = @[]
else:
while state.undoneHistory.len > 0:
let (move, san) = state.undoneHistory.pop()
state.lastMove = some((fromSq: move.startSquare(), toSq: move.targetSquare()))
discard state.board.makeMove(move)
state.moveHistory.add(move)
state.sanHistory.add(san)
state.selectedSquare = none(Square)
state.legalDestinations = @[]
if state.analysisRunning:
restartAnalysis(state)
else:
let setupSpawnPiece = if state.boardSetupMode: setupSpawnPieceForKey(key) else: none(Piece)
if setupSpawnPiece.isSome():
state.boardSetupSpawnPiece = setupSpawnPiece
let piece = setupSpawnPiece.get()
clearSelection(state)
state.setStatus(&"Spawn armed: {piece.toChar()} (click a square to place it)")
return
elif state.boardSetupMode:
return
# Global shortcuts always require Shift.
if state.mode == ModeAnalysis and not state.boardSetupMode and key == Key.ShiftS:
enterBoardSetupMode(state)
return
if key == Key.ShiftF:
state.flipped = not state.flipped
return
if key == Key.ShiftQ:
toggleAutoQueen(state)
return
# Printable ASCII characters
let keyVal = key.int
if keyVal >= 32 and keyVal <= 126:
handleTextInput(state, key)
updateAutocomplete(state)

987
src/heimdall/tui/input.nim Normal file
View File

@@ -0,0 +1,987 @@
# Copyright 2025 Mattia Giambirtone & All Contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
## Text input parsing: classifies input as commands, UCI moves, or SAN moves
import std/[strutils, strformat, options, atomics, base64, parseutils, times]
import heimdall/[board, moves, pieces, movegen, position, search, transpositions]
import heimdall/util/scharnagl
import heimdall/tui/[state, san, analysis, play, pgn, clock]
type
InputKind* = enum
Command
UCIMove
SANMove
const SET_OPTIONS*: seq[tuple[cmd, desc: string]] = @[
("hash", "Transposition table size (e.g. 64, 1 GB, 256 MiB)"),
("threads", "Number of search threads"),
("multipv", "Number of analysis lines"),
("depth", "Search depth limit"),
("contempt", "Static score offset (0-3000)"),
("moveoverhead", "Communication delay in ms (0-30000)"),
("ponder", "Enable/disable pondering (true/false)"),
("normalizescore", "Normalize displayed scores (true/false)"),
("evalfile", "Path to NNUE network file"),
("chess960", "Enable Chess960 mode (true/false)"),
]
const COMMANDS*: seq[tuple[cmd, desc: string]] = @[
("help", "Show available commands"),
("quit", "Exit the TUI"),
("flip", "Flip the board orientation"),
("reset", "Reset to starting position"),
("fen", "Show or load a FEN position"),
("unmove", "Take back the last move"),
("go", "Toggle continuous analysis"),
("stop", "Stop the current search"),
("play", "Play against the engine"),
("resign", "Resign the current game"),
("takeback", "Undo your last move in play mode"),
("watch", "Engine vs engine game"),
("exit", "Exit play/replay mode"),
("load", "Load a PGN file"),
("pgn", "Export current game as PGN to a file"),
("set", "Set engine/UCI options (:set <option> <value>)"),
("clear", "Reset engine state (TT, histories)"),
("threats", "Toggle threat highlighting"),
("frc", "Load a Chess960 position by number (0-959)"),
("dfrc", "Load a Double Fischer Random position"),
("chess960", "Toggle Chess960 mode on/off"),
]
const HELP_SHORTCUTS*: seq[tuple[key, desc: string]] = @[
("Shift+F", "Flip board"),
("Shift+Q", "Toggle auto-queen promotion"),
("Shift+S", "Board setup mode (analysis)"),
("Ctrl+C", "Quit immediately"),
("Ctrl+D", "Quit (press twice)"),
("Esc", "Cancel current action"),
("Left/Right", "Undo/redo moves"),
("Home/End", "Go to start/end"),
]
const HELP_VIEW_HEADER_ROWS* = 3
const HELP_VIEW_FOOTER_ROWS* = 2
proc helpViewportHeight*(panelHeight: int): int =
max(1, panelHeight - HELP_VIEW_HEADER_ROWS - HELP_VIEW_FOOTER_ROWS)
proc buildHelpLines*(): seq[string] =
result.add("Commands:")
for (cmd, desc) in COMMANDS:
result.add((":" & cmd).alignLeft(14) & desc)
result.add("")
result.add("Shortcuts:")
for (key, desc) in HELP_SHORTCUTS:
result.add(key.alignLeft(14) & desc)
result.add("")
result.add("Move input:")
result.add("UCI notation: e2e4, e7e8q")
result.add("SAN notation: Nf3, O-O, e8=Q")
result.add("Square select: e2 then e4")
result.add("Click piece, click destination")
result.add("Board setup: drag, drop off-board deletes")
result.add("Type p/n/b/r/q/k (Shift=White) to spawn")
result.add("Premoves queue; highlight colors show order")
result.add("")
result.add("Autocomplete:")
result.add("Tab: accept suggestion")
result.add("Enter: execute suggestion")
result.add("Up/Down: navigate suggestions")
proc helpLineCount*(): int =
buildHelpLines().len
proc updateAutocomplete*(state: AppState) =
## Updates autocomplete suggestions based on current input
if not state.inputBuffer.startsWith(":") or state.inputBuffer.len < 2:
state.acActive = false
state.acSuggestions = @[]
state.acSelected = -1
return
let content = state.inputBuffer[1..^1]
let parts = content.splitWhitespace()
if parts.len == 0:
state.acActive = false
return
state.acSuggestions = @[]
if parts.len == 1 and not content.endsWith(" "):
# Autocomplete command name
let prefix = parts[0].toLowerAscii()
for (cmd, desc) in COMMANDS:
if cmd.startsWith(prefix) and cmd != prefix:
state.acSuggestions.add((cmd, desc))
elif parts[0].toLowerAscii() == "set":
# Autocomplete :set subcommands
let subPrefix = if parts.len >= 2 and not content.endsWith(" "): parts[1].toLowerAscii()
elif content.endsWith(" "): ""
else: ""
if parts.len <= 2 and (parts.len < 2 or not content.endsWith(" ") or subPrefix.len == 0):
for (opt, desc) in SET_OPTIONS:
if subPrefix.len == 0 or (opt.startsWith(subPrefix) and opt != subPrefix):
state.acSuggestions.add(("set " & opt, desc))
state.acActive = state.acSuggestions.len > 0
if state.acSelected >= state.acSuggestions.len:
state.acSelected = state.acSuggestions.len - 1
if state.acSelected < 0 and state.acSuggestions.len > 0:
state.acSelected = 0
proc classifyInput*(s: string): InputKind =
## Determines whether the input is a command, UCI move, or SAN move
if s.startsWith(":"):
return Command
# UCI move: 4-5 chars like e2e4, e7e8q
if s.len in 4..5:
let lower = s.toLowerAscii()
if lower[0] in 'a'..'h' and lower[1] in '1'..'8' and
lower[2] in 'a'..'h' and lower[3] in '1'..'8':
if s.len == 5 and lower[4] notin ['q', 'r', 'b', 'n']:
return SANMove
return UCIMove
return SANMove
proc parseUCIMoveString*(board: Chessboard, moveStr: string, chess960: bool = false): tuple[move: Move, error: string] =
## Parses a UCI move string (e.g. "e2e4") into a Move.
## Standalone version that doesn't depend on UCISession.
var
startSquare: Square
targetSquare: Square
flag = Normal
if moveStr.len notin 4..5:
return (nullMove(), "invalid move syntax")
let move = moveStr.toLowerAscii()
try:
startSquare = move[0..1].toSquare(checked=true)
except ValueError:
return (nullMove(), &"invalid start square '{move[0..1]}'")
try:
targetSquare = move[2..3].toSquare(checked=true)
except ValueError:
return (nullMove(), &"invalid target square '{move[2..3]}'")
let piece = board.on(startSquare)
if piece.kind == Empty:
return (nullMove(), &"no piece on {move[0..1]}")
# Double pawn push
if piece.kind == Pawn and absDistance(rank(startSquare), rank(targetSquare)) == 2:
flag = DoublePush
# Promotion
if move.len == 5:
case move[4]:
of 'b': flag = PromotionBishop
of 'n': flag = PromotionKnight
of 'q': flag = PromotionQueen
of 'r': flag = PromotionRook
else:
return (nullMove(), &"invalid promotion piece '{move[4]}'")
# Capture detection
if board.on(targetSquare).color == piece.color.opposite():
case flag:
of PromotionBishop: flag = CapturePromotionBishop
of PromotionKnight: flag = CapturePromotionKnight
of PromotionRook: flag = CapturePromotionRook
of PromotionQueen: flag = CapturePromotionQueen
else: flag = Capture
# Castling detection
let canCastle = board.canCastle()
if piece.kind == King:
if startSquare in ["e1".toSquare(), "e8".toSquare()]:
case targetSquare:
of "c1".toSquare(), "c8".toSquare():
flag = LongCastling
targetSquare = canCastle.queen
of "g1".toSquare(), "g8".toSquare():
flag = ShortCastling
targetSquare = canCastle.king
else:
if targetSquare in [canCastle.king, canCastle.queen]:
if not chess960:
return (nullMove(), &"Chess960-style castling move '{moveStr}', but Chess960 is not enabled")
flag = if targetSquare == canCastle.king: ShortCastling else: LongCastling
elif targetSquare in [canCastle.king, canCastle.queen]:
if not chess960:
return (nullMove(), &"Chess960-style castling move '{moveStr}', but Chess960 is not enabled")
flag = if targetSquare == canCastle.king: ShortCastling else: LongCastling
# En passant
if piece.kind == Pawn and targetSquare == board.position.enPassantSquare:
flag = EnPassant
result.move = createMove(startSquare, targetSquare, flag)
proc processCommand*(state: AppState, cmd: string) =
## Processes a colon-prefixed command
let parts = cmd.strip().splitWhitespace()
if parts.len == 0:
return
# Dismiss autocomplete on command execution
state.acActive = false
case parts[0].toLowerAscii()
of "help", "h", "?":
state.helpVisible = not state.helpVisible
state.helpScroll = 0
of "quit", "q":
state.shouldQuit = true
of "flip":
state.flipped = not state.flipped
of "reset":
if state.mode == ModePlay and state.playPhase != Setup:
state.setError("Cannot reset board during a game. Use :exit first.")
return
state.board = newDefaultChessboard()
state.moveHistory = @[]
state.sanHistory = @[]
state.lastMove = none(tuple[fromSq, toSq: Square])
state.selectedSquare = none(Square)
state.legalDestinations = @[]
state.chess960 = false
state.setStatus("Board reset to starting position")
of "fen":
if parts.len < 2:
# Copy FEN to clipboard via OSC 52 and show it
let fen = state.board.toFEN()
let encoded = base64.encode(fen)
stdout.write("\x1b]52;c;" & encoded & "\x1b\\")
stdout.flushFile()
state.setStatus("FEN copied: " & fen)
else:
if state.mode == ModePlay and state.playPhase != Setup:
state.setError("Cannot load FEN during a game. Use :exit first.")
return
let fenStr = parts[1..^1].join(" ")
try:
let pos = fromFEN(fenStr)
state.board = newChessboardFromFEN(fenStr)
state.moveHistory = @[]
state.sanHistory = @[]
state.lastMove = none(tuple[fromSq, toSq: Square])
state.selectedSquare = none(Square)
state.legalDestinations = @[]
state.startFEN = fenStr
state.setStatus("Position loaded from FEN")
except CatchableError as e:
state.setError(&"Invalid FEN: {e.msg}")
of "unmove":
if state.moveHistory.len == 0:
state.setError("No moves to undo")
else:
state.board.unmakeMove()
discard state.moveHistory.pop()
discard state.sanHistory.pop()
if state.moveHistory.len > 0:
let lastM = state.moveHistory[^1]
state.lastMove = some((fromSq: lastM.startSquare(), toSq: lastM.targetSquare()))
else:
state.lastMove = none(tuple[fromSq, toSq: Square])
state.selectedSquare = none(Square)
state.legalDestinations = @[]
state.setStatus("Move undone")
of "set":
if state.analysisRunning or state.engineThinking:
state.setError("Cannot change settings while searching. Use :stop first, then :set.")
return
if parts.len < 3:
state.setError("Usage: :set <option> <value>")
else:
case parts[1].toLowerAscii()
of "multipv":
try:
let n = parseInt(parts[2])
if n < 1 or n > 500:
state.setError("MultiPV must be between 1 and 500")
else:
state.multiPV = n
state.analysisLines = @[] # Clear stale lines
if state.analysisRunning:
restartAnalysis(state)
state.setStatus(&"MultiPV set to {n}")
except ValueError:
state.setError(&"Invalid number: {parts[2]}")
of "threads":
try:
let n = parseInt(parts[2])
if n < 1 or n > 1024:
state.setError("Threads must be between 1 and 1024")
else:
state.engineThreads = n
state.searcher.setWorkerCount(n - 1) # n total = 1 main + (n-1) workers
state.setStatus(&"Threads set to {n}")
except ValueError:
state.setError(&"Invalid number: {parts[2]}")
of "hash":
let raw = parts[2..^1].join(" ").strip()
# Check if it's a bare number (no unit suffix) -> treat as MiB
var sizeMiB: int64
try:
let asNum = parseBiggestInt(raw)
# Bare number, interpret as MiB
sizeMiB = asNum
except ValueError:
# Has a unit suffix, use parseSize (returns bytes)
var sizeBytes: int64
let consumed = parseSize(raw, sizeBytes)
if consumed == 0:
state.setError("Invalid size. Examples: 64, 256 MiB, 1 GB, 2 GiB")
return
sizeMiB = sizeBytes div (1024 * 1024)
if sizeMiB < 1 or sizeMiB > 33554432:
state.setError("Hash must be between 1 MiB and 32 TiB")
else:
state.engineHash = sizeMiB.uint64
state.ttable.resize(sizeMiB.uint64 * 1024 * 1024)
state.setStatus(&"Hash resized to {sizeMiB} MiB")
of "depth":
try:
let n = parseInt(parts[2])
if n < 1 or n > 255:
state.setError("Depth must be between 1 and 255")
else:
state.engineDepth = some(n)
state.setStatus(&"Depth limit set to {n}")
except ValueError:
state.setError(&"Invalid number: {parts[2]}")
of "contempt":
try:
let n = parseInt(parts[2])
if n < 0 or n > 3000:
state.setError("Contempt must be between 0 and 3000")
else:
state.searcher.setContempt(n.int32)
state.setStatus(&"Contempt set to {n}")
except ValueError:
state.setError(&"Invalid number: {parts[2]}")
of "moveoverhead":
try:
let n = parseInt(parts[2])
if n < 0 or n > 30000:
state.setError("Move overhead must be between 0 and 30000 ms")
else:
state.setStatus(&"Move overhead set to {n} ms")
except ValueError:
state.setError(&"Invalid number: {parts[2]}")
of "ponder":
let v = parts[2].toLowerAscii()
if v in ["true", "on", "yes", "1"]:
state.setStatus("Ponder enabled")
elif v in ["false", "off", "no", "0"]:
state.setStatus("Ponder disabled")
else:
state.setError("Expected true/false")
of "normalizescore":
let v = parts[2].toLowerAscii()
if v in ["true", "on", "yes", "1"]:
state.searcher.state.normalizeScore.store(true, moRelaxed)
state.setStatus("Score normalization enabled")
elif v in ["false", "off", "no", "0"]:
state.searcher.state.normalizeScore.store(false, moRelaxed)
state.setStatus("Score normalization disabled")
else:
state.setError("Expected true/false")
of "chess960", "uci_chess960":
let v = parts[2].toLowerAscii()
if v in ["true", "on", "yes", "1"]:
state.chess960 = true
state.searcher.state.chess960.store(true, moRelaxed)
state.setStatus("Chess960 enabled")
elif v in ["false", "off", "no", "0"]:
state.chess960 = false
state.variant = Standard
state.searcher.state.chess960.store(false, moRelaxed)
state.setStatus("Chess960 disabled")
else:
state.setError("Expected true/false")
of "evalfile":
let path = parts[2..^1].join(" ")
if path == "<default>" or path == "default":
state.searcher.setNetwork("")
state.setStatus("Using default network")
else:
state.searcher.setNetwork(path)
state.setStatus(&"Network loaded: {path}")
else:
state.setError(&"Unknown option: {parts[1]}. Use :help for available options.")
of "load":
if state.mode == ModePlay and state.playPhase != Setup:
state.setError("Cannot load PGN during a game. Use :exit first.")
elif parts.len < 2:
state.setError("Usage: :load <pgn-file> [game-number]")
else:
# Check if last arg is a game number
var path: string
var gameIdx = 0
let lastPart = parts[^1]
try:
let n = parseInt(lastPart)
if n >= 1 and parts.len >= 3:
gameIdx = n - 1 # 1-based to 0-based
path = parts[1..^2].join(" ")
else:
path = parts[1..^1].join(" ")
except ValueError:
path = parts[1..^1].join(" ")
try:
let content = readFile(path)
let games = parsePGN(content)
if games.len == 0:
state.setError("No games found in PGN file")
elif gameIdx >= games.len:
state.setError(&"Game {gameIdx + 1} not found (file has {games.len} game(s))")
else:
if games.len > 1 and gameIdx == 0:
# List available games
var listing = &"{games.len} games found. "
for i, g in games:
if i >= 5:
listing &= &"... Use :load {path} <1-{games.len}>"
break
let w = g.getTag("White")
let b = g.getTag("Black")
listing &= &"[{i+1}] {w} vs {b} "
state.setStatus(listing)
let game = games[gameIdx]
if state.analysisRunning:
stopAnalysis(state)
let startBoard = if game.startFEN.len > 0:
newChessboardFromFEN(game.startFEN)
else:
newDefaultChessboard()
state.board = startBoard
state.mode = ModeReplay
state.pgnMoves = game.moves
state.pgnSanHistory = game.sanMoves
state.pgnStartPosition = some(startBoard.position.clone())
state.pgnMoveIndex = 0
state.pgnTags = game.tags
state.pgnResult = game.result
state.moveHistory = @[]
state.sanHistory = @[]
state.lastMove = none(tuple[fromSq, toSq: Square])
state.selectedSquare = none(Square)
state.legalDestinations = @[]
let white = game.getTag("White")
let black = game.getTag("Black")
let gameNum = if games.len > 1: &" (game {gameIdx + 1}/{games.len})" else: ""
let info = if white.len > 0 or black.len > 0:
&"{white} vs {black} ({game.result}){gameNum}"
else:
&"Loaded {game.moves.len} moves ({game.result}){gameNum}"
state.setStatus(&"PGN loaded: {info}. Use Left/Right arrows to navigate.")
except IOError:
state.setError(&"Cannot read file: {path}")
except CatchableError as e:
state.setError(&"PGN error: {e.msg}")
of "pgn":
if parts.len < 2:
state.setError("Usage: :pgn <file>")
elif state.sanHistory.len == 0:
state.setError("No moves to export")
else:
var pgn = ""
pgn &= "[Event \"Heimdall TUI Game\"]\n"
pgn &= "[Site \"Local\"]\n"
# Date
let now = times.now()
pgn &= &"[Date \"{now.year}.{now.month.ord:02d}.{now.monthday:02d}\"]\n"
# Player names
if state.mode == ModePlay:
let whiteName = if state.playerColor == White: "Human" else: "Heimdall"
let blackName = if state.playerColor == Black: "Human" else: "Heimdall"
pgn &= &"[White \"{whiteName}\"]\n"
pgn &= &"[Black \"{blackName}\"]\n"
else:
pgn &= "[White \"?\"]\n"
pgn &= "[Black \"?\"]\n"
if state.startFEN != "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1":
pgn &= &"[FEN \"{state.startFEN}\"]\n"
pgn &= "[SetUp \"1\"]\n"
if state.chess960:
pgn &= "[Variant \"Chess960\"]\n"
# Result
let result = if state.gameResult.isSome():
let r = state.gameResult.get()
if "1-0" in r: "1-0"
elif "0-1" in r: "0-1"
elif "1/2" in r: "1/2-1/2"
else: "*"
else: "*"
pgn &= &"[Result \"{result}\"]\n\n"
# Movetext
var moveNum = 1
for i, san in state.sanHistory:
if i mod 2 == 0:
pgn &= $moveNum & ". "
pgn &= san & " "
if i mod 2 == 1:
inc moveNum
pgn &= result & "\n"
let path = parts[1..^1].join(" ")
try:
writeFile(path, pgn)
state.setStatus(&"PGN saved to {path}")
except IOError:
state.setError(&"Cannot write to {path}")
of "watch":
if state.analysisRunning:
stopAnalysis(state)
state.mode = ModePlay
state.watchMode = true
state.playPhase = Setup
state.setupStep = ChooseVariant
state.gameResult = none(string)
state.setStatus("Engine vs Engine. Choose variant: [S]tandard / [f]rc / [d]frc / [c]urrent", persistent=true)
of "play":
state.watchMode = false
startPlayMode(state)
of "exit":
if state.mode == ModePlay:
exitPlayMode(state)
elif state.mode == ModeReplay:
state.mode = ModeAnalysis
state.setStatus("Exited replay mode")
else:
state.setError("Nothing to exit")
of "clear":
if state.analysisRunning or state.engineThinking:
state.setError("Cannot clear while searching. Use :stop first.")
else:
state.ttable.init()
state.searcher.histories.clear()
state.searcher.resetWorkers()
state.setStatus("Engine state cleared (TT, histories, workers)")
of "go", "analyze", "analysis":
if state.mode == ModePlay:
state.setError("Exit play mode first (:exit)")
else:
toggleAnalysis(state)
of "takeback", "tb":
if state.mode != ModePlay or state.playPhase != PlayerTurn:
state.setError("Takeback only available during your turn in play mode")
elif not state.allowTakeback:
state.setError("Takeback is disabled for this game")
elif state.moveHistory.len < 2:
state.setError("No moves to take back")
else:
# Undo both the engine's last move and the player's last move
state.board.unmakeMove() # undo engine's move
discard state.moveHistory.pop()
discard state.sanHistory.pop()
state.board.unmakeMove() # undo player's move
discard state.moveHistory.pop()
discard state.sanHistory.pop()
if state.moveHistory.len > 0:
let m = state.moveHistory[^1]
state.lastMove = some((fromSq: m.startSquare(), toSq: m.targetSquare()))
else:
state.lastMove = none(tuple[fromSq, toSq: Square])
state.selectedSquare = none(Square)
state.legalDestinations = @[]
state.setStatus("Takeback: your last move undone")
of "resign":
if state.mode == ModePlay and state.playPhase in [PlayerTurn, EngineTurn]:
let winner = if state.playerColor == White: "0-1" else: "1-0"
state.gameResult = some(&"{winner} (resignation)")
state.playPhase = GameOver
state.playerClock.stop()
state.engineClock.stop()
if state.engineThinking:
stopSearch(state)
discard state.channels.response.recv()
state.engineThinking = false
state.setStatus(&"You resigned. {winner}")
else:
state.setError("Not in a game")
of "threats":
state.showThreats = not state.showThreats
state.setStatus("Threats: " & (if state.showThreats: "ON" else: "OFF"))
of "stop":
if state.analysisRunning:
stopAnalysis(state)
state.setStatus("Search stopped")
else:
state.setError("No search running")
of "frc":
if state.mode == ModePlay and state.playPhase != Setup:
state.setError("Cannot change position during a game. Use :exit first.")
return
if parts.len < 2:
state.setError("Usage: :frc <number> (0-959)")
else:
try:
let n = parseInt(parts[1])
if n notin 0..959:
state.setError("Scharnagl number must be 0-959")
else:
let fen = scharnaglToFEN(n)
state.board = newChessboardFromFEN(fen)
state.moveHistory = @[]
state.sanHistory = @[]
state.lastMove = none(tuple[fromSq, toSq: Square])
state.selectedSquare = none(Square)
state.legalDestinations = @[]
state.startFEN = fen
state.chess960 = true
state.variant = FischerRandom
state.searcher.state.chess960.store(true, moRelaxed)
state.setStatus(&"Chess960 position #{n} loaded")
except ValueError:
state.setError(&"Invalid number: {parts[1]}")
of "dfrc":
if state.mode == ModePlay and state.playPhase != Setup:
state.setError("Cannot change position during a game. Use :exit first.")
return
if parts.len < 2:
state.setError("Usage: :dfrc <white> <black> or :dfrc <index>")
else:
try:
var whiteNum, blackNum: int
if parts.len >= 3:
whiteNum = parseInt(parts[1])
blackNum = parseInt(parts[2])
if whiteNum notin 0..959 or blackNum notin 0..959:
state.setError("Scharnagl numbers must be 0-959")
return
else:
let n = parseInt(parts[1])
if n < 0 or n >= 960 * 960:
state.setError("DFRC index must be 0-921599")
return
whiteNum = n mod 960
blackNum = n div 960
let fen = scharnaglToFEN(whiteNum, blackNum)
state.board = newChessboardFromFEN(fen)
state.moveHistory = @[]
state.sanHistory = @[]
state.lastMove = none(tuple[fromSq, toSq: Square])
state.selectedSquare = none(Square)
state.legalDestinations = @[]
state.startFEN = fen
state.chess960 = true
state.variant = DoubleFischerRandom
state.searcher.state.chess960.store(true, moRelaxed)
state.setStatus(&"DFRC position (W:{whiteNum}, B:{blackNum}) loaded")
except ValueError:
state.setError("Invalid number(s)")
of "chess960":
if parts.len < 2:
state.setStatus(&"Chess960: {(if state.chess960: \"on\" else: \"off\")}")
else:
case parts[1].toLowerAscii()
of "on", "true", "yes", "1":
state.chess960 = true
state.searcher.state.chess960.store(true, moRelaxed)
state.setStatus("Chess960 enabled")
of "off", "false", "no", "0":
state.chess960 = false
state.variant = Standard
state.searcher.state.chess960.store(false, moRelaxed)
state.setStatus("Chess960 disabled")
else:
state.setError("Usage: :chess960 on|off")
else:
state.setError(&"Unknown command: {parts[0]}")
proc processUCIMove*(state: AppState, moveStr: string) =
## Processes a UCI move string
# Don't allow moves in replay mode or when engine is thinking
if state.mode == ModeReplay:
state.setError("Cannot make moves in replay mode")
return
if state.boardSetupMode:
state.setError("Cannot enter moves in board setup mode")
return
if state.mode == ModePlay and state.playPhase == EngineTurn and not state.watchMode:
let lower = moveStr.toLowerAscii()
if lower.len notin 4..5:
state.setError("Invalid premove syntax")
return
try:
let fromSq = lower[0..1].toSquare(checked=true)
let toSq = lower[2..3].toSquare(checked=true)
let piece = state.board.on(fromSq)
if piece.kind == Empty or piece.color != state.playerColor:
state.setError("Premove must start from one of your pieces")
return
state.queuePremove(fromSq, toSq)
state.selectedSquare = none(Square)
state.legalDestinations = @[]
except ValueError:
state.setError("Invalid premove syntax")
return
if state.mode == ModePlay and state.playPhase in [EngineTurn, GameOver, Setup]:
state.setError("Cannot make moves now")
return
let (move, error) = parseUCIMoveString(state.board, moveStr, state.chess960)
if move == nullMove():
state.setError(&"Invalid move: {error}")
return
# Validate legality
let result = state.board.makeMove(move)
if result == nullMove():
state.setError(&"Illegal move: {moveStr}")
return
# Undo the makeMove so applyMove can redo it properly
state.board.unmakeMove()
# Record SAN before making the move
let sanStr = state.board.toSAN(move)
state.lastMove = some((fromSq: move.startSquare(), toSq: move.targetSquare()))
let applied = state.board.makeMove(move)
if applied == nullMove():
state.setError(&"Illegal move: {moveStr}")
return
state.moveHistory.add(move)
state.sanHistory.add(sanStr)
state.pendingPremoves = @[]
state.selectedSquare = none(Square)
state.legalDestinations = @[]
if state.mode == ModePlay and state.playPhase == PlayerTurn:
onPlayerMove(state)
elif state.analysisRunning:
restartAnalysis(state)
proc processSANMove*(state: AppState, sanStr: string) =
## Processes a SAN move string
if state.mode == ModeReplay:
state.setError("Cannot make moves in replay mode")
return
if state.boardSetupMode:
state.setError("Cannot enter moves in board setup mode")
return
if state.mode == ModePlay and state.playPhase in [EngineTurn, GameOver, Setup]:
if state.playPhase == EngineTurn and not state.watchMode:
state.setError("Use square selection, dragging, or UCI to queue a premove")
else:
state.setError("Cannot make moves now")
return
let (move, error) = state.board.parseSAN(sanStr)
if move == nullMove():
state.setError(&"Invalid SAN: {error}")
return
# Record SAN before making the move
let san = state.board.toSAN(move)
state.lastMove = some((fromSq: move.startSquare(), toSq: move.targetSquare()))
let applied = state.board.makeMove(move)
if applied == nullMove():
state.setError(&"Illegal move: {sanStr}")
return
state.moveHistory.add(move)
state.sanHistory.add(san)
state.pendingPremoves = @[]
state.selectedSquare = none(Square)
state.legalDestinations = @[]
if state.mode == ModePlay and state.playPhase == PlayerTurn:
onPlayerMove(state)
elif state.analysisRunning:
restartAnalysis(state)
proc processInput*(state: AppState, input: string) =
## Processes a line of text input from the user
let trimmed = input.strip()
if trimmed.len == 0:
return
if state.boardSetupMode:
state.setError("Use the mouse and piece keys while in board setup mode")
return
# During play setup, non-command input goes to the setup handler
if state.mode == ModePlay and state.playPhase == Setup:
if trimmed.startsWith(":"):
processCommand(state, trimmed[1..^1])
else:
handlePlaySetup(state, trimmed)
return
# Check for square selection (2 chars like "e2") - select piece for keyboard move
let lower = trimmed.toLowerAscii()
if lower.len == 2 and lower[0] in 'a'..'h' and lower[1] in '1'..'8':
try:
let sq = lower.toSquare(checked=true)
let piece = state.board.on(sq)
let canQueuePremove = state.mode == ModePlay and state.playPhase == EngineTurn and not state.watchMode
if state.selectedSquare.isSome():
# Second square: try to make a move from selected to this square
let fromSq = state.selectedSquare.get()
if canQueuePremove:
state.queuePremove(fromSq, sq)
state.selectedSquare = none(Square)
state.legalDestinations = @[]
return
var moves = newMoveList()
state.board.generateMoves(moves)
# Check if any legal move exists from->to
var hasMove = false
var isPromo = false
for move in moves:
if move.startSquare() == fromSq and move.targetSquare() == sq:
hasMove = true
if move.isPromotion():
isPromo = true
break
if hasMove:
state.selectedSquare = none(Square)
state.legalDestinations = @[]
if isPromo and not state.autoQueen:
# Enter promotion selection mode
state.promotionPending = true
state.promotionFrom = fromSq
state.promotionTo = sq
state.setStatus("Promote to: [Q]ueen / [R]ook / [B]ishop / [N]knight")
else:
# Find the move (queen promo if applicable)
let promoPiece = if isPromo: Queen else: Pawn # Pawn = not a promo
var foundMove = nullMove()
for move in moves:
if move.startSquare() == fromSq and move.targetSquare() == sq:
if move.isPromotion():
if move.flag().promotionToPiece() == Queen:
foundMove = move
break
else:
foundMove = move
break
if foundMove != nullMove():
let sanStr = state.board.toSAN(foundMove)
state.lastMove = some((fromSq: foundMove.startSquare(), toSq: foundMove.targetSquare()))
let applied = state.board.makeMove(foundMove)
if applied != nullMove():
state.moveHistory.add(foundMove)
state.sanHistory.add(sanStr)
state.undoneHistory = @[]
state.pendingPremoves = @[]
stdout.write("\a")
stdout.flushFile()
if state.mode == ModePlay and state.playPhase == PlayerTurn:
onPlayerMove(state)
elif state.analysisRunning:
restartAnalysis(state)
elif piece.kind != Empty and piece.color == state.board.sideToMove():
# Re-select different piece
state.selectedSquare = some(sq)
state.legalDestinations = @[]
for move in moves:
if move.startSquare() == sq:
state.legalDestinations.add(move.targetSquare())
else:
state.setError(&"No legal move from {state.selectedSquare.get()} to {sq}")
state.selectedSquare = none(Square)
state.legalDestinations = @[]
elif canQueuePremove and piece.kind != Empty and piece.color == state.playerColor:
state.selectedSquare = some(sq)
state.legalDestinations = @[]
state.setStatus(&"Selected {piece.toChar()} on {lower}. Type premove destination square.")
elif piece.kind != Empty and piece.color == state.board.sideToMove():
# Select piece
state.selectedSquare = some(sq)
state.legalDestinations = @[]
var moves = newMoveList()
state.board.generateMoves(moves)
for move in moves:
if move.startSquare() == sq:
state.legalDestinations.add(move.targetSquare())
state.setStatus(&"Selected {piece.toChar()} on {lower}. Type destination square.")
elif piece.kind == Empty:
processSANMove(state, lower)
else:
if canQueuePremove:
state.setError(&"No piece of yours to premove from on {lower}")
else:
state.setError(&"No piece to select on {lower}")
return
except ValueError:
discard # fall through to normal input handling
let kind = classifyInput(trimmed)
case kind
of Command:
processCommand(state, trimmed[1..^1])
of UCIMove:
processUCIMove(state, trimmed)
of SANMove:
processSANMove(state, trimmed)

110
src/heimdall/tui/kitty.nim Normal file
View File

@@ -0,0 +1,110 @@
# Copyright 2025 Mattia Giambirtone & All Contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
## Kitty graphics protocol for transmitting images to the terminal
import std/[base64, strformat]
import heimdall/tui/pixel
# zlib FFI for image compression
{.passl: "-lz".}
proc compressBound(sourceLen: culong): culong {.importc, header: "<zlib.h>".}
proc compress2(dest: pointer, destLen: ptr culong, source: pointer, sourceLen: culong, level: cint): cint {.importc, header: "<zlib.h>".}
proc zlibCompress(data: openArray[uint8]): seq[uint8] =
let srcLen = data.len.culong
var destLen = compressBound(srcLen)
result = newSeq[uint8](destLen)
let rc = compress2(addr result[0], addr destLen, unsafeAddr data[0], srcLen, 6)
if rc == 0:
result.setLen(destLen)
else:
result = @[] # compression failed, caller falls back to uncompressed
const
ESC = "\x1b"
APC = ESC & "_G"
ST = ESC & "\\"
CHUNK_SIZE = 4096
proc deleteImage*(id: int) =
## Deletes a previously transmitted image by ID
stdout.write(&"{APC}a=d,d=I,i={id},q=2;{ST}")
stdout.flushFile()
proc deletePlacement*(imageId, placementId: int) =
## Deletes a specific image placement, keeping the image data.
stdout.write(&"{APC}a=d,d=i,i={imageId},p={placementId},q=2;{ST}")
stdout.flushFile()
proc transmitPixels(buf: PixelBuffer, id: int, display: bool, row = 1, col = 1) =
## Uploads RGBA image data, optionally displaying it immediately.
if display:
stdout.write(ESC & "7")
stdout.write(&"{ESC}[{row};{col}H")
# Try zlib compression (o=z tells kitty data is zlib-compressed)
let compressed = zlibCompress(buf.data)
let useCompression = compressed.len > 0
let payload = if useCompression: compressed else: buf.data
let encoded = base64.encode(payload)
let compFlag = if useCompression: ",o=z" else: ""
let action = if display: "a=T," else: ""
# Send in chunks
var pos = 0
var first = true
while pos < encoded.len:
let remaining = encoded.len - pos
let chunkLen = min(CHUNK_SIZE, remaining)
let chunk = encoded[pos..<pos + chunkLen]
let more = if pos + chunkLen < encoded.len: 1 else: 0
if first:
stdout.write(&"{APC}{action}f=32{compFlag},s={buf.width},v={buf.height},i={id},q=2,m={more};{chunk}{ST}")
first = false
else:
stdout.write(&"{APC}m={more};{chunk}{ST}")
pos += chunkLen
if display:
stdout.write(ESC & "8")
stdout.flushFile()
proc transmitImage*(buf: PixelBuffer, row, col: int, id: int = 1) =
## Transmits and displays an RGBA image at the given terminal position.
transmitPixels(buf, id, display=true, row=row, col=col)
proc uploadImage*(buf: PixelBuffer, id: int) =
## Uploads RGBA image data without creating a placement.
transmitPixels(buf, id, display=false)
proc placeImage*(imageId, placementId, row, col: int, x = 0, y = 0, z = 0) =
## Creates or updates an image placement at the given terminal position.
stdout.write(ESC & "7")
stdout.write(&"{ESC}[{row};{col}H")
stdout.write(&"{APC}a=p,i={imageId},p={placementId},X={x},Y={y},z={z},C=1,q=2;{ST}")
stdout.write(ESC & "8")
stdout.flushFile()

289
src/heimdall/tui/pgn.nim Normal file
View File

@@ -0,0 +1,289 @@
# Copyright 2025 Mattia Giambirtone & All Contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
## PGN (Portable Game Notation) parser
import std/[strutils, strformat]
import heimdall/[board, moves, movegen, position]
import heimdall/tui/san
type
PGNTag* = tuple[name, value: string]
PGNGame* = object
tags*: seq[PGNTag]
moves*: seq[Move] ## Main line moves
sanMoves*: seq[string] ## SAN text of each move
comments*: seq[string] ## Comment after each move (empty if none)
result*: string ## "1-0", "0-1", "1/2-1/2", "*"
startFEN*: string ## Starting position FEN (empty = standard)
PGNTokenKind = enum
tokSymbol # Move text, result, etc
tokString # Quoted string
tokInteger # Move number
tokPeriod # .
tokTagOpen # [
tokTagClose # ]
tokCommentOpen # {
tokCommentClose # }
tokRAVOpen # (
tokRAVClose # )
tokNAG # $N
tokEOF
PGNToken = object
kind: PGNTokenKind
value: string
PGNParser = object
input: string
pos: int
tokens: seq[PGNToken]
tokIdx: int
# --- Tokenizer ---
proc skipWhitespace(p: var PGNParser) =
while p.pos < p.input.len and p.input[p.pos] in {' ', '\t', '\n', '\r'}:
inc p.pos
# Skip line comments (;)
if p.pos < p.input.len and p.input[p.pos] == ';':
while p.pos < p.input.len and p.input[p.pos] != '\n':
inc p.pos
p.skipWhitespace()
proc tokenize(p: var PGNParser) =
while p.pos < p.input.len:
p.skipWhitespace()
if p.pos >= p.input.len: break
let c = p.input[p.pos]
case c
of '[':
p.tokens.add(PGNToken(kind: tokTagOpen))
inc p.pos
of ']':
p.tokens.add(PGNToken(kind: tokTagClose))
inc p.pos
of '{':
inc p.pos
var comment = ""
while p.pos < p.input.len and p.input[p.pos] != '}':
comment.add(p.input[p.pos])
inc p.pos
if p.pos < p.input.len: inc p.pos # skip }
p.tokens.add(PGNToken(kind: tokCommentOpen, value: comment.strip()))
of '(':
p.tokens.add(PGNToken(kind: tokRAVOpen))
inc p.pos
of ')':
p.tokens.add(PGNToken(kind: tokRAVClose))
inc p.pos
of '"':
inc p.pos
var s = ""
while p.pos < p.input.len and p.input[p.pos] != '"':
if p.input[p.pos] == '\\' and p.pos + 1 < p.input.len:
inc p.pos
s.add(p.input[p.pos])
inc p.pos
if p.pos < p.input.len: inc p.pos # skip closing "
p.tokens.add(PGNToken(kind: tokString, value: s))
of '$':
inc p.pos
var nag = ""
while p.pos < p.input.len and p.input[p.pos].isDigit():
nag.add(p.input[p.pos])
inc p.pos
p.tokens.add(PGNToken(kind: tokNAG, value: nag))
of '.':
p.tokens.add(PGNToken(kind: tokPeriod))
inc p.pos
# Skip additional dots (e.g. "1..." = "1.")
while p.pos < p.input.len and p.input[p.pos] == '.':
inc p.pos
of '*':
p.tokens.add(PGNToken(kind: tokSymbol, value: "*"))
inc p.pos
else:
if c.isDigit() or c.isAlphaAscii() or c in {'-', '+', '#', '=', '/'}:
var sym = ""
while p.pos < p.input.len and p.input[p.pos] notin {' ', '\t', '\n', '\r', '[', ']', '{', '}', '(', ')', '"', ';'}:
sym.add(p.input[p.pos])
inc p.pos
# Distinguish integers from symbols
var allDigits = true
for ch in sym:
if not ch.isDigit():
allDigits = false
break
if allDigits and sym.len > 0:
p.tokens.add(PGNToken(kind: tokInteger, value: sym))
else:
p.tokens.add(PGNToken(kind: tokSymbol, value: sym))
else:
inc p.pos # skip unknown chars
p.tokens.add(PGNToken(kind: tokEOF))
# --- Parser ---
proc peek(p: PGNParser): PGNToken =
if p.tokIdx < p.tokens.len:
p.tokens[p.tokIdx]
else:
PGNToken(kind: tokEOF)
proc advance(p: var PGNParser): PGNToken =
result = p.peek()
if p.tokIdx < p.tokens.len:
inc p.tokIdx
proc isResult(s: string): bool =
s in ["1-0", "0-1", "1/2-1/2", "*"]
proc parseTags(p: var PGNParser): seq[PGNTag] =
while p.peek().kind == tokTagOpen:
discard p.advance() # [
var name = ""
var value = ""
if p.peek().kind == tokSymbol:
name = p.advance().value
if p.peek().kind == tokString:
value = p.advance().value
if p.peek().kind == tokTagClose:
discard p.advance() # ]
result.add((name: name, value: value))
proc parseMovetext(p: var PGNParser, startBoard: Chessboard): tuple[moves: seq[Move], sans: seq[string], comments: seq[string], result: string] =
var board = newChessboard(startBoard.positions)
var ravDepth = 0
while p.peek().kind != tokEOF:
let tok = p.peek()
case tok.kind
of tokInteger:
discard p.advance() # move number
# Skip periods after move number
while p.peek().kind == tokPeriod:
discard p.advance()
of tokPeriod:
discard p.advance()
of tokSymbol:
if tok.value.isResult():
result.result = p.advance().value
return
if ravDepth > 0:
# Inside a variation - skip
discard p.advance()
continue
let sanStr = p.advance().value
let (move, error) = board.parseSAN(sanStr)
if move == nullMove():
# Failed to parse move - try to continue
result.comments.add(&"[Error: {error} for '{sanStr}']")
continue
result.moves.add(move)
result.sans.add(sanStr)
# Check for comment after the move
if p.peek().kind == tokCommentOpen:
result.comments.add(p.advance().value)
else:
result.comments.add("")
discard board.makeMove(move)
of tokCommentOpen:
let comment = p.advance().value
# Comment before any move or between move number and move
if ravDepth == 0 and result.comments.len > 0 and result.comments[^1] == "":
result.comments[^1] = comment
of tokRAVOpen:
discard p.advance()
inc ravDepth
of tokRAVClose:
discard p.advance()
if ravDepth > 0:
dec ravDepth
of tokNAG:
discard p.advance() # skip NAGs
else:
discard p.advance()
result.result = "*" # unterminated
proc parsePGN*(input: string): seq[PGNGame] =
## Parses one or more PGN games from a string
var p = PGNParser(input: input, pos: 0)
p.tokenize()
while p.peek().kind != tokEOF:
# Skip any non-tag tokens between games
while p.peek().kind notin {tokTagOpen, tokEOF}:
discard p.advance()
if p.peek().kind == tokEOF:
break
var game: PGNGame
# Parse tags
game.tags = p.parseTags()
# Check for FEN tag
for tag in game.tags:
if tag.name.toLowerAscii() == "fen":
game.startFEN = tag.value
# Create starting board
let startBoard = if game.startFEN.len > 0:
newChessboardFromFEN(game.startFEN)
else:
newDefaultChessboard()
# Parse movetext
let (moves, sans, comments, gameResult) = p.parseMovetext(startBoard)
game.moves = moves
game.sanMoves = sans
game.comments = comments
game.result = gameResult
result.add(game)
proc getTag*(game: PGNGame, name: string): string =
for tag in game.tags:
if tag.name.toLowerAscii() == name.toLowerAscii():
return tag.value
return ""

219
src/heimdall/tui/pixel.nim Normal file
View File

@@ -0,0 +1,219 @@
# Copyright 2025 Mattia Giambirtone & All Contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
## Pixel buffer and pre-rendered chess piece/board assets
##
## Piece images: Sashite Chess Assets (CC0 1.0 Universal)
## https://sashite.dev/assets/chess/
import heimdall/pieces
type
Color* = object
r*, g*, b*, a*: uint8
PixelBuffer* = object
width*, height*: int
data*: seq[uint8] # RGBA interleaved
const
CELL_PX* = 120 # pixels per board square
BOARD_PX* = CELL_PX * 8 # 960
# Highlight overlay colors (applied to square backgrounds)
SELECTED_TINT* = Color(r: 80, g: 200, b: 80, a: 130)
LAST_MOVE_TINT* = Color(r: 200, g: 210, b: 80, a: 100)
PREMOVE_TINT* = Color(r: 80, g: 170, b: 240, a: 110)
LEGAL_DEST_TINT* = Color(r: 50, g: 50, b: 50, a: 120)
CHECK_TINT* = Color(r: 240, g: 60, b: 60, a: 140)
THREATENED_TINT* = Color(r: 230, g: 40, b: 40, a: 150)
PREMOVE_TINTS* = [
Color(r: 80, g: 170, b: 240, a: 110),
Color(r: 80, g: 200, b: 80, a: 110),
Color(r: 235, g: 90, b: 90, a: 110),
Color(r: 245, g: 165, b: 70, a: 110),
Color(r: 180, g: 110, b: 235, a: 110),
Color(r: 80, g: 200, b: 185, a: 110)
]
# Embedded raw RGBA data (loaded at compile time)
BOARD_WHITE_DATA = staticRead("../resources/pieces/rgba/board_white.rgba")
BOARD_BLACK_DATA = staticRead("../resources/pieces/rgba/board_black.rgba")
W_KING_DATA = staticRead("../resources/pieces/rgba/w_king.rgba")
W_QUEEN_DATA = staticRead("../resources/pieces/rgba/w_queen.rgba")
W_ROOK_DATA = staticRead("../resources/pieces/rgba/w_rook.rgba")
W_BISHOP_DATA = staticRead("../resources/pieces/rgba/w_bishop.rgba")
W_KNIGHT_DATA = staticRead("../resources/pieces/rgba/w_knight.rgba")
W_PAWN_DATA = staticRead("../resources/pieces/rgba/w_pawn.rgba")
B_KING_DATA = staticRead("../resources/pieces/rgba/b_king.rgba")
B_QUEEN_DATA = staticRead("../resources/pieces/rgba/b_queen.rgba")
B_ROOK_DATA = staticRead("../resources/pieces/rgba/b_rook.rgba")
B_BISHOP_DATA = staticRead("../resources/pieces/rgba/b_bishop.rgba")
B_KNIGHT_DATA = staticRead("../resources/pieces/rgba/b_knight.rgba")
B_PAWN_DATA = staticRead("../resources/pieces/rgba/b_pawn.rgba")
proc newPixelBuffer*(w, h: int): PixelBuffer =
result.width = w
result.height = h
result.data = newSeq[uint8](w * h * 4)
proc fromRawRGBA*(data: string, w, h: int): PixelBuffer =
## Creates a pixel buffer from raw RGBA string data
result.width = w
result.height = h
result.data = newSeq[uint8](data.len)
copyMem(addr result.data[0], unsafeAddr data[0], data.len)
proc setPixel*(buf: var PixelBuffer, x, y: int, c: Color) {.inline.} =
if x >= 0 and x < buf.width and y >= 0 and y < buf.height:
let i = (y * buf.width + x) * 4
buf.data[i] = c.r
buf.data[i + 1] = c.g
buf.data[i + 2] = c.b
buf.data[i + 3] = c.a
proc getPixel*(buf: PixelBuffer, x, y: int): Color {.inline.} =
if x >= 0 and x < buf.width and y >= 0 and y < buf.height:
let i = (y * buf.width + x) * 4
result = Color(r: buf.data[i], g: buf.data[i+1], b: buf.data[i+2], a: buf.data[i+3])
proc premoveTint*(index: int): Color {.inline.} =
PREMOVE_TINTS[index mod PREMOVE_TINTS.len]
proc blendOver*(dst: var PixelBuffer, src: PixelBuffer, ox, oy: int) =
## Alpha-composites src onto dst at offset (ox, oy)
for sy in 0..<src.height:
let dy = oy + sy
if dy < 0 or dy >= dst.height: continue
for sx in 0..<src.width:
let dx = ox + sx
if dx < 0 or dx >= dst.width: continue
let si = (sy * src.width + sx) * 4
let sa = src.data[si + 3].uint16
if sa == 0: continue
let di = (dy * dst.width + dx) * 4
if sa == 255:
dst.data[di] = src.data[si]
dst.data[di + 1] = src.data[si + 1]
dst.data[di + 2] = src.data[si + 2]
dst.data[di + 3] = 255
else:
let da = dst.data[di + 3].uint16
let outA = sa + da * (255 - sa) div 255
if outA == 0: continue
dst.data[di] = uint8((src.data[si].uint16 * sa + dst.data[di].uint16 * da * (255 - sa) div 255) div outA)
dst.data[di + 1] = uint8((src.data[si+1].uint16 * sa + dst.data[di+1].uint16 * da * (255 - sa) div 255) div outA)
dst.data[di + 2] = uint8((src.data[si+2].uint16 * sa + dst.data[di+2].uint16 * da * (255 - sa) div 255) div outA)
dst.data[di + 3] = uint8(outA)
proc blendOverScaled*(dst: var PixelBuffer, src: PixelBuffer, ox, oy, dw, dh: int) =
## Alpha-composites src onto dst at offset (ox, oy), scaled to dw×dh
if src.width == 0 or src.height == 0: return
for dy in 0..<dh:
let ty = oy + dy
if ty < 0 or ty >= dst.height: continue
let sy = (dy * src.height) div dh
for dx in 0..<dw:
let tx = ox + dx
if tx < 0 or tx >= dst.width: continue
let sx = (dx * src.width) div dw
let si = (sy * src.width + sx) * 4
let sa = src.data[si + 3].uint16
if sa == 0: continue
let di = (ty * dst.width + tx) * 4
if sa == 255:
dst.data[di] = src.data[si]
dst.data[di + 1] = src.data[si + 1]
dst.data[di + 2] = src.data[si + 2]
dst.data[di + 3] = 255
else:
let da = dst.data[di + 3].uint16
let outA = sa + da * (255 - sa) div 255
if outA == 0: continue
dst.data[di] = uint8((src.data[si].uint16 * sa + dst.data[di].uint16 * da * (255 - sa) div 255) div outA)
dst.data[di + 1] = uint8((src.data[si+1].uint16 * sa + dst.data[di+1].uint16 * da * (255 - sa) div 255) div outA)
dst.data[di + 2] = uint8((src.data[si+2].uint16 * sa + dst.data[di+2].uint16 * da * (255 - sa) div 255) div outA)
dst.data[di + 3] = uint8(outA)
proc tintRect*(buf: var PixelBuffer, x1, y1, x2, y2: int, tint: Color) =
## Applies a semi-transparent color tint over a rectangular region
for y in max(0, y1)..min(buf.height - 1, y2):
for x in max(0, x1)..min(buf.width - 1, x2):
let i = (y * buf.width + x) * 4
let sa = tint.a.uint16
let da = 255'u16 - sa
buf.data[i] = uint8((buf.data[i].uint16 * da + tint.r.uint16 * sa) div 255)
buf.data[i + 1] = uint8((buf.data[i+1].uint16 * da + tint.g.uint16 * sa) div 255)
buf.data[i + 2] = uint8((buf.data[i+2].uint16 * da + tint.b.uint16 * sa) div 255)
proc fillCircle*(buf: var PixelBuffer, cx, cy, r: int, c: Color) =
let r2 = r * r
for y in max(0, cy - r)..min(buf.height - 1, cy + r):
for x in max(0, cx - r)..min(buf.width - 1, cx + r):
if (x - cx) * (x - cx) + (y - cy) * (y - cy) <= r2:
buf.setPixel(x, y, c)
# --- Asset access ---
proc getBoardImage*(flipped: bool): PixelBuffer =
if flipped:
fromRawRGBA(BOARD_BLACK_DATA, BOARD_PX, BOARD_PX)
else:
fromRawRGBA(BOARD_WHITE_DATA, BOARD_PX, BOARD_PX)
proc getPieceImage*(piece: Piece): PixelBuffer =
let data = case piece.color:
of White:
case piece.kind:
of King: W_KING_DATA
of Queen: W_QUEEN_DATA
of Rook: W_ROOK_DATA
of Bishop: W_BISHOP_DATA
of Knight: W_KNIGHT_DATA
of Pawn: W_PAWN_DATA
of Empty: ""
of Black:
case piece.kind:
of King: B_KING_DATA
of Queen: B_QUEEN_DATA
of Rook: B_ROOK_DATA
of Bishop: B_BISHOP_DATA
of Knight: B_KNIGHT_DATA
of Pawn: B_PAWN_DATA
of Empty: ""
of None: ""
if data.len > 0:
fromRawRGBA(data, CELL_PX, CELL_PX)
else:
newPixelBuffer(0, 0)

877
src/heimdall/tui/play.nim Normal file
View File

@@ -0,0 +1,877 @@
# Copyright 2025 Mattia Giambirtone & All Contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
## Play mode: play against the engine with clocks
import std/[options, random, atomics, strutils, strformat, parseutils]
import heimdall/[board, moves, pieces, movegen, position, search, transpositions, eval]
import heimdall/util/[limits, scharnagl]
import heimdall/tui/[state, clock, san, analysis]
proc beginGame(state: AppState)
proc startEngineTurn*(state: AppState)
proc onPlayerMove*(state: AppState, clearQueuedPremoves = true)
proc resolvePendingPremove(state: AppState): bool =
if state.pendingPremoves.len == 0:
return false
let premove = state.pendingPremoves[0]
state.pendingPremoves.delete(0)
var moves = newMoveList()
state.board.generateMoves(moves)
var foundMove = nullMove()
var isPromotion = false
for move in moves:
if move.startSquare() == premove.fromSq and move.targetSquare() == premove.toSq:
if move.isPromotion():
isPromotion = true
if state.autoQueen and move.flag().promotionToPiece() == Queen:
foundMove = move
break
else:
foundMove = move
break
if foundMove == nullMove() and not isPromotion:
state.clearPremoves()
state.setStatus(&"Premove canceled: {premove.fromSq.toUCI()}{premove.toSq.toUCI()}")
return false
if isPromotion and not state.autoQueen:
state.promotionPending = true
state.promotionFrom = premove.fromSq
state.promotionTo = premove.toSq
state.setStatus("Premove ready: choose [Q]ueen / [R]ook / [B]ishop / [N]knight")
return true
if foundMove == nullMove():
state.clearPremoves()
state.setStatus(&"Premove canceled: {premove.fromSq.toUCI()}{premove.toSq.toUCI()}")
return false
let sanStr = state.board.toSAN(foundMove)
state.lastMove = some((fromSq: foundMove.startSquare(), toSq: foundMove.targetSquare()))
let applied = state.board.makeMove(foundMove)
if applied == nullMove():
state.clearPremoves()
state.setStatus(&"Premove canceled: {premove.fromSq.toUCI()}{premove.toSq.toUCI()}")
return false
state.moveHistory.add(foundMove)
state.sanHistory.add(sanStr)
state.undoneHistory = @[]
stdout.write("\a")
stdout.flushFile()
onPlayerMove(state, clearQueuedPremoves=false)
return true
proc startPlayMode*(state: AppState) =
## Enters play mode setup. The actual setup is driven by
## user input processed in handlePlaySetup.
if state.analysisRunning:
stopAnalysis(state)
state.mode = ModePlay
state.boardSetupMode = false
state.boardSetupSpawnPiece = none(Piece)
state.pendingPremoves = @[]
state.playPhase = Setup
state.setupStep = ChooseVariant
state.gameResult = none(string)
state.setStatus("Choose variant: [S]tandard / [f]rc / [d]frc / [c]urrent", persistent=true)
proc setupVariant(state: AppState, input: string) =
case input.toLowerAscii()
of "s", "standard", "":
# Default: standard
state.variant = Standard
state.chess960 = false
state.searcher.state.chess960.store(false, moRelaxed)
state.board = newDefaultChessboard()
of "f", "frc":
state.variant = FischerRandom
state.chess960 = true
state.searcher.state.chess960.store(true, moRelaxed)
let n = rand(959)
state.board = newChessboardFromFEN(scharnaglToFEN(n))
state.setStatus(&"FRC position #{n}")
of "d", "dfrc":
state.variant = DoubleFischerRandom
state.chess960 = true
state.searcher.state.chess960.store(true, moRelaxed)
let w = rand(959)
let b = rand(959)
state.board = newChessboardFromFEN(scharnaglToFEN(w, b))
state.setStatus(&"DFRC position W:{w} B:{b}")
of "c", "current":
# Keep the current board position as-is
discard
else:
state.setStatus("Choose variant: [S]tandard / [f]rc / [d]frc / [c]urrent", persistent=true)
return
state.moveHistory = @[]
state.sanHistory = @[]
state.lastMove = none(tuple[fromSq, toSq: Square])
if state.watchMode:
state.playerColor = White # White = playerClock, Black = engineClock
state.setupStep = ChooseWatchSeparate
state.setStatus("Configure engines separately? [y]es / [N]o", persistent=true)
else:
state.setupStep = ChooseSide
state.setStatus("Play as: [w]hite / [b]lack / [R]andom", persistent=true)
proc setupSide(state: AppState, input: string) =
case input.toLowerAscii()
of "w", "white":
state.playerColor = White
of "b", "black":
state.playerColor = Black
of "r", "random", "":
# Default: random
state.playerColor = if rand(1) == 0: White else: Black
else:
state.setStatus("Play as: [w]hite / [b]lack / [R]andom", persistent=true)
return
# Flip board to match player's perspective
state.flipped = state.playerColor == Black
state.setupStep = ChoosePlayerTime
state.setStatus("Your time control (e.g. 5m+3s, 10m, 1h+30s, none):", persistent=true)
proc setupPlayerTime(state: AppState, input: string) =
let (timeMs, incMs, ok) = parseTimeControl(input)
if not ok:
state.setStatus("Invalid time control. Examples: 5m+3s, 10m, 90s, none", persistent=true)
return
if timeMs == 0:
state.playerClock = newClock(int64.high div 2, 0) # effectively infinite
else:
state.playerClock = newClock(timeMs, incMs)
state.setupStep = ChooseEngineTime
state.setStatus("Engine time control (e.g. 5m+3s, same, depth 20):", persistent=true)
proc setupEngineTime(state: AppState, input: string) =
let stripped = input.strip().toLowerAscii()
if stripped == "same" and not state.watchMode:
state.engineClock = state.playerClock
elif stripped == "same":
state.setStatus("No player time to copy. Enter a time control:", persistent=true)
return
elif stripped.startsWith("depth"):
let parts = stripped.splitWhitespace()
if parts.len >= 2:
try:
state.engineDepth = some(parseInt(parts[1]))
state.engineClock = newClock(int64.high div 2, 0)
except ValueError:
state.setStatus("Invalid depth. Examples: depth 20, same, 5m+3s", persistent=true)
return
else:
state.setStatus("Usage: depth <number>")
return
else:
let (timeMs, incMs, ok) = parseTimeControl(stripped)
if not ok:
state.setStatus("Invalid time control. Examples: same, depth 20, 5m+3s", persistent=true)
return
if timeMs == 0:
state.engineClock = newClock(int64.high div 2, 0)
else:
state.engineClock = newClock(timeMs, incMs)
# In watch mode, both sides use the same time control
if state.watchMode:
state.playerClock = state.engineClock
state.allowTakeback = false
state.setupStep = ChooseWatchThreads
state.setStatus(&"Threads (shared, current: {state.engineThreads}, Enter to keep):", persistent=true)
else:
state.setupStep = ChooseTakeback
state.setStatus("Allow takeback? [y]es / [N]o", persistent=true)
proc setupWatchSeparate(state: AppState, input: string) =
case input.toLowerAscii()
of "y", "yes":
state.watchSeparateConfig = true
state.setupStep = ChooseWatchWhiteTime
state.setStatus("White engine time control (e.g. 5m+3s, depth 20, none):", persistent=true)
of "n", "no", "":
state.watchSeparateConfig = false
state.setupStep = ChooseEngineTime
state.setStatus("Time control for both engines (e.g. 5m+3s, depth 20, none):", persistent=true)
else:
state.setStatus("Configure engines separately? [y]es / [N]o", persistent=true)
proc setupWatchWhiteTime(state: AppState, input: string) =
let stripped = input.strip().toLowerAscii()
if stripped.startsWith("depth"):
let parts = stripped.splitWhitespace()
if parts.len >= 2:
try:
state.engineDepth = some(parseInt(parts[1]))
state.playerClock = newClock(int64.high div 2, 0)
except ValueError:
state.setStatus("Invalid depth. Examples: depth 20, 5m+3s", persistent=true)
return
else:
state.setStatus("Usage: depth <number>", persistent=true)
return
else:
let (timeMs, incMs, ok) = parseTimeControl(stripped)
if not ok:
state.setStatus("Invalid time control. Examples: 5m+3s, depth 20, none", persistent=true)
return
if timeMs == 0:
state.playerClock = newClock(int64.high div 2, 0)
else:
state.playerClock = newClock(timeMs, incMs)
state.setupStep = ChooseWatchBlackTime
state.setStatus("Black engine time control (e.g. 5m+3s, depth 20, same):", persistent=true)
proc setupWatchBlackTime(state: AppState, input: string) =
let stripped = input.strip().toLowerAscii()
if stripped == "same":
state.engineClock = state.playerClock
elif stripped.startsWith("depth"):
let parts = stripped.splitWhitespace()
if parts.len >= 2:
try:
# Note: depth limit applies to both sides since there's one engine
state.engineDepth = some(parseInt(parts[1]))
state.engineClock = newClock(int64.high div 2, 0)
except ValueError:
state.setStatus("Invalid depth. Examples: depth 20, same, 5m+3s", persistent=true)
return
else:
state.setStatus("Usage: depth <number>", persistent=true)
return
else:
let (timeMs, incMs, ok) = parseTimeControl(stripped)
if not ok:
state.setStatus("Invalid time control. Examples: same, depth 20, 5m+3s", persistent=true)
return
if timeMs == 0:
state.engineClock = newClock(int64.high div 2, 0)
else:
state.engineClock = newClock(timeMs, incMs)
state.allowTakeback = false
state.setupStep = ChooseWatchThreads
state.setStatus(&"Threads (shared, current: {state.engineThreads}, Enter to keep):", persistent=true)
proc setupWatchThreads(state: AppState, input: string) =
let stripped = input.strip()
if stripped.len > 0:
try:
let n = parseInt(stripped)
if n < 1 or n > 1024:
state.setStatus("Threads must be 1-1024:", persistent=true)
return
state.engineThreads = n
state.searcher.setWorkerCount(n - 1)
except ValueError:
state.setStatus("Invalid number. Enter thread count:", persistent=true)
return
state.setupStep = ChooseWatchHash
state.setStatus(&"Hash size (shared, current: {state.engineHash} MiB, Enter to keep):", persistent=true)
proc parseHashInput(input: string): tuple[sizeMiB: int64, ok: bool] =
let stripped = input.strip()
if stripped.len == 0:
return (0'i64, true) # keep current
try:
let n = parseBiggestInt(stripped)
if n < 1 or n > 33554432:
return (0'i64, false)
return (n, true)
except ValueError:
var sizeBytes: int64
let consumed = parseSize(stripped, sizeBytes)
if consumed == 0:
return (0'i64, false)
let sizeMiB = sizeBytes div (1024 * 1024)
if sizeMiB < 1 or sizeMiB > 33554432:
return (0'i64, false)
return (sizeMiB, true)
proc setupWatchHash(state: AppState, input: string) =
let (sizeMiB, ok) = parseHashInput(input)
if not ok:
state.setStatus("Invalid size. Examples: 64, 1 GB, 256 MiB:", persistent=true)
return
if sizeMiB > 0:
state.engineHash = sizeMiB.uint64
state.ttable.resize(sizeMiB.uint64 * 1024 * 1024)
if state.watchSeparateConfig:
# Ask for Black's settings separately
state.watchThreads = state.engineThreads # default = same as White
state.watchHash = state.engineHash
state.setupStep = ChooseWatchBlackThreads
state.setStatus(&"Black engine threads (Enter = same as White: {state.engineThreads}):", persistent=true)
else:
# Same config for both - Black copies White's settings
state.watchThreads = state.engineThreads
state.watchHash = state.engineHash
state.setupStep = ChooseWatchPonder
state.setStatus("Enable pondering for both engines? [y]es / [N]o", persistent=true)
proc setupWatchBlackThreads(state: AppState, input: string) =
let stripped = input.strip()
if stripped.len > 0:
try:
let n = parseInt(stripped)
if n < 1 or n > 1024:
state.setStatus("Threads must be 1-1024:", persistent=true)
return
state.watchThreads = n
except ValueError:
state.setStatus("Invalid number:", persistent=true)
return
state.setupStep = ChooseWatchBlackHash
state.setStatus(&"Black engine hash (Enter = same as White: {state.engineHash} MiB):", persistent=true)
proc setupWatchBlackHash(state: AppState, input: string) =
let (sizeMiB, ok) = parseHashInput(input)
if not ok:
state.setStatus("Invalid size. Examples: 64, 1 GB, 256 MiB:", persistent=true)
return
if sizeMiB > 0:
state.watchHash = sizeMiB.uint64
state.setupStep = ChooseWatchWhitePonder
state.setStatus("White engine pondering? [y]es / [N]o", persistent=true)
proc setupWatchPonder(state: AppState, input: string) =
## Shared ponder setting for both engines
case input.toLowerAscii()
of "y", "yes":
state.allowPonder = true
state.watchPonder = true
of "n", "no", "":
state.allowPonder = false
state.watchPonder = false
else:
state.setStatus("Enable pondering for both engines? [y]es / [N]o", persistent=true)
return
beginGame(state)
proc setupWatchWhitePonder(state: AppState, input: string) =
case input.toLowerAscii()
of "y", "yes":
state.allowPonder = true
of "n", "no", "":
state.allowPonder = false
else:
state.setStatus("White engine pondering? [y]es / [N]o", persistent=true)
return
state.setupStep = ChooseWatchBlackPonder
state.setStatus("Black engine pondering? [y]es / [N]o", persistent=true)
proc setupWatchBlackPonder(state: AppState, input: string) =
case input.toLowerAscii()
of "y", "yes":
state.watchPonder = true
of "n", "no", "":
state.watchPonder = false
else:
state.setStatus("Black engine pondering? [y]es / [N]o", persistent=true)
return
beginGame(state)
proc setupTakeback(state: AppState, input: string) =
case input.toLowerAscii()
of "y", "yes":
state.allowTakeback = true
of "n", "no", "":
state.allowTakeback = false
else:
state.setStatus("Allow takeback? [y]es / [N]o", persistent=true)
return
state.setupStep = ChoosePonder
state.setStatus("Enable pondering? [y]es / [N]o", persistent=true)
proc setupPonder(state: AppState, input: string) =
case input.toLowerAscii()
of "y", "yes":
state.allowPonder = true
of "n", "no", "":
state.allowPonder = false
else:
state.setStatus("Enable pondering? [y]es / [N]o", persistent=true)
return
beginGame(state)
proc beginGame(state: AppState) =
## Transitions from setup to active game
# Clear primary engine state
state.ttable.init()
state.searcher.histories.clear()
state.searcher.resetWorkers()
state.pendingPremoves = @[]
# Initialize second engine for watch mode (independent instance)
if state.watchMode:
if state.watchTtable != nil:
dealloc(state.watchTtable)
state.watchTtable = create(TranspositionTable)
state.watchTtable[] = newTranspositionTable(state.watchHash * 1024 * 1024)
state.watchSearcher = newSearchManager(state.board.positions, state.watchTtable, evalState=newEvalState(verbose=false))
if state.watchThreads > 1:
state.watchSearcher.setWorkerCount(state.watchThreads - 1)
state.watchInitialized = true
startWatchWorker(state)
# Record game info for display
state.gameStartFEN = state.board.toFEN()
# Build time control description
proc fmtClock(c: ChessClock): string =
if c.remainingMs >= int64.high div 4:
return "unlimited"
let mins = c.remainingMs div 60_000
let secs = (c.remainingMs mod 60_000) div 1000
let incSecs = c.incrementMs div 1000
if incSecs > 0:
return &"{mins}m+{incSecs}s"
else:
return &"{mins}m{secs}s"
if state.watchMode:
state.gameTimeControl = "Engine vs Engine: " & fmtClock(state.engineClock)
elif state.engineDepth.isSome():
state.gameTimeControl = fmtClock(state.playerClock) & " vs depth " & $state.engineDepth.get()
elif state.playerClock.remainingMs == state.engineClock.remainingMs and
state.playerClock.incrementMs == state.engineClock.incrementMs:
state.gameTimeControl = fmtClock(state.playerClock)
else:
state.gameTimeControl = fmtClock(state.playerClock) & " vs " & fmtClock(state.engineClock)
if state.watchMode:
# Engine vs Engine: always engine turn
state.playPhase = EngineTurn
state.engineClock.start()
startEngineTurn(state)
else:
state.playPhase = if state.board.sideToMove() == state.playerColor: PlayerTurn else: EngineTurn
if state.playPhase == PlayerTurn:
state.playerClock.start()
state.setStatus("Your turn!")
else:
state.engineClock.start()
startEngineTurn(state)
proc handlePlaySetup*(state: AppState, input: string) =
## Processes user input during play mode setup
case state.setupStep
of ChooseVariant:
setupVariant(state, input)
of ChooseSide:
setupSide(state, input)
of ChoosePlayerTime:
setupPlayerTime(state, input)
of ChooseEngineTime:
setupEngineTime(state, input)
of ChooseTakeback:
setupTakeback(state, input)
of ChoosePonder:
setupPonder(state, input)
of ChooseWatchSeparate:
setupWatchSeparate(state, input)
of ChooseWatchWhiteTime:
setupWatchWhiteTime(state, input)
of ChooseWatchBlackTime:
setupWatchBlackTime(state, input)
of ChooseWatchThreads:
setupWatchThreads(state, input)
of ChooseWatchHash:
setupWatchHash(state, input)
of ChooseWatchBlackThreads:
setupWatchBlackThreads(state, input)
of ChooseWatchBlackHash:
setupWatchBlackHash(state, input)
of ChooseWatchPonder:
setupWatchPonder(state, input)
of ChooseWatchWhitePonder:
setupWatchWhitePonder(state, input)
of ChooseWatchBlackPonder:
setupWatchBlackPonder(state, input)
proc checkGameOver*(state: AppState): bool =
## Checks if the game is over and sets gameResult if so.
## Returns true if the game ended.
if state.gameResult.isSome():
return true
# Check clocks
if state.playerClock.expired:
let winner = if state.playerColor == White: "0-1" else: "1-0"
state.gameResult = some(&"{winner} (time)")
state.playPhase = GameOver
state.playerClock.stop()
state.engineClock.stop()
state.setStatus(&"Time forfeit! {winner}")
return true
if state.engineClock.expired:
let winner = if state.playerColor == White: "1-0" else: "0-1"
state.gameResult = some(&"{winner} (time)")
state.playPhase = GameOver
state.playerClock.stop()
state.engineClock.stop()
state.setStatus(&"Engine flagged! {winner}")
return true
# Check position-based endings
var moves = newMoveList()
state.board.generateMoves(moves)
if moves.len == 0:
if state.board.inCheck():
let winner = if state.board.sideToMove() == White: "0-1" else: "1-0"
state.gameResult = some(&"{winner} (checkmate)")
state.playPhase = GameOver
state.playerClock.stop()
state.engineClock.stop()
state.setStatus(&"Checkmate! {winner}")
else:
state.gameResult = some("1/2-1/2 (stalemate)")
state.playPhase = GameOver
state.playerClock.stop()
state.engineClock.stop()
state.setStatus("Stalemate! Draw")
return true
if state.board.isInsufficientMaterial():
state.gameResult = some("1/2-1/2 (insufficient material)")
state.playPhase = GameOver
state.playerClock.stop()
state.engineClock.stop()
state.setStatus("Draw by insufficient material")
return true
if state.board.halfMoveClock() >= 100:
state.gameResult = some("1/2-1/2 (50-move rule)")
state.playPhase = GameOver
state.playerClock.stop()
state.engineClock.stop()
state.setStatus("Draw by 50-move rule")
return true
if state.board.drawnByRepetition(0):
state.gameResult = some("1/2-1/2 (repetition)")
state.playPhase = GameOver
state.playerClock.stop()
state.engineClock.stop()
state.setStatus("Draw by repetition")
return true
return false
proc startEngineTurn*(state: AppState) =
## Starts the engine's search for its move
state.engineThinking = true
var positions: seq[Position]
for pos in state.board.positions:
positions.add(pos.clone())
# In watch mode, determine which engine plays this move
let isBlackTurn = state.board.sideToMove() == Black
let useSecond = state.watchMode and state.watchInitialized and isBlackTurn
# Start the right clock and pick limits
let depthLimit = if useSecond: state.watchDepth else: state.engineDepth
var engineLimits: seq[SearchLimit]
if useSecond:
state.engineClock.start()
if depthLimit.isSome():
engineLimits.add(newDepthLimit(depthLimit.get()))
elif state.engineClock.remainingMs < int64.high div 2:
engineLimits.add(newTimeLimit(state.engineClock.remainingMs, state.engineClock.incrementMs, 250))
elif state.watchMode:
state.playerClock.start()
if depthLimit.isSome():
engineLimits.add(newDepthLimit(depthLimit.get()))
elif state.playerClock.remainingMs < int64.high div 2:
engineLimits.add(newTimeLimit(state.playerClock.remainingMs, state.playerClock.incrementMs, 250))
else:
state.engineClock.start()
if depthLimit.isSome():
engineLimits.add(newDepthLimit(depthLimit.get()))
elif state.engineClock.remainingMs < int64.high div 2:
engineLimits.add(newTimeLimit(state.engineClock.remainingMs, state.engineClock.incrementMs, 250))
let cmd = SearchCommand(
kind: StartEngineMove,
enginePositions: positions,
engineLimits: engineLimits
)
if useSecond:
state.watchChannels.command.send(cmd)
else:
state.channels.command.send(cmd)
proc onEngineMoveComplete*(state: AppState) =
## Called when the engine's search finishes
state.engineThinking = false
# Determine which engine just moved (the side that was to move BEFORE the search)
# Since the search just finished, sideToMove is still the side that searched
let wasBlack = state.board.sideToMove() == Black
let usedSecond = state.watchMode and state.watchInitialized and wasBlack
# Press the correct clock
if usedSecond:
state.engineClock.press()
elif state.watchMode:
state.playerClock.press()
else:
state.engineClock.press()
# Get the best move from the correct engine's statistics
let stats = if usedSecond: state.watchSearcher.statistics
else: state.searcher.statistics
let bestMove = stats.bestMove.load(moRelaxed)
if bestMove == nullMove():
state.setStatus("Engine couldn't find a move!")
state.playPhase = GameOver
return
# Handle opponent's ponder in watch mode
if state.watchMode:
if usedSecond and state.isPondering:
# Black just moved, White was pondering
if bestMove == state.ponderMove:
state.searcher.stopPondering() # ponderhit!
else:
state.searcher.cancel()
discard state.channels.response.recv()
state.isPondering = false
elif not usedSecond and state.isWatchPondering:
# White just moved, Black was pondering
if bestMove == state.watchPonderMove:
state.watchSearcher.stopPondering() # ponderhit!
else:
state.watchSearcher.cancel()
discard state.watchChannels.response.recv()
state.isWatchPondering = false
# Record SAN before making the move
let sanStr = state.board.toSAN(bestMove)
state.lastMove = some((fromSq: bestMove.startSquare(), toSq: bestMove.targetSquare()))
let applied = state.board.makeMove(bestMove)
if applied == nullMove():
state.setStatus("Engine made illegal move!")
state.playPhase = GameOver
return
state.moveHistory.add(bestMove)
state.sanHistory.add(sanStr)
# Audible feedback for engine move (disabled in watch mode)
if not state.watchMode:
stdout.write("\a")
stdout.flushFile()
if not checkGameOver(state):
if state.watchMode:
# Engine vs Engine: start the other engine's turn
state.playPhase = EngineTurn
# The engine that just moved can now ponder while the other thinks
let justMovedBlack = usedSecond # Black just moved, White is next
if justMovedBlack and state.watchPonder:
# Black just moved - start Black pondering on White's expected reply
let pvSecond = state.watchSearcher.previousVariations[0].moves[1]
if pvSecond != nullMove():
state.watchPonderMove = pvSecond
var ponderPositions: seq[Position]
for pos in state.board.positions:
ponderPositions.add(pos.clone())
var ponderBoard = newChessboard(ponderPositions)
discard ponderBoard.makeMove(pvSecond)
var finalPositions: seq[Position]
for pos in ponderBoard.positions:
finalPositions.add(pos.clone())
state.watchChannels.command.send(SearchCommand(
kind: StartEngineMove, ponder: true,
enginePositions: finalPositions,
engineLimits: @[newTimeLimit(state.engineClock.remainingMs, state.engineClock.incrementMs, 250)]
))
state.isWatchPondering = true
elif not justMovedBlack and state.allowPonder:
# White just moved - start White pondering on Black's expected reply
let pvSecond = state.searcher.previousVariations[0].moves[1]
if pvSecond != nullMove():
state.ponderMove = pvSecond
var ponderPositions: seq[Position]
for pos in state.board.positions:
ponderPositions.add(pos.clone())
var ponderBoard = newChessboard(ponderPositions)
discard ponderBoard.makeMove(pvSecond)
var finalPositions: seq[Position]
for pos in ponderBoard.positions:
finalPositions.add(pos.clone())
state.channels.command.send(SearchCommand(
kind: StartEngineMove, ponder: true,
enginePositions: finalPositions,
engineLimits: @[newTimeLimit(state.playerClock.remainingMs, state.playerClock.incrementMs, 250)]
))
state.isPondering = true
startEngineTurn(state)
else:
state.playPhase = PlayerTurn
state.playerClock.start()
if resolvePendingPremove(state):
return
state.setStatus(&"Engine played {sanStr}. Your turn!")
# Start pondering if enabled - search on the expected reply
if state.allowPonder:
# The ponder move is the second move in the PV
let ponderMove = stats.variationMoves[0].load(moRelaxed)
# Actually read from previousVariations for the full PV
let pvSecond = state.searcher.previousVariations[0].moves[1]
if pvSecond != nullMove():
state.ponderMove = pvSecond
# Temporarily make the ponder move on a cloned board
var ponderPositions: seq[Position]
for pos in state.board.positions:
ponderPositions.add(pos.clone())
# Make the ponder move on the cloned position stack
var ponderBoard = newChessboard(ponderPositions)
discard ponderBoard.makeMove(pvSecond)
var finalPositions: seq[Position]
for pos in ponderBoard.positions:
finalPositions.add(pos.clone())
let cmd = SearchCommand(
kind: StartEngineMove,
ponder: true,
enginePositions: finalPositions,
engineLimits: @[newTimeLimit(
state.engineClock.remainingMs,
state.engineClock.incrementMs, 250)]
)
state.channels.command.send(cmd)
state.isPondering = true
proc onPlayerMove*(state: AppState, clearQueuedPremoves = true) =
## Called after the player successfully makes a move
if clearQueuedPremoves:
state.pendingPremoves = @[]
state.playerClock.press()
if state.isPondering:
# Check if the player's move matches the ponder move
let playerMove = state.moveHistory[^1]
if playerMove == state.ponderMove:
# Ponderhit! Tell the engine to switch from ponder to real search
state.searcher.stopPondering()
state.isPondering = false
state.engineThinking = true
state.playPhase = EngineTurn
state.engineClock.start()
# The search continues with real time limits
return
else:
# Ponder miss - cancel the ponder search
state.searcher.cancel()
discard state.channels.response.recv()
state.isPondering = false
if not checkGameOver(state):
state.playPhase = EngineTurn
startEngineTurn(state)
proc tickClocks*(state: AppState) =
## Updates running clocks. Called each frame.
if state.mode != ModePlay or state.playPhase in [Setup, GameOver]:
return
state.playerClock.tick()
state.engineClock.tick()
discard checkGameOver(state)
proc exitPlayMode*(state: AppState) =
## Exits play mode back to analysis
if state.isPondering or state.engineThinking:
stopSearch(state)
discard state.channels.response.recv()
state.engineThinking = false
state.isPondering = false
if state.isWatchPondering:
state.watchSearcher.cancel()
discard state.watchChannels.response.recv()
state.isWatchPondering = false
state.playerClock.stop()
state.engineClock.stop()
state.pendingPremoves = @[]
state.mode = ModeAnalysis
state.playPhase = Setup
state.gameResult = none(string)
# Clean up second engine and its worker if initialized
if state.watchInitialized:
# Stop the second worker thread
if state.watchSearcher.isSearching():
state.watchSearcher.cancel()
state.watchChannels.command.send(SearchCommand(kind: Shutdown))
discard state.watchChannels.response.recv()
joinThread(state.watchWorkerThread)
state.watchChannels.command.close()
state.watchChannels.response.close()
state.watchSearcher.shutdownWorkers()
if state.watchTtable != nil:
dealloc(state.watchTtable)
state.watchTtable = nil
state.watchInitialized = false
state.watchMode = false
state.watchSeparateConfig = false
state.setStatus("Exited play mode")

View File

@@ -0,0 +1,266 @@
# Copyright 2025 Mattia Giambirtone & All Contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
## Raw terminal input reader with SGR mouse support.
## Bypasses illwill's getKey() for input to properly handle
## mouse escape sequences that illwill can't parse.
import std/[strutils, os]
from std/posix import STDIN_FILENO, read
from std/termios import Termios, tcGetAttr, tcSetAttr, TCSANOW, ISIG, Cflag
import illwill
type
InputEventKind* = enum
ievNone
ievKey
ievMouse
MouseAction* = enum
maPress
maRelease
maMove
MouseButton* = enum
mbLeft
mbMiddle
mbRight
mbNone
MouseEvent* = object
x*, y*: int
button*: MouseButton
action*: MouseAction
InputEvent* = object
case kind*: InputEventKind
of ievNone: discard
of ievKey:
key*: Key
of ievMouse:
mouse*: MouseEvent
const
# Enable button-event tracking plus pixel-precise SGR coordinates.
SGR_MOUSE_ENABLE* = "\x1b[?1002h\x1b[?1016h"
SGR_MOUSE_DISABLE* = "\x1b[?1002l\x1b[?1016l"
proc disableISIG*() =
## Disables ISIG so Ctrl+C/Ctrl+Z are delivered as bytes
## instead of generating signals. Call after illwillInit.
var ttyState: Termios
discard tcGetAttr(STDIN_FILENO.cint, addr ttyState)
ttyState.c_lflag = ttyState.c_lflag and not ISIG
discard tcSetAttr(STDIN_FILENO.cint, TCSANOW, addr ttyState)
proc enableMouseTracking*() =
stdout.write(SGR_MOUSE_ENABLE)
stdout.flushFile()
proc disableMouseTracking*() =
stdout.write(SGR_MOUSE_DISABLE)
stdout.flushFile()
proc readByte(): int =
## Reads one byte from stdin non-blocking.
## Returns -1 if nothing available (VMIN=0 set by illwill).
var c: char
let n = read(STDIN_FILENO, addr c, 1)
if n <= 0: return -1
return c.int
const
ESC_SEQUENCE_POLL_MS = 25
proc readByteWait(timeoutMs = ESC_SEQUENCE_POLL_MS): int =
## Reads one byte from stdin, spinning briefly for escape sequence continuations.
## Escape sequence bytes can arrive a few milliseconds apart, especially for
## longer mouse packets, so wait a little instead of timing out immediately.
var c: char
for attempt in 0..<max(1, timeoutMs):
let n = read(STDIN_FILENO, addr c, 1)
if n > 0: return c.int
sleep(1)
return -1
proc discardCSISequence() =
## Consumes the rest of an unknown CSI sequence up to its final byte.
while true:
let b = readByteWait()
if b < 0:
return
let c = chr(b)
if c in {'@'..'~'}:
return
proc flushMouseNumber(parts: var seq[int], numBuf: var string): bool =
if numBuf.len == 0 or numBuf == "-":
return false
try:
parts.add(parseInt(numBuf))
numBuf = ""
return true
except ValueError:
return false
proc tryParseSGRMouse(): InputEvent =
## Parses an SGR mouse sequence after \e[< has been consumed.
## Format: btn;x;yM (press) or btn;x;ym (release).
## With pixel mouse mode enabled, x/y are terminal pixel coordinates.
var numBuf = ""
var parts: seq[int]
while true:
let b = readByteWait()
if b < 0:
discardCSISequence()
return InputEvent(kind: ievNone)
let c = chr(b)
if c.isDigit():
numBuf &= c
elif c == '-' and numBuf.len == 0:
numBuf = "-"
elif c == ';':
if not flushMouseNumber(parts, numBuf):
discardCSISequence()
return InputEvent(kind: ievNone)
elif c in {'M', 'm'}:
if not flushMouseNumber(parts, numBuf):
discardCSISequence()
return InputEvent(kind: ievNone)
if parts.len >= 3:
let btnBits = parts[0]
let x = parts[1] - 1 # 1-based to 0-based
let y = parts[2] - 1
let pressed = c == 'M'
let isMove = (btnBits and 32) != 0
let button = case (btnBits and 3)
of 0: mbLeft
of 1: mbMiddle
of 2: mbRight
else: mbNone
let action = if isMove: maMove
elif pressed: maPress
else: maRelease
return InputEvent(kind: ievMouse, mouse: MouseEvent(
x: x, y: y, button: button, action: action
))
return InputEvent(kind: ievNone)
else:
# Unknown char in sequence, discard the rest of the packet so it
# cannot leak into normal text input.
discardCSISequence()
return InputEvent(kind: ievNone)
proc tryParseCSI(): InputEvent =
## Parses a CSI (\e[) sequence
let b = readByteWait()
if b < 0:
return InputEvent(kind: ievKey, key: Key.Escape)
let c = chr(b)
case c
of '<':
return tryParseSGRMouse()
of 'A': return InputEvent(kind: ievKey, key: Key.Up)
of 'B': return InputEvent(kind: ievKey, key: Key.Down)
of 'C': return InputEvent(kind: ievKey, key: Key.Right)
of 'D': return InputEvent(kind: ievKey, key: Key.Left)
of 'H': return InputEvent(kind: ievKey, key: Key.Home)
of 'F': return InputEvent(kind: ievKey, key: Key.End)
of '1':
let b2 = readByteWait()
if b2 < 0: return InputEvent(kind: ievNone)
if chr(b2) == '~':
return InputEvent(kind: ievKey, key: Key.Home)
# Consume rest of unknown sequence
if chr(b2) in {'0'..'9'}:
discard readByteWait() # consume trailing ~
return InputEvent(kind: ievNone)
of '3':
discard readByteWait() # consume ~
return InputEvent(kind: ievKey, key: Key.Delete)
of '4':
discard readByteWait()
return InputEvent(kind: ievKey, key: Key.End)
of '5':
discard readByteWait()
return InputEvent(kind: ievKey, key: Key.PageUp)
of '6':
discard readByteWait()
return InputEvent(kind: ievKey, key: Key.PageDown)
else:
discardCSISequence()
return InputEvent(kind: ievNone)
proc pollInput*(): InputEvent =
## Non-blocking input poll. Returns keyboard or mouse events.
let b = readByte()
if b < 0:
return InputEvent(kind: ievNone)
case b
of 0x1b: # ESC
let b2 = readByteWait()
if b2 < 0:
return InputEvent(kind: ievKey, key: Key.Escape)
case chr(b2)
of '[':
return tryParseCSI()
of 'O':
# SS3 sequences (some terminals use for arrow keys)
let b3 = readByteWait()
if b3 < 0: return InputEvent(kind: ievKey, key: Key.Escape)
case chr(b3)
of 'A': return InputEvent(kind: ievKey, key: Key.Up)
of 'B': return InputEvent(kind: ievKey, key: Key.Down)
of 'C': return InputEvent(kind: ievKey, key: Key.Right)
of 'D': return InputEvent(kind: ievKey, key: Key.Left)
of 'H': return InputEvent(kind: ievKey, key: Key.Home)
of 'F': return InputEvent(kind: ievKey, key: Key.End)
else: return InputEvent(kind: ievNone)
else:
return InputEvent(kind: ievKey, key: Key.Escape)
of 0x0d, 0x0a:
return InputEvent(kind: ievKey, key: Key.Enter)
of 0x7f:
return InputEvent(kind: ievKey, key: Key.Backspace)
of 0x09:
return InputEvent(kind: ievKey, key: Key.Tab)
of 0x01..0x08, 0x0b, 0x0c, 0x0e..0x1a:
# Ctrl+A through Ctrl+Z (excluding Tab=0x09, Enter=0x0d)
{.push warning[HoleEnumConv]:off.}
return InputEvent(kind: ievKey, key: Key(b))
{.pop.}
else:
# Regular printable character or other
if b >= 32 and b <= 126:
{.push warning[HoleEnumConv]:off.}
return InputEvent(kind: ievKey, key: Key(b))
{.pop.}
return InputEvent(kind: ievNone)

View File

@@ -0,0 +1,737 @@
# Copyright 2025 Mattia Giambirtone & All Contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
## Composes the full TUI layout into an illwill TerminalBuffer
import std/[strformat, strutils, monotimes, options]
import illwill
import heimdall/[pieces, board, eval, moves, transpositions]
import heimdall/util/wdl
import heimdall/tui/[state, board_view, clock, input]
const
INFO_PANEL_PREFERRED_WIDTH = 30
proc formatScore(score: Score): string =
if score.isMateScore():
let plies = mateScore() - abs(score)
let moves = (plies + 1) div 2
if score > 0:
return &"M{moves}"
else:
return &"-M{moves}"
else:
let cp = score.float / 100.0
return &"{cp:+.2f}"
proc formatNodes(n: uint64): string =
if n >= 1_000_000_000:
return &"{n.float / 1_000_000_000.0:.1f}G"
elif n >= 1_000_000:
return &"{n.float / 1_000_000.0:.1f}M"
elif n >= 1_000:
return &"{n.float / 1_000.0:.1f}K"
else:
return $n
proc formatNPS(n: uint64): string =
formatNodes(n) & "/s"
proc formatCastling(board: Chessboard, chess960: bool): string =
let
whiteCastle = board.position.castlingAvailability[White]
blackCastle = board.position.castlingAvailability[Black]
if chess960:
# Shredder notation: use rook file letters
if whiteCastle.king != nullSquare():
result &= chr(ord('A') + whiteCastle.king.file().int)
if whiteCastle.queen != nullSquare():
result &= chr(ord('A') + whiteCastle.queen.file().int)
if blackCastle.king != nullSquare():
result &= chr(ord('a') + blackCastle.king.file().int)
if blackCastle.queen != nullSquare():
result &= chr(ord('a') + blackCastle.queen.file().int)
else:
if whiteCastle.king != nullSquare():
result &= "K"
if whiteCastle.queen != nullSquare():
result &= "Q"
if blackCastle.king != nullSquare():
result &= "k"
if blackCastle.queen != nullSquare():
result &= "q"
if result.len == 0:
result = "-"
proc drawInfoPanel(tb: var TerminalBuffer, state: AppState, startX, startY, width, height: int) =
## Draws the engine info panel on the right side
var y = startY
# Title
tb.setForegroundColor(fgCyan, bright=true)
tb.setBackgroundColor(bgNone)
tb.write(startX, y, "Engine Info")
inc y, 2
# Helper: draw a label + value pair
let labelCol = 14 # column width for labels
template infoLine(label: string, value: string) =
let maxVal = width - labelCol - 1
tb.setForegroundColor(fgCyan)
tb.write(startX, y, label)
tb.setForegroundColor(fgWhite, bright=true)
if value.len <= maxVal:
tb.write(startX + labelCol, y, value)
inc y
else:
# Wrap long values across multiple lines
var pos = 0
while pos < value.len:
let chunk = value[pos..<min(pos + maxVal, value.len)]
tb.write(startX + labelCol, y, chunk)
inc y
pos += maxVal
# Position info
let stm = if state.board.sideToMove() == White: "White" else: "Black"
infoLine("Side:", stm)
let castling = formatCastling(state.board, state.chess960)
infoLine("Castling:", castling)
infoLine("Zobrist:", &"{state.board.zobristKey().uint64:#0X}")
let epStr = if state.board.position.enPassantSquare == nullSquare(): "-" else: $state.board.position.enPassantSquare
infoLine("EP:", epStr)
infoLine("50-move:", &"{state.board.halfMoveClock()}/100")
infoLine("Move:", $state.board.position.fullMoveCount)
# FEN (truncated to panel width)
let fen = state.board.toFEN()
let maxFen = width - labelCol - 1
let fenDisplay = if fen.len > maxFen: fen[0..<maxFen-3] & "..." else: fen
tb.setForegroundColor(fgCyan)
tb.write(startX, y, "FEN:")
tb.setForegroundColor(fgWhite)
tb.setStyle({styleDim})
tb.write(startX + labelCol, y, fenDisplay)
tb.setStyle({})
inc y
# Engine settings
infoLine("Threads:", $state.engineThreads)
let hashFill = state.ttable.getFillEstimate()
let hashPct = &"{hashFill.float / 10.0:.1f}%"
infoLine("Hash:", $state.engineHash & " MiB (" & hashPct & " full)")
if state.engineDepth.isSome():
infoLine("Limit:", "depth " & $state.engineDepth.get())
if state.multiPV > 1:
infoLine("MultiPV:", $state.multiPV)
inc y
# Search status
if state.mode == ModePlay and (state.isPondering or state.isWatchPondering):
tb.setForegroundColor(fgMagenta, bright=true)
if state.isPondering and state.isWatchPondering:
tb.write(startX, y, &"[W pondering {state.ponderMove.toUCI()}, B pondering {state.watchPonderMove.toUCI()}]")
elif state.isPondering:
let side = if state.watchMode: "White" else: "Engine"
tb.write(startX, y, &"[{side} PONDERING on {state.ponderMove.toUCI()}]")
else:
tb.write(startX, y, &"[Black PONDERING on {state.watchPonderMove.toUCI()}]")
elif state.mode == ModePlay and state.engineThinking:
tb.setForegroundColor(fgYellow, bright=true)
if state.watchMode:
let side = if state.board.sideToMove() == White: "White" else: "Black"
tb.write(startX, y, &"[{side} THINKING]")
elif state.pendingPremoves.len > 0:
let nextPremove = state.pendingPremoves[0]
if state.pendingPremoves.len == 1:
tb.write(startX, y, &"[ENGINE THINKING | PREMOVE {nextPremove.fromSq.toUCI()}{nextPremove.toSq.toUCI()}]")
else:
tb.write(startX, y, &"[ENGINE THINKING | PREMOVES {state.pendingPremoves.len} | NEXT {nextPremove.fromSq.toUCI()}{nextPremove.toSq.toUCI()}]")
else:
tb.write(startX, y, "[ENGINE THINKING]")
elif state.boardSetupMode:
tb.setForegroundColor(fgCyan, bright=true)
tb.write(startX, y, "[BOARD SETUP]")
elif state.analysisRunning:
tb.setForegroundColor(fgGreen, bright=true)
tb.write(startX, y, "[SEARCHING]")
elif state.mode == ModePlay:
case state.playPhase
of PlayerTurn:
tb.setForegroundColor(fgGreen, bright=true)
tb.write(startX, y, "[YOUR TURN]")
of GameOver:
tb.setForegroundColor(fgRed, bright=true)
tb.write(startX, y, "[GAME OVER]")
of Setup:
tb.setForegroundColor(fgCyan, bright=true)
tb.write(startX, y, "[SETUP]")
else:
tb.setForegroundColor(fgRed)
tb.write(startX, y, "[IDLE]")
else:
tb.setForegroundColor(fgRed)
tb.write(startX, y, "[IDLE]")
inc y
# Indicators (on their own line)
var indicatorX = startX
if state.chess960:
tb.setForegroundColor(fgMagenta, bright=true)
let variantStr = case state.variant
of Standard: ""
of FischerRandom: " [FRC]"
of DoubleFischerRandom: " [DFRC]"
if variantStr.len > 0:
tb.write(indicatorX, y, variantStr)
indicatorX += variantStr.len + 1
if state.showThreats:
tb.setForegroundColor(fgRed, bright=true)
tb.write(indicatorX, y, "[Threats]")
indicatorX += 10
if state.autoQueen:
tb.setForegroundColor(fgYellow, bright=true)
tb.write(indicatorX, y, "[Auto-queen]")
indicatorX += 13
if state.pendingPremoves.len > 0:
let nextPremove = state.pendingPremoves[0]
tb.setForegroundColor(fgBlue, bright=true)
let premoveLabel =
if state.pendingPremoves.len == 1:
&" [Premove {nextPremove.fromSq.toUCI()}{nextPremove.toSq.toUCI()}]"
else:
&" [Premoves {state.pendingPremoves.len}, next {nextPremove.fromSq.toUCI()}{nextPremove.toSq.toUCI()}]"
tb.write(indicatorX, y, premoveLabel)
indicatorX += premoveLabel.len
if state.boardSetupMode and state.boardSetupSpawnPiece.isSome():
let piece = state.boardSetupSpawnPiece.get()
tb.setForegroundColor(fgGreen, bright=true)
tb.write(indicatorX, y, &" [Spawn {piece.toChar()}]")
inc y
if state.boardSetupMode:
tb.setForegroundColor(fgCyan)
tb.write(startX, y, "Setup: drag to move, drop off-board to delete, Esc to apply")
inc y
tb.setForegroundColor(fgWhite)
tb.write(startX, y, "Spawn: p/n/b/r/q/k for black, Shift+key for white")
inc y
inc y
# Move list (PGN-style, wrapped in the panel) - shown right after status
if state.sanHistory.len > 0:
tb.setForegroundColor(fgCyan, bright=true)
tb.write(startX, y, "Moves:")
inc y
var line = ""
var moveNum = 1
for i, san in state.sanHistory:
var token = ""
if i mod 2 == 0:
token = $moveNum & ". " & san
else:
token = san
inc moveNum
if line.len + token.len + 1 > width - 1:
tb.setForegroundColor(fgWhite)
tb.write(startX, y, line)
inc y
if y >= startY + height - 6:
break
line = token & " "
else:
line &= token & " "
if line.len > 0 and y < startY + height - 6:
tb.setForegroundColor(fgWhite)
tb.write(startX, y, line)
inc y
inc y
# Analysis depth/nodes/nps (hidden during play mode)
if state.mode != ModePlay and (state.analysisRunning or state.analysisLines.len > 0):
infoLine("Depth:", $state.analysisDepth)
infoLine("Nodes:", formatNodes(state.analysisNodes))
infoLine("NPS:", formatNPS(state.analysisNPS))
# Eval bar + WDL for the primary line
if state.analysisLines.len > 0 and state.analysisLines[0].pv.len > 0:
let primaryLine = state.analysisLines[0]
let mat = state.board.material()
let wdl = getExpectedWDL(primaryLine.rawScore, mat)
# WDL display (Italian flag colors: green W, white D, red L)
tb.setForegroundColor(fgCyan)
tb.write(startX, y, "WDL:")
tb.setForegroundColor(fgGreen, bright=true)
tb.write(startX + labelCol, y, &"W:{wdl.win div 10}%")
tb.setForegroundColor(fgWhite, bright=true)
tb.write(startX + labelCol + 6, y, &" D:{wdl.draw div 10}%")
tb.setForegroundColor(fgRed, bright=true)
tb.write(startX + labelCol + 13, y, &" L:{wdl.loss div 10}%")
inc y
# Eval bar: visual bar showing white's winning chances
let barWidth = min(width - 2, 30)
let whiteShare = if primaryLine.score.isMateScore():
(if primaryLine.score > 0: barWidth else: 0)
else:
max(0, min(barWidth, (wdl.win + wdl.draw div 2) * barWidth div 1000))
tb.setForegroundColor(fgCyan)
tb.write(startX, y, "Eval:")
var bar = ""
for b in 0..<barWidth:
if b < whiteShare:
bar &= "\xe2\x96\x88" # █ (full block = white's share)
else:
bar &= "\xe2\x96\x91" # ░ (light shade = black's share)
tb.setForegroundColor(fgWhite, bright=true)
tb.write(startX + labelCol, y, bar)
inc y
inc y
# Analysis lines (MultiPV)
if state.analysisLines.len > 0:
tb.setForegroundColor(fgCyan, bright=true)
tb.write(startX, y, "Analysis Lines:")
inc y
let mat = state.board.material()
for i, line in state.analysisLines:
if y >= startY + height - 4:
break
if line.pv.len == 0:
continue
let scoreStr = formatScore(line.score)
let wdl = getExpectedWDL(line.rawScore, mat)
let wdlShort = &"({wdl.win div 10}/{wdl.draw div 10}/{wdl.loss div 10})"
# Show PV moves (as many as fit)
var pvStr = ""
let headerLen = ($(i+1)).len + 2 + scoreStr.len + 1 + wdlShort.len + 1
let maxPVWidth = width - headerLen - 1
for j, move in line.pv:
let moveStr = move.toUCI()
if pvStr.len + moveStr.len + 1 > maxPVWidth:
pvStr &= ".."
break
if j > 0: pvStr &= " "
pvStr &= moveStr
var x = startX
tb.setForegroundColor(fgYellow, bright=true)
tb.write(x, y, &"{i+1}. ")
x += ($(i+1)).len + 2
tb.setForegroundColor(fgWhite, bright=true)
tb.write(x, y, scoreStr & " ")
x += scoreStr.len + 1
let wW = &"{wdl.win div 10}"
let wD = &"{wdl.draw div 10}"
let wL = &"{wdl.loss div 10}"
tb.setForegroundColor(fgGreen)
tb.write(x, y, "(")
x += 1
tb.write(x, y, wW)
x += wW.len
tb.setForegroundColor(fgWhite)
tb.write(x, y, "/")
x += 1
tb.write(x, y, wD)
x += wD.len
tb.setForegroundColor(fgRed)
tb.write(x, y, "/")
x += 1
tb.write(x, y, wL)
x += wL.len
tb.write(x, y, ") ")
x += 2
tb.setForegroundColor(fgWhite)
tb.write(x, y, pvStr)
inc y
# PGN metadata (if in replay mode)
if state.mode == ModeReplay and state.pgnTags.len > 0:
inc y
tb.setForegroundColor(fgCyan, bright=true)
tb.write(startX, y, "PGN Info:")
inc y, 2
# Helper to find a tag value
proc getTag(tags: seq[tuple[name, value: string]], tagName: string): string =
for (n, v) in tags:
if n.toLowerAscii() == tagName.toLowerAscii() and v.len > 0 and v != "?":
return v
return ""
# Players with Elo (name in white, elo in yellow)
for side in ["White", "Black"]:
let name = getTag(state.pgnTags, side)
if name.len > 0:
let elo = getTag(state.pgnTags, side & "Elo")
tb.setForegroundColor(fgCyan)
tb.write(startX, y, side & ":")
tb.setForegroundColor(fgWhite, bright=true)
tb.write(startX + labelCol, y, name)
if elo.len > 0:
tb.setForegroundColor(fgYellow, bright=true)
tb.write(startX + labelCol + name.len, y, " (" & elo & ")")
inc y
# Other tags in blue
const otherTags = ["Event", "Site", "Date", "Round", "Result",
"TimeControl", "ECO", "Opening"]
for tagName in otherTags:
if y >= startY + height - 6:
break
let value = getTag(state.pgnTags, tagName)
if value.len > 0:
let label = case tagName
of "TimeControl": "Time Ctrl:"
of "ECO": "ECO:"
else: tagName & ":"
let maxVal = width - labelCol - 1
let displayVal = if value.len > maxVal: value[0..<maxVal] else: value
tb.setForegroundColor(fgCyan)
tb.write(startX, y, label)
tb.setForegroundColor(fgBlue, bright=true)
tb.write(startX + labelCol, y, displayVal)
inc y
# Move counter
infoLine("Moves:", &"{state.pgnMoveIndex}/{state.pgnMoves.len}")
inc y
# Game info and clocks (if in play mode)
if state.mode == ModePlay and state.playPhase != Setup:
inc y
tb.setForegroundColor(fgCyan, bright=true)
tb.write(startX, y, "Game:")
inc y
# Game details
let variantStr = case state.variant
of Standard: "Standard"
of FischerRandom: "Chess960"
of DoubleFischerRandom: "DFRC"
infoLine("Variant:", variantStr)
infoLine("TC:", state.gameTimeControl)
if not state.watchMode:
let sideStr = if state.playerColor == White: "White" else: "Black"
infoLine("Playing:", sideStr)
if state.allowTakeback:
infoLine("Takeback:", "enabled")
if state.allowPonder or state.watchPonder:
if state.watchMode:
let wStatus = if state.isPondering: &"on {state.ponderMove.toUCI()}"
elif state.allowPonder: "enabled"
else: "off"
let bStatus = if state.isWatchPondering: &"on {state.watchPonderMove.toUCI()}"
elif state.watchPonder: "enabled"
else: "off"
infoLine("W Ponder:", wStatus)
infoLine("B Ponder:", bStatus)
else:
if state.isPondering:
infoLine("Ponder:", &"on {state.ponderMove.toUCI()}")
else:
infoLine("Ponder:", "enabled")
if state.gameResult.isSome():
infoLine("Result:", state.gameResult.get())
inc y
# Clocks
tb.setForegroundColor(fgCyan, bright=true)
tb.write(startX, y, "Clocks:")
inc y
let whiteLabel = if state.watchMode: "Engine" elif state.playerColor == White: "You" else: "Engine"
let blackLabel = if state.watchMode: "Engine" elif state.playerColor == Black: "You" else: "Engine"
let wClock = if state.playerColor == White: state.playerClock else: state.engineClock
let bClock = if state.playerColor == Black: state.playerClock else: state.engineClock
let clockCol = 15
tb.setForegroundColor(fgCyan)
tb.write(startX, y, &"W ({whiteLabel}):")
tb.setForegroundColor(fgWhite, bright=true)
tb.write(startX + clockCol, y, formatTime(wClock))
inc y
tb.setForegroundColor(fgCyan)
tb.write(startX, y, &"B ({blackLabel}):")
tb.setForegroundColor(fgWhite, bright=true)
tb.write(startX + clockCol, y, formatTime(bClock))
inc y
inc y
proc drawInputBar(tb: var TerminalBuffer, state: AppState, startX, startY, width: int) =
## Draws the input bar at the bottom
tb.setForegroundColor(fgYellow, bright=true)
tb.setBackgroundColor(bgNone)
tb.write(startX, startY, "> ")
let maxInputLen = width - 15 # Reserve space for mode indicator
if state.acActive and state.acSelected >= 0 and state.acSelected < state.acSuggestions.len:
let suggestion = ":" & state.acSuggestions[state.acSelected].cmd
if suggestion.startsWith(state.inputBuffer):
# Show typed portion in bright white, ghost remainder in dark gray
let typed = state.inputBuffer
let ghost = suggestion[typed.len..^1]
tb.setForegroundColor(fgWhite, bright=true)
tb.write(startX + 2, startY, typed)
tb.setForegroundColor(fgWhite)
tb.setStyle({styleDim})
tb.write(startX + 2 + typed.len, startY, ghost)
tb.setStyle({})
else:
tb.setForegroundColor(fgWhite)
tb.write(startX + 2, startY, state.inputBuffer)
else:
tb.setForegroundColor(fgWhite)
var displayInput = state.inputBuffer
if displayInput.len > maxInputLen:
displayInput = displayInput[displayInput.len - maxInputLen .. ^1]
tb.write(startX + 2, startY, displayInput)
# Cursor: at end of suggestion when autocomplete is active, else at actual cursor position
var cursorX = startX + 2 + min(state.inputCursorPos, maxInputLen)
if state.acActive and state.acSelected >= 0 and state.acSelected < state.acSuggestions.len:
let suggestion = ":" & state.acSuggestions[state.acSelected].cmd
if suggestion.startsWith(state.inputBuffer):
cursorX = startX + 2 + suggestion.len
tb.setForegroundColor(fgYellow, bright=true)
tb.write(cursorX, startY, "|")
# Mode indicator on the right
let modeStr = case state.mode
of ModeAnalysis:
if state.boardSetupMode: "[Board Setup]"
elif state.analysisRunning: "[Analyzing]" else: "[Analysis]"
of ModePlay:
case state.playPhase
of Setup: "[Setup]"
of PlayerTurn: "[Your Turn]"
of EngineTurn: "[Thinking]"
of GameOver: "[Game Over]"
of ModeReplay: "[Replay]"
tb.setForegroundColor(fgCyan)
tb.write(startX + width - modeStr.len - 1, startY, modeStr)
proc drawHelpBox(tb: var TerminalBuffer, state: AppState, startX, startY, width, height: int) =
## Draws the help overlay in the info panel area
if not state.helpVisible:
return
let lines = buildHelpLines()
let viewportHeight = helpViewportHeight(height)
let maxScroll = max(0, lines.len - viewportHeight)
let scroll = max(0, min(state.helpScroll, maxScroll))
var y = startY
tb.setForegroundColor(fgCyan, bright=true)
tb.setBackgroundColor(bgNone)
let title =
if maxScroll > 0: &"Help [{scroll + 1}-{min(scroll + viewportHeight, lines.len)}/{lines.len}]"
else: "Help"
tb.write(startX, y, title)
inc y
tb.setForegroundColor(fgWhite)
tb.write(startX, y, "-".repeat(min(width, 40)))
inc y, 2
for i in 0..<viewportHeight:
let lineIndex = scroll + i
if lineIndex >= lines.len or y >= startY + height - HELP_VIEW_FOOTER_ROWS:
break
let line = lines[lineIndex]
if line.len == 0:
inc y
continue
if line.endsWith(":"):
tb.setForegroundColor(fgYellow, bright=true)
tb.write(startX, y, line)
else:
tb.setForegroundColor(fgWhite)
let truncLine = if line.len > width - 1: line[0..<max(0, width - 1)] else: line
tb.write(startX + 1, y, truncLine)
inc y
let footerY = startY + height - 1
let footer = "↑/↓ scroll, PgUp/PgDn page, Esc close"
tb.setForegroundColor(fgCyan)
tb.write(startX, footerY, if footer.len > width: footer[0..<width] else: footer)
proc drawAutocomplete(tb: var TerminalBuffer, state: AppState, startX, bottomY, width: int) =
## Draws autocomplete suggestions above the input bar
if not state.acActive or state.acSuggestions.len == 0:
return
let count = min(state.acSuggestions.len, 8) # Max 8 visible suggestions
# Compute description column based on longest command name
var maxCmdLen = 0
for i in 0..<count:
maxCmdLen = max(maxCmdLen, state.acSuggestions[i].cmd.len)
let descCol = maxCmdLen + 6 # 3 for "> :" prefix + 3 padding
for i in 0..<count:
let y = bottomY - count + i
if y < 0: continue
let (cmd, desc) = state.acSuggestions[i]
let isSelected = i == state.acSelected
tb.setBackgroundColor(bgNone)
if isSelected:
tb.setForegroundColor(fgGreen, bright=true)
tb.setStyle({styleBright})
tb.write(startX, y, ">")
tb.write(startX + 1, y, " :" & cmd)
else:
tb.setForegroundColor(fgYellow, bright=true)
tb.write(startX, y, " :" & cmd)
# Description, positioned after the longest command
tb.setStyle({styleBright})
if isSelected:
tb.setForegroundColor(fgWhite, bright=true)
else:
tb.setForegroundColor(fgWhite)
let descX = startX + descCol
let maxDesc = width - descCol - 1
if maxDesc > 0:
let truncDesc = if desc.len > maxDesc: desc[0..<maxDesc] else: desc
tb.write(descX, y, truncDesc)
tb.setStyle({})
tb.setBackgroundColor(bgNone)
proc drawStatusBar(tb: var TerminalBuffer, state: AppState, startX, startY, width: int) =
## Draws the status bar (transient messages)
if state.statusMessage.len > 0 and getMonoTime() < state.statusExpiry:
if state.statusIsError:
tb.setForegroundColor(fgRed, bright=true)
else:
tb.setForegroundColor(fgYellow)
tb.setBackgroundColor(bgNone)
let msg = if state.statusMessage.len > width: state.statusMessage[0..<width] else: state.statusMessage
tb.write(startX, startY, msg)
proc drawTooSmallOverlay(tb: var TerminalBuffer, w, h: int) =
let minimum = minimumTerminalSize()
let lines = [
"Terminal too small for the TUI",
&"Minimum size: {minimum.w}x{minimum.h}",
&"Current size: {w}x{h}",
"Resize the window or reduce the terminal font size"
]
let startY = max(1, h div 2 - lines.len div 2)
for i, line in lines:
let x = max(0, (w - line.len) div 2)
tb.setForegroundColor(if i == 0: fgRed else: fgYellow, bright=true)
tb.setBackgroundColor(bgNone)
tb.write(x, startY + i, line)
var
prevW, prevH: int
persistentTb: TerminalBuffer
proc render*(state: AppState) =
## Renders the complete TUI layout
let
w = terminalWidth()
h = terminalHeight()
# Recreate buffer only on resize; otherwise reuse to avoid
# illwill doing a full redraw that wipes the kitty board image
if persistentTb == nil or w != prevW or h != prevH:
persistentTb = newTerminalBuffer(w, h)
prevW = w
prevH = h
# Force board image retransmit after resize since illwill's
# displayFull will overwrite the board area
resetBoardHash()
var tb = persistentTb
let boardIsVisible = boardVisible()
let boardW = boardWidth()
let boardH = boardHeight()
let infoPanelX = BOARD_MARGIN_X + boardW + BOARD_GAP_COLS
let infoPanelWidth = min(max(INFO_PANEL_MIN_WIDTH, w - infoPanelX - 1), max(INFO_PANEL_PREFERRED_WIDTH, w - infoPanelX - 1))
let infoPanelHeight = h - 4
tb.setBackgroundColor(bgNone)
tb.setForegroundColor(fgNone)
for y in 0..<h:
for x in 0..<w:
if boardIsVisible and x >= BOARD_MARGIN_X and x < BOARD_MARGIN_X + boardW and y >= BOARD_MARGIN_Y and y < BOARD_MARGIN_Y + boardH:
continue # skip board area
tb.write(x, y, " ")
if not boardIsVisible:
hideBoardImages()
drawTooSmallOverlay(tb, w, h)
else:
if state.helpVisible:
drawHelpBox(tb, state, infoPanelX, BOARD_MARGIN_Y, infoPanelWidth, infoPanelHeight)
else:
drawInfoPanel(tb, state, infoPanelX, BOARD_MARGIN_Y, infoPanelWidth, infoPanelHeight)
# Draw the separator line
tb.setForegroundColor(fgWhite)
tb.setBackgroundColor(bgNone)
for x in 0..<w:
tb.write(x, h - 3, "-")
# Draw autocomplete suggestions (above input bar)
drawAutocomplete(tb, state, 1, h - 3, w - 2)
# Draw the input bar
drawInputBar(tb, state, 1, h - 2, w - 2)
# Draw the status bar
drawStatusBar(tb, state, 1, h - 1, w - 2)
tb.display()
if boardIsVisible:
displayBoard(state, BOARD_MARGIN_Y + 1, BOARD_MARGIN_X + 1)

235
src/heimdall/tui/san.nim Normal file
View File

@@ -0,0 +1,235 @@
# Copyright 2025 Mattia Giambirtone & All Contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
## SAN (Standard Algebraic Notation) parser and formatter
import std/[strutils, strformat]
import heimdall/[board, moves, pieces, movegen]
proc charToPieceKind(c: char): PieceKind =
case c.toLowerAscii():
of 'k': King
of 'q': Queen
of 'r': Rook
of 'b': Bishop
of 'n': Knight
of 'p': Pawn
else: Empty
proc parseSAN*(board: Chessboard, san: string): tuple[move: Move, error: string] =
## Parses a SAN string and returns the corresponding legal move.
## Returns nullMove() with an error message if the SAN is invalid.
var moves = newMoveList()
board.generateMoves(moves)
if san.len == 0:
return (nullMove(), "empty move")
# Handle castling
if san in ["O-O", "0-0", "o-o"]:
for move in moves:
if move.isShortCastling():
return (move, "")
return (nullMove(), "short castling not available")
if san in ["O-O-O", "0-0-0", "o-o-o"]:
for move in moves:
if move.isLongCastling():
return (move, "")
return (nullMove(), "long castling not available")
# Strip check/checkmate indicators and annotations
var s = san
while s.len > 0 and s[^1] in {'+', '#', '!', '?'}:
s = s[0..^2]
if s.len == 0:
return (nullMove(), "empty move after stripping annotations")
# Parse promotion suffix (e.g., "=Q", "=N")
var promotionPiece = Empty
if s.len >= 2 and s[^2] == '=':
promotionPiece = charToPieceKind(s[^1])
if promotionPiece == Empty:
return (nullMove(), &"invalid promotion piece '{s[^1]}'")
s = s[0..^3]
# Parse capture marker
s = s.replace("x", "")
if s.len < 2:
return (nullMove(), "move too short")
# Determine piece kind from first character
var pieceKind: PieceKind
if s[0] in "KQRBN":
pieceKind = charToPieceKind(s[0])
s = s[1..^1]
else:
pieceKind = Pawn
if s.len < 2:
return (nullMove(), "move too short after piece")
# Target square is always the last two characters
let targetStr = s[^2..^1]
if targetStr[0] notin 'a'..'h' or targetStr[1] notin '1'..'8':
return (nullMove(), &"invalid target square '{targetStr}'")
var targetSquare: Square
try:
targetSquare = targetStr.toSquare(checked=true)
except ValueError:
return (nullMove(), &"invalid target square '{targetStr}'")
# Disambiguation: everything before the target square
let disambig = s[0..^3]
var disambigFile = -1'i8
var disambigRank = -1'i8
for c in disambig:
if c in 'a'..'h':
disambigFile = (c.uint8 - 'a'.uint8).int8
elif c in '1'..'8':
# Convert to internal rank (0 = rank 8, 7 = rank 1)
disambigRank = ((c.uint8 - '1'.uint8) xor 7).int8
# Find matching legal move
var matchCount = 0
var matchedMove = nullMove()
for move in moves:
if move.targetSquare() != targetSquare:
continue
let startSq = move.startSquare()
let piece = board.on(startSq)
if piece.kind != pieceKind:
continue
# Check disambiguation
if disambigFile >= 0 and startSq.file().int8 != disambigFile:
continue
if disambigRank >= 0 and startSq.rank().int8 != disambigRank:
continue
# Check promotion
if promotionPiece != Empty:
if not move.isPromotion():
continue
if move.flag().promotionToPiece() != promotionPiece:
continue
elif move.isPromotion():
# If no promotion specified but move is a promotion, skip non-queen promotions
if move.flag().promotionToPiece() != Queen:
continue
inc matchCount
matchedMove = move
if matchCount == 0:
return (nullMove(), &"no legal move matches '{san}'")
if matchCount > 1:
return (nullMove(), &"ambiguous move '{san}' ({matchCount} matches)")
return (matchedMove, "")
proc toSAN*(board: Chessboard, move: Move): string =
## Converts a move to SAN notation given the current board position.
if move == nullMove():
return "null"
# Castling
if move.isShortCastling():
return "O-O"
if move.isLongCastling():
return "O-O-O"
let piece = board.on(move.startSquare())
let isCapture = move.isCapture() or move.isEnPassant()
# Piece letter (uppercase, omitted for pawns)
if piece.kind != Pawn:
result &= piece.toChar().toUpperAscii()
# Disambiguation
if piece.kind != Pawn:
var moves = newMoveList()
board.generateMoves(moves)
var needFile = false
var needRank = false
var ambiguous = false
for other in moves:
if other == move:
continue
if other.targetSquare() != move.targetSquare():
continue
if board.on(other.startSquare()).kind != piece.kind:
continue
# Another piece of same kind can go to same target
ambiguous = true
if other.startSquare().file() == move.startSquare().file():
needRank = true
if other.startSquare().rank() == move.startSquare().rank():
needFile = true
if ambiguous:
if not needFile and not needRank:
# Default: disambiguate by file
needFile = true
if needFile:
result &= chr(ord('a') + move.startSquare().file().int)
if needRank:
result &= chr(ord('1') + (move.startSquare().rank().int xor 7))
elif isCapture:
# Pawn captures include the origin file
result &= chr(ord('a') + move.startSquare().file().int)
# Capture marker
if isCapture:
result &= "x"
# Target square
result &= move.targetSquare().toUCI()
# Promotion
if move.isPromotion():
result &= "="
case move.flag().promotionToPiece():
of Queen: result &= "Q"
of Rook: result &= "R"
of Bishop: result &= "B"
of Knight: result &= "N"
else: discard
# Check/checkmate indicator
# We need to make the move to see if it results in check/mate
let resultMove = board.makeMove(move)
if resultMove != nullMove():
if board.inCheck():
var legalMoves = newMoveList()
board.generateMoves(legalMoves)
if legalMoves.len == 0:
result &= "#"
else:
result &= "+"
board.unmakeMove()

274
src/heimdall/tui/state.nim Normal file
View File

@@ -0,0 +1,274 @@
# Copyright 2025 Mattia Giambirtone & All Contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
## Central application state for the TUI
import std/[options, monotimes, times, strformat]
import heimdall/[board, moves, pieces, eval, search, transpositions]
import heimdall/util/limits
type
TUIMode* = enum
ModeAnalysis ## Free position analysis
ModePlay ## Playing against the engine
ModeReplay ## Stepping through a loaded PGN
PlayPhase* = enum
Setup ## Choosing side, time control, variant
PlayerTurn ## Waiting for player input
EngineTurn ## Engine is thinking
GameOver ## Game ended
SetupStep* = enum
ChooseVariant
ChooseSide
ChoosePlayerTime
ChooseEngineTime
ChooseTakeback
ChoosePonder
ChooseWatchSeparate # Ask if engines should be configured separately
ChooseWatchWhiteTime # White engine TC
ChooseWatchBlackTime # Black engine TC
ChooseWatchThreads # White/shared thread count
ChooseWatchHash # White/shared hash size
ChooseWatchBlackThreads # Black engine threads (separate config only)
ChooseWatchBlackHash # Black engine hash (separate config only)
ChooseWatchPonder # Shared ponder setting
ChooseWatchWhitePonder # White engine ponder (separate config only)
ChooseWatchBlackPonder # Black engine ponder (separate config only)
ChessVariant* = enum
Standard
FischerRandom
DoubleFischerRandom
AnalysisLine* = object
pv*: seq[Move]
score*: Score # Normalized, white-relative (for display)
rawScore*: Score # Raw STM-relative (for WDL computation)
depth*: int
Premove* = tuple[fromSq, toSq: Square]
ChessClock* = object
remainingMs*: int64
incrementMs*: int64
lastTick*: MonoTime
running*: bool
expired*: bool
SearchAction* = enum
StartAnalysis
StartEngineMove
StopSearch
Shutdown
SearchCommand* = object
case kind*: SearchAction
of StartAnalysis:
analysisPositions*: seq[Position]
analysisVariations*: int
of StartEngineMove:
enginePositions*: seq[Position]
engineLimits*: seq[SearchLimit]
ponder*: bool # Search in ponder mode (limits disabled until ponderhit)
of StopSearch, Shutdown:
discard
SearchResponse* = enum
SearchComplete
Exiting
AppState* = ref object
mode*: TUIMode
board*: Chessboard
moveHistory*: seq[Move]
sanHistory*: seq[string]
startFEN*: string
flipped*: bool
chess960*: bool
selectedSquare*: Option[Square]
dragSourceSquare*: Option[Square] # Source square of an in-progress mouse drag
dragCursor*: Option[tuple[x, y: int]] # Board-image pixel position of the dragged piece
pendingPremoves*: seq[Premove]
boardSetupMode*: bool # Manual board editing mode (analysis only)
boardSetupSpawnPiece*: Option[Piece]
boardSetupResumeAnalysis*: bool
legalDestinations*: seq[Square]
lastMove*: Option[tuple[fromSq, toSq: Square]]
undoneHistory*: seq[tuple[move: Move, san: string]] # for redo via Right arrow
inputBuffer*: string
inputCursorPos*: int
statusMessage*: string
statusIsError*: bool
statusExpiry*: MonoTime
statusPersistent*: bool # If true, don't auto-expire (dismiss on keypress)
shouldQuit*: bool
showThreats*: bool # Threat highlighting toggle (off by default)
ctrlDPending*: bool # Waiting for second Ctrl+D to confirm exit
autoQueen*: bool # Auto-promote to queen (toggle with q)
promotionPending*: bool # Waiting for user to choose promotion piece
promotionFrom*: Square # Source square of pending promotion
promotionTo*: Square # Target square of pending promotion
helpVisible*: bool # Help box overlay in info panel
helpScroll*: int # Scroll offset for the help overlay
# Autocomplete
acSuggestions*: seq[tuple[cmd, desc: string]]
acSelected*: int # -1 = none selected
acActive*: bool
# Analysis (MultiPV support)
analysisRunning*: bool
multiPV*: int
analysisLines*: seq[AnalysisLine]
analysisDepth*: int
analysisNPS*: uint64
analysisNodes*: uint64
# Play mode
playPhase*: PlayPhase
setupStep*: SetupStep
variant*: ChessVariant
playerColor*: PieceColor
playerClock*: ChessClock
engineClock*: ChessClock
engineThinking*: bool
gameResult*: Option[string]
watchMode*: bool # Engine vs Engine (both sides auto-play)
watchSeparateConfig*: bool # Engines configured separately in watch mode
allowTakeback*: bool # Whether takeback is allowed in this game
allowPonder*: bool # Primary engine ponder setting
isPondering*: bool # Primary engine currently pondering
ponderMove*: Move # Move the primary engine is pondering on
isWatchPondering*: bool # Second engine currently pondering
watchPonderMove*: Move # Move the second engine is pondering on
gameStartFEN*: string # FEN at game start (for display)
gameTimeControl*: string # Human-readable TC description
# Second engine for watch mode (independent instance)
watchSearcher*: SearchManager
watchTtable*: ptr TranspositionTable
watchThreads*: int
watchHash*: uint64
watchDepth*: Option[int]
watchPonder*: bool # Second engine ponder setting
watchInitialized*: bool
watchWorkerThread*: Thread[ptr AppState]
watchChannels*: tuple[command: Channel[SearchCommand], response: Channel[SearchResponse]]
# PGN replay
pgnMoveIndex*: int
pgnMoves*: seq[Move]
pgnSanHistory*: seq[string]
pgnStartPosition*: Option[Position]
pgnTags*: seq[tuple[name, value: string]] # Metadata from loaded PGN
pgnResult*: string
# Engine config
engineDepth*: Option[int]
engineThreads*: int
engineHash*: uint64
# Search integration
searcher*: SearchManager
ttable*: ptr TranspositionTable
searchWorkerThread*: Thread[ptr AppState]
channels*: tuple[command: Channel[SearchCommand], response: Channel[SearchResponse]]
pvChannel*: Channel[seq[AnalysisLine]]
proc newAppState*: AppState =
new(result)
result.mode = ModeAnalysis
result.board = newDefaultChessboard()
result.startFEN = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
result.multiPV = 1
result.autoQueen = true
result.dragSourceSquare = none(Square)
result.dragCursor = none(tuple[x, y: int])
result.pendingPremoves = @[]
result.helpScroll = 0
result.boardSetupSpawnPiece = none(Piece)
result.engineThreads = 1
result.engineHash = 64
result.playPhase = Setup
result.setupStep = ChooseVariant
result.ttable = create(TranspositionTable)
result.ttable[] = newTranspositionTable(result.engineHash * 1024 * 1024)
result.searcher = newSearchManager(result.board.positions, result.ttable, evalState=newEvalState(verbose=false))
result.channels.command.open()
result.channels.response.open()
result.pvChannel.open()
const STATUS_DURATION* = initDuration(seconds = 3)
proc setStatus*(state: AppState, msg: string, isError: bool = false, persistent: bool = false) =
state.statusMessage = msg
state.statusIsError = isError
state.statusPersistent = persistent
if persistent:
state.statusExpiry = MonoTime.high()
else:
state.statusExpiry = getMonoTime() + STATUS_DURATION
proc setError*(state: AppState, msg: string) =
state.setStatus(msg, isError = true)
proc dismissStatus*(state: AppState) =
## Clears a persistent status message
if state.statusPersistent:
state.statusMessage = ""
state.statusPersistent = false
proc queuePremove*(state: AppState, fromSq, toSq: Square) =
state.pendingPremoves.add((fromSq: fromSq, toSq: toSq))
state.setStatus(&"Queued premove #{state.pendingPremoves.len}: {fromSq.toUCI()}{toSq.toUCI()}")
proc clearPremoves*(state: AppState, statusMessage = "") =
state.pendingPremoves = @[]
if statusMessage.len > 0:
state.setStatus(statusMessage)
proc removeLatestPremoveAtSquare*(state: AppState, sq: Square): bool =
if state.pendingPremoves.len == 0:
return false
for i in countdown(state.pendingPremoves.high, 0):
let premove = state.pendingPremoves[i]
if premove.fromSq == sq or premove.toSq == sq:
state.pendingPremoves.delete(i)
if state.pendingPremoves.len == 0:
state.setStatus("Premoves cleared")
else:
state.setStatus(&"Removed premove on {sq.toUCI()} ({state.pendingPremoves.len} queued)")
return true
false
proc cleanup*(state: AppState) =
state.channels.command.close()
state.channels.response.close()
state.pvChannel.close()
if state.ttable != nil:
dealloc(state.ttable)
state.ttable = nil

View File

@@ -878,7 +878,7 @@ proc searchWorkerLoop(self: UCISearchWorker) {.thread.} =
var line = self.session.searcher.search(action.command.searchmoves, false, self.session.canPonder and action.command.ponder,
self.session.minimal, self.session.variations)[0]
let chess960 = self.session.searcher.state.chess960.load(moRelaxed)
for move in line.mitems():
for move in line.moves.mitems():
if move == nullMove():
break
if move.isCastling() and not chess960:
@@ -895,24 +895,24 @@ proc searchWorkerLoop(self: UCISearchWorker) {.thread.} =
while not self.session.searcher.shouldStop():
# Sleep for 10ms
sleep(10)
if line[0] == nullMove():
if line.moves[0] == nullMove():
# No best move. Well shit. Usually this only happens at insanely low TCs
# so we just pick a random legal move
var moves = newMoveList()
var board = newChessboard(@[self.session.searcher.getCurrentPosition().clone()])
board.generateMoves(moves)
line[0] = moves[rand(0..moves.high())]
line.moves[0] = moves[rand(0..moves.high())]
if not self.session.isMixedMode:
if line[1] != nullMove():
echo &"bestmove {line[0].toUCI()} ponder {line[1].toUCI()}"
if line.moves[1] != nullMove():
echo &"bestmove {line.moves[0].toUCI()} ponder {line.moves[1].toUCI()}"
else:
echo &"bestmove {line[0].toUCI()}"
echo &"bestmove {line.moves[0].toUCI()}"
else:
if line[1] != nullMove():
styledWrite(stdout, self.session.useColor, fgGreen, "Best move: ", styleBright, fgWhite, line[0].toUCI(), "\n",
resetStyle, fgCyan, "Best response: ", styleBright, fgWhite, line[1].toUCI(), "\n")
if line.moves[1] != nullMove():
styledWrite(stdout, self.session.useColor, fgGreen, "Best move: ", styleBright, fgWhite, line.moves[0].toUCI(), "\n",
resetStyle, fgCyan, "Best response: ", styleBright, fgWhite, line.moves[1].toUCI(), "\n")
else:
styledWrite(stdout, self.session.useColor, fgGreen, "Best move: ", styleBright, fgWhite, line[0].toUCI(), "\n")
styledWrite(stdout, self.session.useColor, fgGreen, "Best move: ", styleBright, fgWhite, line.moves[0].toUCI(), "\n")
if self.session.debug:
echo "info string worker has finished searching"
if self.session.isMixedMode and not self.session.searcher.cancelled():

View File

@@ -43,6 +43,12 @@ type
# across the entire search
spentNodes*: array[Square.smallest()..Square.biggest(), array[Square.smallest()..Square.biggest(), Atomic[uint64]]]
# Per-variation scores and best moves for MultiPV live polling.
# Updated after each variation completes a depth iteration.
variationScores*: array[218, Atomic[Score]]
variationMoves*: array[218, Atomic[Move]]
variationCount*: Atomic[int]
SearchState* = ref object
## A container for the the portion of