Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions src/cli_chess/core/game/game_presenter_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,14 @@
from cli_chess.modules.clock import ClockPresenter
from cli_chess.modules.premove import PremovePresenter
from cli_chess.utils import log, AlertType, RequestSuccessfullySent, EventTopics, save_game_pgn
from cli_chess.utils.config import game_config
from cli_chess.utils.move_input_preview import analyze_move_input, longest_matching_san_prefix
from abc import ABC, abstractmethod
import chess
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from cli_chess.core.game import GameModelBase, PlayableGameModelBase
from prompt_toolkit.buffer import Buffer


class GamePresenterBase(ABC):
Expand Down Expand Up @@ -55,6 +59,7 @@ def __init__(self, model: PlayableGameModelBase):
self.premove_presenter = PremovePresenter(model.premove_model)
super().__init__(model)
self.model = model
self._move_input_hint_text = ""

@abstractmethod
def _get_view(self) -> PlayableGameViewBase:
Expand All @@ -78,6 +83,77 @@ def update(self, *args, **kwargs) -> None:
self.premove_presenter.clear_premove()
self._save_pgn()

def on_move_input_changed(self, text: str) -> None:
"""Refresh live board hints and the move preview line while typing."""
self._refresh_move_input_preview(text)

def get_move_input_hint_text(self) -> str:
"""Resolved SAN when input matches exactly one legal move (for the hint line)."""
return self._move_input_hint_text

def try_tab_complete_move_input(self, buffer: "Buffer") -> bool:
"""Extend partial SAN to the longest common prefix of matching moves. Returns True if applied."""
if not game_config.get_boolean(game_config.Keys.LIVE_MOVE_INPUT_HIGHLIGHTS):
return False
if not game_config.get_boolean(game_config.Keys.LIVE_MOVE_INPUT_AUTOCOMPLETE):
return False
if self.model.board_model.is_game_over():
return False

current = buffer.text.strip()
if not current or current.lower().startswith("send"):
return False

board = self.model.board_model.board
next_prefix = longest_matching_san_prefix(board, current)
if not next_prefix or next_prefix == current:
return False

buffer.text = next_prefix
buffer.cursor_position = len(next_prefix)
return True

def _refresh_move_input_preview(self, text: str) -> None:
self._move_input_hint_text = ""
board_model = self.model.board_model

if not game_config.get_boolean(game_config.Keys.LIVE_MOVE_INPUT_HIGHLIGHTS):
board_model.clear_move_input_highlights()
return

if board_model.is_game_over():
board_model.clear_move_input_highlights()
return

stripped = text.strip()
if not stripped:
board_model.clear_move_input_highlights()
return

if stripped.lower().startswith("send"):
board_model.clear_move_input_highlights()
return

analysis = analyze_move_input(board_model.board, stripped)
if not analysis.from_squares:
board_model.clear_move_input_highlights()
return

show_targets = game_config.get_boolean(game_config.Keys.LIVE_MOVE_INPUT_SHOW_TARGETS)

if analysis.preview_move:
board_model.set_move_input_highlights(
set(),
set(),
analysis.preview_move,
)
self._move_input_hint_text = (
f"If you press Enter: {board_model.board.san(analysis.preview_move)}"
)
else:
to_sq = set(analysis.to_squares) if show_targets else set()
board_model.set_move_input_highlights(set(analysis.from_squares), to_sq, chess.Move.null())

