zig-chess/docs/ui-move-list-playback-plan.md

11 KiB

UI Move List and Playback Mode Plan

Goal

Add UI support for:

  • A fixed-size move list panel on the left side of the board.
  • Two move columns: White move and Black move, with turn number on the left.
  • Standard PGN/SAN notation display.
  • Scroll support when the move list is longer than the visible panel.
  • A move-list button that copies the current game as PGN to the clipboard.
  • A playback-only vertical analysis bar between the board and move list.
  • A board-orientation control that can render from White's perspective or Black's perspective.
  • A new playback mode where pieces cannot be moved, and the user can step through a loaded game.
  • In edit mode, a UI control to choose whose move is next.
  • Mode-specific paste behavior:
    • Edit mode: Ctrl+V pastes FEN.
    • Playback mode: Ctrl+V pastes PGN.
    • Play mode: no FEN/PGN paste.

Collaboration rule

The assistant should manage only UI/application-side changes for this feature.

Do not edit files under src/chess/ unless explicitly requested.

When UI work needs chess-layer functionality, stop and describe the required chess API/data shape so the project owner can implement it.

Current state

  • BoardState owns the current chess position.
  • src/chess/game.zig has a basic Game framework with:
    • initial_state
    • state
    • moves
    • time_control
    • clocks
    • status
  • UI currently uses current_state directly in src/main.zig.
  • Existing modes are:
    • play
    • edit
  • Existing paste behavior parses clipboard as FEN regardless of mode.
  • FEN copy is available via Ctrl+C.
  • Desired copy behavior: Ctrl+C should continue to copy FEN in every mode. PGN copy should use an explicit move-list button, not replace Ctrl+C.

Desired architecture

Eventually, UI should treat Game as the game/session model:

UI/App
├── current mode: play | edit | playback
├── active Game
├── playback cursor / ply index
├── move list scroll offset
├── board orientation: white perspective | black perspective
├── analysis display state for playback mode
└── rendering state

The chess layer should provide enough data for the UI to display moves and reconstruct positions, but the UI should not implement chess notation rules itself.

Stage 0: Board orientation and edit-side-to-move controls

UI work

  • Add app/UI state for board orientation:
white perspective: current rendering
black perspective: visually flipped board, with White pieces/ranks at the top and Black pieces/ranks at the bottom
  • This must be a purely visual transform.
  • Keep the same chess square numbering and logical coordinates:
    • a1 is still square 0
    • White still starts on ranks 1 and 2 logically
    • Black still starts on ranks 7 and 8 logically
  • Update all board visual mappings consistently:
    • square rendering
    • piece rendering
    • coordinate labels
    • mouse hit-testing
    • hover/selection/check/checkmate highlights
    • valid/legal move dots
    • dragging source/target display
    • promotion popup placement
  • Add a UI control to toggle orientation, probably near the mode buttons or move list panel.
  • In edit mode, add a control to choose whose move is next:
    • White to move
    • Black to move
  • Changing side-to-move in edit mode should update only the current board state's active color/turn field and then rebuild rendering.
  • In non-edit modes, the side-to-move selector should either be hidden or disabled.

Chess-layer needs

No chess-rule changes are required for board orientation. It is visual only.

For edit-side-to-move, the UI needs mutable access to the current position's active color. If BoardState.turn remains public, no new chess API is required. If you later make it private, add a small setter such as:

pub fn setTurn(self: *BoardState, color: piece.Color) void

Stage 1: Add playback mode UI shell

UI work

  • Add playback to the app/input mode enum.
  • Add a third mode button, likely labeled VIEW or PGN/REPLAY.
  • In playback mode:
    • ignore board clicks/drags for moving pieces
    • no palette editing
    • no legal-move highlight generation
  • Keep reset behavior available or decide whether reset resets the active game/playback.

Chess-layer needs

None.

Stage 2: Make paste mode-specific

UI work

Change clipboard paste behavior:

  • If mode is .edit:
    • parse clipboard as FEN
    • replace current board state/game position
  • If mode is .playback:
    • send clipboard text to a future PGN import function
    • for now, log that PGN paste is not yet implemented if chess API is missing
  • If mode is .play:
    • ignore paste or log debug message

Chess-layer needs

Eventually needed:

pub fn loadPgn(allocator: std.mem.Allocator, pgn_text: []const u8) !Game

or equivalent parse API.

For stage 2, this can be stubbed from the UI side with no chess changes.

Stage 3: Move list panel with placeholder/static data

UI work

  • Define a fixed rectangle left of the board.
  • Render panel background and border.
  • Render column headers, for example:
#   White     Black
  • Add a visible PGN copy button inside or attached to the move list panel.
    • Clicking it should copy the current game PGN to the clipboard once chess-layer PGN export exists.
    • Until PGN export exists, the button can be visually present but log that export is not implemented.
  • Render rows using temporary/static strings or currently available move placeholders.
  • Establish layout constants:
    • panel left/right/top/bottom
    • row height
    • turn-number column x
    • white column x
    • black column x
    • max visible rows

Chess-layer needs

None for placeholder rendering.

Stage 4: Record/display move notation from played games

UI work

  • Replace direct current_state.move(...) calls with game.makeMove(...) once Game is wired into main.zig.
  • Read game.moves and draw move rows.
  • Update the move list after every played move.

