Add experimental new TUI (bench 5739439)
102
README.md
@@ -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 :>
|
||||
|
||||
@@ -22,3 +22,4 @@ requires "struct == 0.2.3"
|
||||
requires "nimsimd == 1.2.13"
|
||||
|
||||
requires "noise >= 0.1.10"
|
||||
requires "illwill >= 0.4.1"
|
||||
|
||||
@@ -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)
|
||||
|
||||
BIN
src/heimdall/resources/pieces/b_bishop.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
11
src/heimdall/resources/pieces/b_bishop.svg
Normal 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 |
BIN
src/heimdall/resources/pieces/b_king.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
15
src/heimdall/resources/pieces/b_king.svg
Normal 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 |
BIN
src/heimdall/resources/pieces/b_knight.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
12
src/heimdall/resources/pieces/b_knight.svg
Normal 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 |
BIN
src/heimdall/resources/pieces/b_pawn.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
6
src/heimdall/resources/pieces/b_pawn.svg
Normal 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 |
BIN
src/heimdall/resources/pieces/b_queen.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
14
src/heimdall/resources/pieces/b_queen.svg
Normal 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 |
BIN
src/heimdall/resources/pieces/b_rook.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
11
src/heimdall/resources/pieces/b_rook.svg
Normal 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 |
BIN
src/heimdall/resources/pieces/board_black.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
88
src/heimdall/resources/pieces/board_black.svg
Normal 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 |
BIN
src/heimdall/resources/pieces/board_white.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
88
src/heimdall/resources/pieces/board_white.svg
Normal 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 |
BIN
src/heimdall/resources/pieces/rgba/b_bishop.rgba
Normal file
BIN
src/heimdall/resources/pieces/rgba/b_king.rgba
Normal file
BIN
src/heimdall/resources/pieces/rgba/b_knight.rgba
Normal file
BIN
src/heimdall/resources/pieces/rgba/b_pawn.rgba
Normal file
BIN
src/heimdall/resources/pieces/rgba/b_queen.rgba
Normal file
BIN
src/heimdall/resources/pieces/rgba/b_rook.rgba
Normal file
1
src/heimdall/resources/pieces/rgba/board_black.rgba
Normal file
1
src/heimdall/resources/pieces/rgba/board_white.rgba
Normal file
BIN
src/heimdall/resources/pieces/rgba/w_bishop.rgba
Normal file
BIN
src/heimdall/resources/pieces/rgba/w_king.rgba
Normal file
BIN
src/heimdall/resources/pieces/rgba/w_knight.rgba
Normal file
BIN
src/heimdall/resources/pieces/rgba/w_pawn.rgba
Normal file
BIN
src/heimdall/resources/pieces/rgba/w_queen.rgba
Normal file
BIN
src/heimdall/resources/pieces/rgba/w_rook.rgba
Normal file
BIN
src/heimdall/resources/pieces/w_bishop.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
13
src/heimdall/resources/pieces/w_bishop.svg
Normal 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 |
BIN
src/heimdall/resources/pieces/w_king.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
13
src/heimdall/resources/pieces/w_king.svg
Normal 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 |
BIN
src/heimdall/resources/pieces/w_knight.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
9
src/heimdall/resources/pieces/w_knight.svg
Normal 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 |
BIN
src/heimdall/resources/pieces/w_pawn.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
7
src/heimdall/resources/pieces/w_pawn.svg
Normal 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 |
BIN
src/heimdall/resources/pieces/w_queen.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
25
src/heimdall/resources/pieces/w_queen.svg
Normal 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 |
BIN
src/heimdall/resources/pieces/w_rook.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
12
src/heimdall/resources/pieces/w_rook.svg
Normal 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 |
@@ -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)
|
||||
|
||||
337
src/heimdall/tui/analysis.nim
Normal 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
@@ -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()
|
||||
357
src/heimdall/tui/board_view.nim
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||
266
src/heimdall/tui/rawinput.nim
Normal 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)
|
||||
737
src/heimdall/tui/renderer.nim
Normal 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
@@ -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
@@ -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
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||