def user_input_received(self, inpt: str) -> None:
"""Respond to the users input. This input can either be the
move input, or game actions (such as resign)
Expand Down
19 changes: 18 additions & 1 deletion src/cli_chess/core/game/game_view_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from prompt_toolkit.key_binding import KeyBindings, merge_key_bindings
from prompt_toolkit.keys import Keys
from prompt_toolkit.buffer import Buffer
from prompt_toolkit.filters import Condition
from prompt_toolkit.filters import Condition, has_focus
from abc import ABC, abstractmethod
from typing import Tuple, TYPE_CHECKING
if TYPE_CHECKING:
Expand Down Expand Up @@ -93,6 +93,11 @@ def __init__(self, presenter: PlayableGamePresenterBase):
self.presenter = presenter
self.premove_container = presenter.premove_presenter.view
self.input_field_container = self._create_input_field_container()
self.input_field_container.buffer.on_text_changed.add_handler(self._on_move_input_buffer_changed)
self.move_input_hint_window = Window(
FormattedTextControl(lambda: self._move_input_hint_fragments()),
height=D(max=1),
)
self.notation_help = NotationHelpContainer()
super().__init__(presenter)

Expand Down Expand Up @@ -195,6 +200,11 @@ def _(event): # noqa
def _(event):
self.presenter.premove_presenter.clear_premove()

@bindings.add(Keys.Tab, filter=has_focus(self.input_field_container), eager=True)
def _(event):
if self.presenter.try_tab_complete_move_input(self.input_field_container.buffer):
event.app.invalidate()

return merge_key_bindings([bindings, super().get_key_bindings()])

def _create_input_field_container(self) -> TextArea:
Expand All @@ -213,3 +223,10 @@ def _accept_input(self, input: Buffer) -> None: # noqa
"""Accept handler for the input field"""
self.presenter.user_input_received(input.text)
self.input_field_container.text = ''

def _on_move_input_buffer_changed(self, buffer: Buffer) -> None:
self.presenter.on_move_input_changed(buffer.text)

def _move_input_hint_fragments(self) -> StyleAndTextTuples:
text = self.presenter.get_move_input_hint_text()
return [("class:label.dim", text)] if text else []
7 changes: 5 additions & 2 deletions src/cli_chess/core/game/offline_game/offline_game_view.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations
from cli_chess.core.game import PlayableGameViewBase
from prompt_toolkit.layout import Container, HSplit, VSplit, VerticalAlign
from prompt_toolkit.layout import Container, HSplit, VSplit, VerticalAlign, D
from prompt_toolkit.widgets import Box
from typing import TYPE_CHECKING
if TYPE_CHECKING:
Expand All @@ -25,7 +25,10 @@ def _create_container(self) -> Container:
self.player_info_lower_container,
]), padding=0, padding_top=1)
]),
self.input_field_container,
HSplit([
self.input_field_container,
self.move_input_hint_window,
], height=D(max=2, preferred=2)),
self.premove_container,
self.alert,
self.notation_help,
Expand Down
7 changes: 5 additions & 2 deletions src/cli_chess/core/game/online_game/online_game_view.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations
from cli_chess.core.game import PlayableGameViewBase
from prompt_toolkit.layout import Container, HSplit, VSplit, VerticalAlign
from prompt_toolkit.layout import Container, HSplit, VSplit, VerticalAlign, D
from prompt_toolkit.widgets import Box
from typing import TYPE_CHECKING
if TYPE_CHECKING:
Expand Down Expand Up @@ -29,7 +29,10 @@ def _create_container(self) -> Container:
]),
self.chat_container
]),
self.input_field_container,
HSplit([
self.input_field_container,
self.move_input_hint_window,
], height=D(max=2, preferred=2)),
self.premove_container,
self.alert,
self.notation_help,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ def _create_menu(self) -> MenuCategory:
MultiValueMenuOption(game_config.Keys.SHOW_MOVE_LIST_IN_UNICODE, "", self._get_available_game_config_options(game_config.Keys.SHOW_MOVE_LIST_IN_UNICODE), display_name="Show move list in unicode"), # noqa: E501
MultiValueMenuOption(game_config.Keys.SHOW_MATERIAL_DIFF_IN_UNICODE, "", self._get_available_game_config_options(game_config.Keys.SHOW_MATERIAL_DIFF_IN_UNICODE), display_name="Unicode material difference"), # noqa: E501
MultiValueMenuOption(game_config.Keys.PAD_UNICODE, "", self._get_available_game_config_options(game_config.Keys.PAD_UNICODE), display_name="Pad unicode (fix overlap)"), # noqa: E501
MultiValueMenuOption(game_config.Keys.LIVE_MOVE_INPUT_HIGHLIGHTS, "", self._get_available_game_config_options(game_config.Keys.LIVE_MOVE_INPUT_HIGHLIGHTS), display_name="Live move input hints"), # noqa: E501
MultiValueMenuOption(game_config.Keys.LIVE_MOVE_INPUT_SHOW_TARGETS, "", self._get_available_game_config_options(game_config.Keys.LIVE_MOVE_INPUT_SHOW_TARGETS), display_name="Show target squares"), # noqa: E501
MultiValueMenuOption(game_config.Keys.LIVE_MOVE_INPUT_AUTOCOMPLETE, "", self._get_available_game_config_options(game_config.Keys.LIVE_MOVE_INPUT_AUTOCOMPLETE), display_name="Tab autocomplete"), # noqa: E501
MultiValueMenuOption(terminal_config.Keys.TERMINAL_COLOR_DEPTH, "", self._get_available_color_depth_options(), display_name="Terminal color depth"), # noqa: E501
]
return MenuCategory("Program Settings", menu_options)
Expand Down
53 changes: 52 additions & 1 deletion src/cli_chess/modules/board/board_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import chess
import chess.variant
from random import randint
from typing import List, Optional
from typing import List, Optional, Set


class BoardModel:
Expand All @@ -16,6 +16,9 @@ def __init__(self, orientation: chess.Color = chess.WHITE, variant="standard", f
self.side_confirmed = side_confirmed # flag to indicate if the users color is fully confirmed (e.g. online)
self.highlight_move = chess.Move.null()
self.premove_highlight = chess.Move.null()
self._move_input_from_squares: frozenset = frozenset()
self._move_input_to_squares: frozenset = frozenset()
self._move_input_preview_move: chess.Move = chess.Move.null()
self._game_over_result: Optional[chess.Outcome] = None
self._log_init_info()

Expand Down Expand Up @@ -54,6 +57,9 @@ def reinitialize_board(self, variant: str, orientation: chess.Color, fen: str =
self.initial_fen = self.board.fen()
self.set_board_orientation(chess.WHITE if variant.lower() == "racingkings" else orientation, notify=False)
self.highlight_move = chess.Move.from_uci(uci_last_move) if uci_last_move else chess.Move.null()
self._move_input_from_squares = frozenset()
self._move_input_to_squares = frozenset()
self._move_input_preview_move = chess.Move.null()
self._game_over_result = None
self.side_confirmed = is_side_confirmed

Expand All @@ -69,6 +75,9 @@ def reset(self, notify=True):
"""
self.board.reset()
self.set_fen(self.initial_fen, notify=False)
self._move_input_from_squares = frozenset()
self._move_input_to_squares = frozenset()
self._move_input_preview_move = chess.Move.null()
self._game_over_result = None