Chess-layer needs

The Game move history should expose display notation for each ply.

Suggested shape:

pub const MoveRecord = struct {
    from: bitboard.Square,
    to: bitboard.Square,
    promotion: ?piece.PieceType,
    san: []const u8, // or fixed buffer / owned slice
    time_remaining: u32,
};

If avoiding heap allocation in each move record, possible alternatives:

san: [16]u8,
san_len: u8,

The chess layer should generate SAN before mutating the board, because SAN depends on the pre-move position.

Needed API could be:

pub fn makeMove(self: *Game, allocator: std.mem.Allocator, from: Square, to: Square, promotion: ?PieceType) !void

or:

pub fn formatSanMove(allocator: std.mem.Allocator, state_before: BoardState, from: Square, to: Square, promotion: ?PieceType) ![]u8

Stage 5: Playback analysis bar

UI work

  • Add a vertical analysis bar between the board and the move list panel.
  • The analysis bar should be visible only in playback mode.
  • The bar is divided into a White section and a Black section.
  • When analysis is equal, the White/Black divide should be centered.
  • When White is winning, the White portion grows and the divide moves toward Black's side.
  • When Black is winning, the Black portion grows and the divide moves toward White's side.
  • The bar is purely display; it should not affect board state or move legality.
  • Initial placeholder behavior can render the bar at 50/50 until analysis data exists.
  • Later, map engine evaluation scores to a display fraction.

Suggested visual mapping:

score = 0.00 pawns      -> 50% White / 50% Black
score > 0, White better -> White section larger
score < 0, Black better -> Black section larger

Use a bounded/nonlinear mapping so very large evaluations do not immediately consume the whole bar. For example, later UI code could map centipawns to a normalized value with a clamp or sigmoid-like curve. Exact mapping can be tuned visually.

Chess/engine-layer needs

No chess-layer changes are needed for placeholder rendering.

Eventually, playback analysis needs external UCI analysis data:

score cp <centipawns>
score mate <moves>
pv <principal variation moves>

The UI only needs a normalized analysis value or raw score from the UCI analysis client.

Stage 6: Scrolling move list

UI work

  • Track move_list_scroll_row or similar state in main.zig.
  • Add mouse wheel handling when cursor is over the move panel.
  • Clamp scroll offset between 0 and max(0, total_rows - visible_rows).
  • Optional later: visual scrollbar.

Chess-layer needs

None.

Stage 7: Playback cursor and stepping

UI work

  • Add playback_ply_index app state.
  • In playback mode:
    • Right arrow: advance one ply
    • Left arrow: go back one ply
    • Home: first position
    • End: final position
  • Render the board for the current playback ply, not necessarily the final game state.
  • Highlight or mark the current row/ply in the move list.

Chess-layer needs

Need a way to reconstruct board state at an arbitrary ply.

Possible API:

pub fn stateAtPly(self: *const Game, ply_index: usize) board.BoardState

or, if allocation/errors are involved:

pub fn replayToPly(self: *const Game, ply_index: usize) !board.BoardState

This can replay from initial_state through the first ply_index moves.

Stage 8: PGN paste/import and PGN copy/export

UI work

  • In playback mode, Ctrl+V gets clipboard text and passes it to chess PGN parser.
  • Keep Ctrl+C as FEN copy in every mode.
  • Add click handling for the move-list PGN copy button.
  • When the PGN copy button is clicked, copy exported PGN to the clipboard.
  • On success:
    • replace active game with parsed game
    • set mode to playback or remain playback
    • set playback cursor to final ply or start; choose behavior intentionally
    • reset move-list scroll
  • On failure:
    • log a warning
    • keep current game unchanged

Chess-layer needs

PGN parser that returns a Game with move history and final state, plus PGN export for the current game.

Suggested API:

pub fn initFromPgn(allocator: std.mem.Allocator, pgn_text: []const u8, time_control: TimeControl) !Game

or:

pub fn parsePgn(allocator: std.mem.Allocator, pgn_text: []const u8) !Game

Suggested export API:

pub fn formatPgn(allocator: std.mem.Allocator, game: Game) ![]u8

or as a method:

pub fn formatPgn(self: *const Game, allocator: std.mem.Allocator) ![]u8

Parser should understand at least:

  • move numbers: 1., 1...
  • SAN moves
  • castling: O-O, O-O-O
  • captures: x
  • promotion: =Q, =R, =B, =N
  • check/checkmate suffixes: +, #
  • game termination markers: 1-0, 0-1, 1/2-1/2, *
  • comments and tags can be added later if desired

Reference: Steven J. Edwards, Portable Game Notation Specification and Implementation Guide, especially movetext/SAN sections.

Stage 9: Optional polish

  • Auto-scroll move list to latest move in play mode.
  • Click a move row in playback mode to jump to that ply.
  • Show current move highlight.
  • Add scrollbar.
  • Show numeric eval next to the analysis bar.
  • Show best line/PV next to or below the move list.
  • Add richer PGN copy/export options, such as including/excluding tag pairs.
  • Display game result in the move panel.
  • Show time remaining per move later if clocks are implemented.

Immediate next action

Start with Stage 0, then Stage 1 and Stage 2 in UI-only files:

  • src/board_input.zig
  • src/text_render.zig
  • src/main.zig

Do not modify src/chess/ for these stages.