diff --git a/src/shellmate/tui/app.py b/src/shellmate/tui/app.py index 36637e6..6a61bd9 100644 --- a/src/shellmate/tui/app.py +++ b/src/shellmate/tui/app.py @@ -53,21 +53,47 @@ class GameScreen(Screen): GameScreen { layout: grid; grid-size: 2; - grid-columns: 2fr 1fr; - padding: 1; + grid-columns: 3fr 1fr; + grid-rows: 1fr; + padding: 0; } #board-container { align: center middle; - padding: 1; + padding: 0; + width: 100%; + height: 100%; + min-width: 40; + min-height: 22; + } + + #chess-board { + width: auto; + height: auto; + min-width: 36; + min-height: 20; } #sidebar { padding: 1; + min-width: 20; + max-width: 30; + border-left: solid #2a3a2a; + } + + #game-status { + height: auto; + max-height: 8; + } + + #move-list { + height: 1fr; + min-height: 8; } #move-input { dock: bottom; + height: auto; } .hint-text { diff --git a/src/shellmate/tui/widgets/board.py b/src/shellmate/tui/widgets/board.py index 453916f..ece4306 100644 --- a/src/shellmate/tui/widgets/board.py +++ b/src/shellmate/tui/widgets/board.py @@ -1,36 +1,60 @@ -"""Chess board widget with Unicode pieces.""" +"""Chess board widget with Unicode pieces - Hyper-polished version.""" from textual.widget import Widget from textual.reactive import reactive +from textual.geometry import Size from rich.text import Text from rich.style import Style from rich.console import RenderableType import chess -# Unicode chess pieces -PIECES = { +# Unicode chess pieces - using filled variants for better visibility +PIECES_FILLED = { + 'K': '♚', 'Q': '♛', 'R': '♜', 'B': '♝', 'N': '♞', 'P': '♟', + 'k': '♚', 'q': '♛', 'r': '♜', 'b': '♝', 'n': '♞', 'p': '♟', +} + +# Alternative set with outlined white pieces +PIECES_OUTLINE = { 'K': '♔', 'Q': '♕', 'R': '♖', 'B': '♗', 'N': '♘', 'P': '♙', 'k': '♚', 'q': '♛', 'r': '♜', 'b': '♝', 'n': '♞', 'p': '♟', } -# Colors -LIGHT_SQUARE = "#3d5a45" -DARK_SQUARE = "#2d4235" -LIGHT_SQUARE_SELECTED = "#5d8a65" -DARK_SQUARE_SELECTED = "#4d7255" -HIGHLIGHT_MOVE = "#6b8f71" +# Colors - refined palette +LIGHT_SQUARE = "#b8c6a0" # Sage green light +DARK_SQUARE = "#6d8b5e" # Forest green dark +LIGHT_SQUARE_SELECTED = "#e8d44d" # Gold highlight +DARK_SQUARE_SELECTED = "#d4c02a" # Darker gold +HIGHLIGHT_LAST_MOVE = "#a8c878" # Bright green for last move +HIGHLIGHT_LEGAL = "#7ba35a" # Legal move dots WHITE_PIECE = "#ffffff" BLACK_PIECE = "#1a1a1a" +BORDER_COLOR = "#4a5d4a" +LABEL_COLOR = "#8faf7f" + +# Box drawing characters for clean borders +BOX = { + 'tl': '┌', 'tr': '┐', 'bl': '└', 'br': '┘', + 'h': '─', 'v': '│', + 'tj': '┬', 'bj': '┴', 'lj': '├', 'rj': '┤', + 'x': '┼', + # Heavy variants for outer border + 'TL': '╔', 'TR': '╗', 'BL': '╚', 'BR': '╝', + 'H': '═', 'V': '║', + 'TJ': '╤', 'BJ': '╧', 'LJ': '╟', 'RJ': '╢', +} class ChessBoardWidget(Widget): - """Interactive chess board widget.""" + """Interactive chess board widget with auto-sizing.""" DEFAULT_CSS = """ ChessBoardWidget { - width: 42; - height: 20; + width: 100%; + height: 100%; + min-width: 34; + min-height: 18; padding: 0; } """ @@ -40,6 +64,8 @@ class ChessBoardWidget(Widget): legal_moves: reactive[set] = reactive(set) last_move: reactive[tuple | None] = reactive(None) flipped: reactive[bool] = reactive(False) + use_heavy_border: reactive[bool] = reactive(True) + compact_mode: reactive[bool] = reactive(False) def __init__( self, @@ -48,71 +74,249 @@ class ChessBoardWidget(Widget): ): super().__init__(**kwargs) self.board = board or chess.Board() + self._cell_width = 3 # Will be calculated + self._cell_height = 1 + + def get_content_width(self, container: Size, viewport: Size) -> int: + """Calculate optimal width.""" + # Each cell is at least 3 chars wide + borders + labels + min_width = 8 * 3 + 9 + 4 # 8 cells * 3 + 9 borders + labels + return max(min_width, container.width) + + def get_content_height(self, container: Size, viewport: Size) -> int: + """Calculate optimal height.""" + # Each cell is 1-2 lines + borders + labels + return max(18, min(22, container.height)) + + def _calculate_cell_size(self, width: int, height: int) -> tuple[int, int]: + """Calculate cell dimensions based on available space.""" + # Available width for cells (minus borders and labels) + available_width = width - 4 # 2 for labels on each side + available_height = height - 4 # 2 for labels + 2 for borders + + # Calculate cell width (must be odd for centering) + cell_w = max(3, (available_width - 9) // 8) # -9 for internal borders + if cell_w % 2 == 0: + cell_w -= 1 + cell_w = min(cell_w, 7) # Cap at 7 for sanity + + # Calculate cell height + cell_h = max(1, (available_height - 9) // 8) + cell_h = min(cell_h, 3) + + return cell_w, cell_h def render(self) -> RenderableType: - """Render the chess board.""" - text = Text() + """Render the chess board with perfect alignment.""" + size = self.size + self._cell_width, self._cell_height = self._calculate_cell_size(size.width, size.height) - # File labels - files = " a b c d e f g h " if not self.flipped else " h g f e d c b a " - text.append(files + "\n", style="dim") + # Use compact mode for small terminals + if size.width < 40 or size.height < 20: + return self._render_compact() + else: + return self._render_standard() + + def _render_compact(self) -> RenderableType: + """Compact board for small terminals.""" + text = Text() + cw = 3 # Fixed cell width for compact + + files = "abcdefgh" if not self.flipped else "hgfedcba" + + # File labels - centered over cells + text.append(" ", style=f"dim {LABEL_COLOR}") + for f in files: + text.append(f" {f} ", style=f"dim {LABEL_COLOR}") + text.append("\n") # Top border - text.append(" ┌" + "───┬" * 7 + "───┐\n", style="dim") + border_style = Style(color=BORDER_COLOR) + text.append(" ╔", style=border_style) + text.append("═══╤" * 7 + "═══╗\n", style=border_style) ranks = range(7, -1, -1) if not self.flipped else range(8) for rank_idx, rank in enumerate(ranks): # Rank label - text.append(f"{rank + 1}│", style="dim") + text.append(f"{rank + 1}║", style=f"dim {LABEL_COLOR}") file_range = range(8) if not self.flipped else range(7, -1, -1) - for file in file_range: + for file_idx, file in enumerate(file_range): square = chess.square(file, rank) piece = self.board.piece_at(square) - # Determine square color + # Determine square styling is_light = (rank + file) % 2 == 1 is_selected = square == self.selected_square is_legal_target = square in self.legal_moves is_last_move = self.last_move and square in self.last_move if is_selected: - bg_color = LIGHT_SQUARE_SELECTED if is_light else DARK_SQUARE_SELECTED - elif is_legal_target or is_last_move: - bg_color = HIGHLIGHT_MOVE - else: - bg_color = LIGHT_SQUARE if is_light else DARK_SQUARE - - # Get piece character - if piece: - char = PIECES.get(piece.symbol(), '?') - fg_color = WHITE_PIECE if piece.color == chess.WHITE else BLACK_PIECE + bg = LIGHT_SQUARE_SELECTED if is_light else DARK_SQUARE_SELECTED + elif is_last_move: + bg = HIGHLIGHT_LAST_MOVE elif is_legal_target: - char = '·' - fg_color = "#888888" + bg = HIGHLIGHT_LEGAL + else: + bg = LIGHT_SQUARE if is_light else DARK_SQUARE + + # Piece or empty + if piece: + char = PIECES_OUTLINE.get(piece.symbol(), '?') + fg = WHITE_PIECE if piece.color == chess.WHITE else BLACK_PIECE + elif is_legal_target: + char = '•' + fg = "#505050" else: char = ' ' - fg_color = WHITE_PIECE + fg = WHITE_PIECE - style = Style(color=fg_color, bgcolor=bg_color) + style = Style(color=fg, bgcolor=bg, bold=True) text.append(f" {char} ", style=style) - if file != (0 if self.flipped else 7): - text.append("│", style="dim") + # Cell separator + if file_idx < 7: + text.append("│", style=border_style) - text.append(f"│{rank + 1}\n", style="dim") + text.append(f"║{rank + 1}\n", style=f"dim {LABEL_COLOR}") # Row separator if rank_idx < 7: - text.append(" ├" + "───┼" * 7 + "───┤\n", style="dim") + text.append(" ╟", style=border_style) + text.append("───┼" * 7 + "───╢\n", style=border_style) # Bottom border - text.append(" └" + "───┴" * 7 + "───┘\n", style="dim") + text.append(" ╚", style=border_style) + text.append("═══╧" * 7 + "═══╝\n", style=border_style) # File labels - text.append(files, style="dim") + text.append(" ", style=f"dim {LABEL_COLOR}") + for f in files: + text.append(f" {f} ", style=f"dim {LABEL_COLOR}") + + return text + + def _render_standard(self) -> RenderableType: + """Standard board with variable cell size.""" + text = Text() + cw = self._cell_width + ch = self._cell_height + + # Ensure odd width for centering + if cw % 2 == 0: + cw = max(3, cw - 1) + + files = "abcdefgh" if not self.flipped else "hgfedcba" + pad = cw // 2 + + border_style = Style(color=BORDER_COLOR) + label_style = Style(color=LABEL_COLOR, dim=True) + + # File labels - perfectly centered + text.append(" ", style=label_style) + for f in files: + text.append(" " * pad + f + " " * pad, style=label_style) + text.append(" ", style=label_style) # For border space + text.append("\n") + + # Top border with double lines + cell_border = BOX['H'] * cw + text.append(" " + BOX['TL'], style=border_style) + for i in range(8): + text.append(cell_border, style=border_style) + if i < 7: + text.append(BOX['TJ'], style=border_style) + text.append(BOX['TR'] + "\n", style=border_style) + + ranks = range(7, -1, -1) if not self.flipped else range(8) + + for rank_idx, rank in enumerate(ranks): + # Multi-line cells + for cell_line in range(ch): + is_middle = cell_line == ch // 2 + + # Rank label (only on middle line) + if is_middle: + text.append(f"{rank + 1}", style=label_style) + else: + text.append(" ", style=label_style) + + text.append(BOX['V'], style=border_style) + + file_range = range(8) if not self.flipped else range(7, -1, -1) + + for file_idx, file in enumerate(file_range): + square = chess.square(file, rank) + piece = self.board.piece_at(square) + + # Square styling + is_light = (rank + file) % 2 == 1 + is_selected = square == self.selected_square + is_legal_target = square in self.legal_moves + is_last_move = self.last_move and square in self.last_move + + if is_selected: + bg = LIGHT_SQUARE_SELECTED if is_light else DARK_SQUARE_SELECTED + elif is_last_move: + bg = HIGHLIGHT_LAST_MOVE + elif is_legal_target: + bg = HIGHLIGHT_LEGAL + else: + bg = LIGHT_SQUARE if is_light else DARK_SQUARE + + # Content (only on middle line) + if is_middle: + if piece: + char = PIECES_OUTLINE.get(piece.symbol(), '?') + fg = WHITE_PIECE if piece.color == chess.WHITE else BLACK_PIECE + elif is_legal_target: + char = '•' + fg = "#404040" + else: + char = ' ' + fg = WHITE_PIECE + else: + char = ' ' + fg = WHITE_PIECE + + style = Style(color=fg, bgcolor=bg, bold=True) + cell_content = " " * pad + char + " " * pad + text.append(cell_content, style=style) + + # Separator + if file_idx < 7: + text.append(BOX['v'], style=border_style) + + # Right border and rank label + if is_middle: + text.append(f"{BOX['V']}{rank + 1}\n", style=label_style) + else: + text.append(f"{BOX['V']} \n", style=border_style) + + # Row separator + if rank_idx < 7: + text.append(" " + BOX['LJ'], style=border_style) + for i in range(8): + text.append(BOX['h'] * cw, style=border_style) + if i < 7: + text.append(BOX['x'], style=border_style) + text.append(BOX['RJ'] + "\n", style=border_style) + + # Bottom border + text.append(" " + BOX['BL'], style=border_style) + for i in range(8): + text.append(BOX['H'] * cw, style=border_style) + if i < 7: + text.append(BOX['BJ'], style=border_style) + text.append(BOX['BR'] + "\n", style=border_style) + + # File labels + text.append(" ", style=label_style) + for f in files: + text.append(" " * pad + f + " " * pad, style=label_style) + text.append(" ", style=label_style) return text