if notify:
Expand Down Expand Up @@ -205,6 +214,45 @@ def get_highlight_move(self) -> chess.Move:
"""
return self.highlight_move

def get_move_input_from_squares(self) -> frozenset:
"""Squares to highlight as candidate moving pieces while typing a move."""
return self._move_input_from_squares

def get_move_input_to_squares(self) -> frozenset:
"""Destination squares to highlight for in-progress move input (optional)."""
return self._move_input_to_squares

def get_move_input_preview_move(self) -> chess.Move:
"""When input resolves to exactly one legal move, both ends are highlighted."""
return self._move_input_preview_move

def set_move_input_highlights(
self,
from_squares: Set[chess.Square],
to_squares: Set[chess.Square],
preview_move: chess.Move,
notify: bool = True,
) -> None:
"""Updates transient highlights driven by the move input field."""
self._move_input_from_squares = frozenset(from_squares)
self._move_input_to_squares = frozenset(to_squares)
self._move_input_preview_move = preview_move
if notify:
self._notify_board_model_updated()

def clear_move_input_highlights(self, notify: bool = True) -> None:
"""Clears move-input-driven highlights."""
if (
self._move_input_from_squares
or self._move_input_to_squares
or bool(self._move_input_preview_move)
):
self._move_input_from_squares = frozenset()
self._move_input_to_squares = frozenset()
self._move_input_preview_move = chess.Move.null()
if notify:
self._notify_board_model_updated()

def set_board_orientation(self, color: chess.Color, notify=True) -> None:
"""Sets the board's orientation to the color passed in.
If notify is false, a model update notification will not be sent.
Expand Down Expand Up @@ -314,6 +362,9 @@ def set_board_position(self, fen: str, uci_last_move=""):
if fen:
self.set_fen(fen, notify=False)
self.highlight_move = chess.Move.from_uci(uci_last_move) if uci_last_move else chess.Move.null()
self._move_input_from_squares = frozenset()
self._move_input_to_squares = frozenset()
self._move_input_preview_move = chess.Move.null()
self._notify_board_model_updated()
except Exception as e:
log.error(f"Error caught setting board position: {e}")
Expand Down
17 changes: 15 additions & 2 deletions src/cli_chess/modules/board/board_presenter.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,21 @@ def get_square_display_color(self, square: chess.Square) -> str:
except IndexError:
pass

if self.model.is_square_in_check(square):
square_color = "in-check"
live_hints = self.game_config_values.get(game_config.Keys.LIVE_MOVE_INPUT_HIGHLIGHTS, False)
if live_hints:
try:
preview = self.model.get_move_input_preview_move()
if bool(preview) and (square == preview.from_square or square == preview.to_square):
square_color = "move-input-preview"
elif square in self.model.get_move_input_to_squares():
square_color = "move-input-target"
elif square in self.model.get_move_input_from_squares():
square_color = "move-input-piece"
except IndexError:
pass

if show_board_highlights and self.model.is_square_in_check(square):
square_color = "in-check"

return square_color

Expand Down
50 changes: 50 additions & 0 deletions src/cli_chess/tests/utils/test_move_input_preview.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import chess

from cli_chess.utils.move_input_preview import (
analyze_move_input,
legal_moves_matching_san_prefix,
longest_matching_san_prefix,
)


def test_single_knight_prefix_identifies_one_square():
board = chess.Board("8/8/8/8/8/8/8/N3K2k w - - 0 1")
preview = analyze_move_input(board, "N")
assert preview.from_squares == frozenset([chess.A1])
assert preview.preview_move is None


def test_unique_move_sets_preview():
board = chess.Board()
preview = analyze_move_input(board, "e4")
assert preview.preview_move is not None
assert preview.preview_move == chess.Move.from_uci("e2e4")


def test_file_level_prefix_narrows_to_one_knight():
board = chess.Board()
nb_from = {m.from_square for m in legal_moves_matching_san_prefix(board, "Nc")}
nf_from = {m.from_square for m in legal_moves_matching_san_prefix(board, "Nf")}
assert nb_from == {chess.B1}
assert nf_from == {chess.G1}


def test_castling_zero_for_letter_o():
board = chess.Board("r3k2r/pppppppp/8/8/8/8/PPPPPPPP/R3K2R w KQkq - 0 1")
m_o = legal_moves_matching_san_prefix(board, "0-0")
m_O = legal_moves_matching_san_prefix(board, "O-O")
assert {m.uci() for m in m_o} == {m.uci() for m in m_O}


def test_longest_common_prefix_completion():
board = chess.Board()
assert longest_matching_san_prefix(board, "N") == "N"
p = longest_matching_san_prefix(board, "e")
assert p.startswith("e")


def test_no_match_returns_empty():
board = chess.Board()
preview = analyze_move_input(board, "Qzz")
assert preview.from_squares == frozenset()
assert longest_matching_san_prefix(board, "Qzz") == ""
6 changes: 6 additions & 0 deletions src/cli_chess/utils/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,9 @@ class Keys(Enum):
SHOW_MOVE_LIST_IN_UNICODE = "show_move_list_in_unicode"
SHOW_MATERIAL_DIFF_IN_UNICODE = "show_material_diff_in_unicode"
PAD_UNICODE = "pad_unicode"
LIVE_MOVE_INPUT_HIGHLIGHTS = "live_move_input_highlights"
LIVE_MOVE_INPUT_SHOW_TARGETS = "live_move_input_show_targets"
LIVE_MOVE_INPUT_AUTOCOMPLETE = "live_move_input_autocomplete"

@property
def default_value(self):
Expand All @@ -245,6 +248,9 @@ def default_value(self):
self.SHOW_MOVE_LIST_IN_UNICODE: False,
self.SHOW_MATERIAL_DIFF_IN_UNICODE: True,
self.PAD_UNICODE: True,
self.LIVE_MOVE_INPUT_HIGHLIGHTS: False,
self.LIVE_MOVE_INPUT_SHOW_TARGETS: False,
self.LIVE_MOVE_INPUT_AUTOCOMPLETE: False,
}
return default_lookup[self]

Expand Down
Loading
Loading