mcfreecell/linux-freecell.py

1215 lines
40 KiB
Python
Raw Normal View History

2026-03-18 17:25:39 -04:00
#!/usr/bin/env python3
"""
linux_freecell.py
A curses-based FreeCell prototype that uses Linux distro themed suits.
Controls
--------
Arrow keys / h j k l : Move cursor
2026-03-18 17:59:01 -04:00
v : Enter / exit tableau visual selection
y : Pick up selected card / stack
p : Drop held card / stack
2026-03-18 19:03:02 -04:00
? : Show a suggested move
2026-03-18 17:25:39 -04:00
Space or Enter : Pick up / drop a card or stack
f : Quick move selected card to a free cell
d : Quick move selected card to foundation
Esc : Cancel held move
q : Quit
Notes
-----
- This is a prototype, not a complete polished game.
- It assumes your terminal can display the suit glyphs you choose.
- If a Nerd Font glyph does not render correctly, replace the glyph strings
in SUITS below with safer ASCII or Unicode symbols.
"""
import curses
import random
from dataclasses import dataclass
from typing import List, Optional, Tuple
# ---------------------------------------------------------------------------
# Card model
# ---------------------------------------------------------------------------
RANKS = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"]
RANK_VALUES = {rank: i + 1 for i, rank in enumerate(RANKS)}
@dataclass(frozen=True)
class SuitInfo:
"""Describes one suit in our Linux-themed deck."""
name: str
glyph: str
color_group: str # "red" or "black"
@dataclass
class Card:
"""Represents one playing card."""
rank: str
suit: SuitInfo
@property
def value(self) -> int:
return RANK_VALUES[self.rank]
@property
def color_group(self) -> str:
return self.suit.color_group
def short_name(self) -> str:
return f"{self.rank}{self.suit.glyph}"
# ---------------------------------------------------------------------------
# Suit set
#
# Replace glyphs below with Nerd Font icons you prefer.
# These defaults are deliberately conservative so they are more likely to
# render in ordinary terminals.
# ---------------------------------------------------------------------------
SUITS = [
SuitInfo(name="Debian", glyph="", color_group="red"),
SuitInfo(name="Gentoo", glyph="", color_group="red"),
SuitInfo(name="Arch", glyph="󰣇", color_group="black"),
SuitInfo(name="Slackware", glyph="", color_group="black"),
]
# ---------------------------------------------------------------------------
# Board / game state
# ---------------------------------------------------------------------------
@dataclass
class HeldCards:
"""Represents one or more cards currently picked up by the player."""
cards: List[Card]
source_type: str # "col" or "free"
source_index: int
2026-03-18 19:03:02 -04:00
@dataclass(frozen=True)
class HintMove:
"""Describes one suggested legal move for the current board."""
source_type: str
source_index: int
dest_type: str
dest_index: int
cards: Tuple[Card, ...]
score: int
2026-03-18 17:25:39 -04:00
class FreeCellGame:
"""Holds the state and rules for a small FreeCell prototype."""
def __init__(self) -> None:
self.columns: List[List[Card]] = [[] for _ in range(8)]
self.freecells: List[Optional[Card]] = [None] * 4
self.foundations: List[List[Card]] = [[] for _ in range(4)]
self.held: Optional[HeldCards] = None
2026-03-18 19:03:02 -04:00
self.hint_move: Optional[HintMove] = None
2026-03-18 17:59:01 -04:00
self.visual_mode = False
self.visual_row = 0
2026-03-18 17:25:39 -04:00
# Cursor zones:
# "top" -> freecells + foundations
# "bottom" -> tableau columns
self.cursor_zone = "bottom"
self.cursor_index = 0
2026-03-18 19:03:02 -04:00
self.status = "Arrow keys move, v selects a stack, ? shows a hint."
2026-03-18 17:25:39 -04:00
self._deal_new_game()
2026-03-18 19:03:02 -04:00
def clear_hint(self) -> None:
"""Drop the currently remembered hint highlight."""
self.hint_move = None
2026-03-18 17:25:39 -04:00
def _deal_new_game(self) -> None:
"""Build and shuffle a standard 52-card deck, then deal to 8 columns."""
deck: List[Card] = [Card(rank, suit) for suit in SUITS for rank in RANKS]
random.shuffle(deck)
for i, card in enumerate(deck):
self.columns[i % 8].append(card)
# -----------------------------------------------------------------------
# Rules helpers
# -----------------------------------------------------------------------
def is_valid_tableau_sequence(self, cards: List[Card]) -> bool:
"""Return True if cards form a descending alternating-color stack."""
if not cards:
return False
for upper, lower in zip(cards, cards[1:]):
if upper.value != lower.value + 1:
return False
if upper.color_group == lower.color_group:
return False
return True
def count_empty_freecells(self) -> int:
"""Count currently open freecells."""
return sum(1 for cell in self.freecells if cell is None)
def count_empty_columns(self, exclude_index: Optional[int] = None) -> int:
"""Count empty tableau columns, optionally excluding one column."""
empty_count = 0
for col_index, column in enumerate(self.columns):
if col_index == exclude_index:
continue
if not column:
empty_count += 1
return empty_count
def max_movable_cards(self, source_col: int, moving_count: int) -> int:
"""Return the maximum legal stack size movable from a tableau column."""
source_becomes_empty = moving_count == len(self.columns[source_col])
empty_columns = self.count_empty_columns(
exclude_index=source_col if source_becomes_empty else None
)
return (self.count_empty_freecells() + 1) * (2**empty_columns)
2026-03-18 17:59:01 -04:00
def valid_tail_start_index(self, col_index: int) -> Optional[int]:
"""Return the first row of the bottom valid descending alternating tail."""
column = self.columns[col_index]
if not column:
return None
start = len(column) - 1
while start > 0:
upper = column[start - 1]
lower = column[start]
if upper.value != lower.value + 1:
break
if upper.color_group == lower.color_group:
break
start -= 1
return start
def current_visual_stack(self) -> Tuple[List[Card], str]:
"""Return the currently highlighted tableau stack while in visual mode."""
if self.cursor_zone != "bottom":
return [], "Visual mode only works on tableau columns."
column = self.columns[self.cursor_index]
if not column:
return [], "That column is empty."
tail_start = self.valid_tail_start_index(self.cursor_index)
if tail_start is None:
return [], "That column is empty."
start_index = max(tail_start, min(self.visual_row, len(column) - 1))
cards = column[start_index:]
max_cards = self.max_movable_cards(self.cursor_index, len(cards))
if len(cards) > max_cards:
return cards, f"Move limit is {max_cards} cards with current free space."
return cards, ""
2026-03-18 17:25:39 -04:00
def selected_stack_from_column(self, col_index: int) -> Tuple[List[Card], str]:
"""Return the currently selected stack slice for a tableau column."""
column = self.columns[col_index]
if not column:
return [], "That column is empty."
2026-03-18 17:59:01 -04:00
if self.visual_mode and self.cursor_zone == "bottom":
return self.current_visual_stack()
2026-03-18 17:25:39 -04:00
2026-03-18 17:59:01 -04:00
cards = [column[-1]]
2026-03-18 17:25:39 -04:00
return cards, ""
def can_place_stack_on_column(self, cards: List[Card], col_index: int) -> bool:
"""Return True if a valid held stack can be dropped on a tableau column."""
if not self.is_valid_tableau_sequence(cards):
return False
column = self.columns[col_index]
if not column:
return True
moving_top = cards[0]
destination_top = column[-1]
return (
destination_top.value == moving_top.value + 1
and destination_top.color_group != moving_top.color_group
)
def can_place_on_column(self, card: Card, col_index: int) -> bool:
"""
FreeCell tableau rule:
- Empty column accepts any card.
- Otherwise, placed card must be one rank lower than destination top,
and opposite color group.
"""
column = self.columns[col_index]
if not column:
return True
top = column[-1]
return top.value == card.value + 1 and top.color_group != card.color_group
def can_move_to_foundation(self, card: Card) -> bool:
"""
Foundation rule:
- Each suit builds upward from Ace to King.
"""
foundation = self.foundation_for_suit(card.suit.name)
if not foundation:
return card.rank == "A"
top = foundation[-1]
return top.suit.name == card.suit.name and card.value == top.value + 1
def foundation_for_suit(self, suit_name: str) -> List[Card]:
"""Return the foundation pile corresponding to a given suit."""
suit_index = next(i for i, s in enumerate(SUITS) if s.name == suit_name)
return self.foundations[suit_index]
def first_empty_freecell(self) -> Optional[int]:
"""Return the first free freecell slot, or None if full."""
for i, cell in enumerate(self.freecells):
if cell is None:
return i
return None
2026-03-18 19:03:02 -04:00
def count_hidden_low_cards(self) -> int:
"""Count aces and twos that are not currently exposed."""
count = 0
for column in self.columns:
for card in column[:-1]:
if card.value <= 2:
count += 1
return count
def card_can_help_foundation(self, card: Card) -> bool:
"""Return True for low cards that usually unlock early progress."""
return card.value <= 3
def describe_hint_move(self, move: HintMove) -> str:
"""Return a compact human-readable hint description."""
if move.source_type == "col":
source_name = f"column {move.source_index + 1}"
else:
source_name = f"free cell {move.source_index + 1}"
if move.dest_type == "col":
dest_name = f"column {move.dest_index + 1}"
elif move.dest_type == "free":
dest_name = f"free cell {move.dest_index + 1}"
else:
dest_name = f"{SUITS[move.dest_index].name} foundation"
if len(move.cards) == 1:
return (
f"move {move.cards[0].short_name()} from {source_name} to {dest_name}"
)
return (
f"move {len(move.cards)} cards {move.cards[0].short_name()}..{move.cards[-1].short_name()} "
f"from {source_name} to {dest_name}"
)
def score_hint_move(
self,
source_type: str,
source_index: int,
dest_type: str,
dest_index: int,
cards: List[Card],
) -> int:
"""Assign a cheap deterministic score to a legal move."""
score = 0
moving_top = cards[0]
source_column = self.columns[source_index] if source_type == "col" else None
source_exposes = None
hidden_low_before = self.count_hidden_low_cards()
if source_type == "col":
assert source_column is not None
remaining = source_column[: -len(cards)]
source_exposes = remaining[-1] if remaining else None
if not remaining:
score += 250
if source_exposes and self.card_can_help_foundation(source_exposes):
score += 320
if dest_type == "foundation":
score += 1000 + moving_top.value * 5
elif dest_type == "col":
destination = self.columns[dest_index]
if destination:
score += 220 + len(cards) * 25
if len(cards) > 1:
score += 120
else:
score += 60 if len(cards) > 1 else -120
elif dest_type == "free":
score -= 80
if moving_top.value <= 3:
score += 140
if source_exposes and self.can_move_to_foundation(source_exposes):
score += 180
if moving_top.value <= 3:
score += 80
hidden_low_after = hidden_low_before
if source_type == "col" and source_exposes and source_exposes.value <= 2:
hidden_low_after -= 1
score += (hidden_low_before - hidden_low_after) * 120
if source_type == "free" and dest_type == "col":
score += 200
return score
def enumerate_hint_moves(self) -> List[HintMove]:
"""Enumerate cheap one-ply legal moves for hinting."""
moves: List[HintMove] = []
for col_index, column in enumerate(self.columns):
if not column:
continue
top_card = column[-1]
if self.can_move_to_foundation(top_card):
cards = (top_card,)
moves.append(
HintMove(
source_type="col",
source_index=col_index,
dest_type="foundation",
dest_index=SUITS.index(top_card.suit),
cards=cards,
score=self.score_hint_move(
"col",
col_index,
"foundation",
SUITS.index(top_card.suit),
[top_card],
),
)
)
free_idx = self.first_empty_freecell()
if free_idx is not None:
cards = (top_card,)
moves.append(
HintMove(
source_type="col",
source_index=col_index,
dest_type="free",
dest_index=free_idx,
cards=cards,
score=self.score_hint_move(
"col", col_index, "free", free_idx, [top_card]
),
)
)
tail_start = self.valid_tail_start_index(col_index)
if tail_start is None:
continue
for start_index in range(len(column) - 1, tail_start - 1, -1):
cards_list = column[start_index:]
if len(cards_list) > self.max_movable_cards(col_index, len(cards_list)):
continue
for dest_index in range(8):
if dest_index == col_index:
continue
if self.can_place_stack_on_column(cards_list, dest_index):
moves.append(
HintMove(
source_type="col",
source_index=col_index,
dest_type="col",
dest_index=dest_index,
cards=tuple(cards_list),
score=self.score_hint_move(
"col", col_index, "col", dest_index, cards_list
),
)
)
for free_index, card in enumerate(self.freecells):
if card is None:
continue
if self.can_move_to_foundation(card):
moves.append(
HintMove(
source_type="free",
source_index=free_index,
dest_type="foundation",
dest_index=SUITS.index(card.suit),
cards=(card,),
score=self.score_hint_move(
"free",
free_index,
"foundation",
SUITS.index(card.suit),
[card],
),
)
)
for dest_index in range(8):
if self.can_place_stack_on_column([card], dest_index):
moves.append(
HintMove(
source_type="free",
source_index=free_index,
dest_type="col",
dest_index=dest_index,
cards=(card,),
score=self.score_hint_move(
"free", free_index, "col", dest_index, [card]
),
)
)
moves.sort(key=lambda move: (-move.score, self.describe_hint_move(move)))
return moves
def show_hint(self) -> None:
"""Pick the best cheap legal move and remember it for highlighting."""
if self.held is not None:
self.status = "Drop or cancel the held cards before asking for a hint."
return
moves = self.enumerate_hint_moves()
if not moves:
self.hint_move = None
self.status = "Hint: no obvious legal move found."
return
self.hint_move = moves[0]
self.status = f"Hint: {self.describe_hint_move(self.hint_move)}."
def hinted_freecell(self, index: int) -> bool:
"""Return True when a freecell is part of the current hint."""
if self.hint_move is None:
return False
return (
self.hint_move.source_type == "free"
and self.hint_move.source_index == index
) or (self.hint_move.dest_type == "free" and self.hint_move.dest_index == index)
def hinted_foundation(self, index: int) -> bool:
"""Return True when a foundation is the current hint destination."""
return (
self.hint_move is not None
and self.hint_move.dest_type == "foundation"
and self.hint_move.dest_index == index
)
def hinted_column_source_start(self, index: int) -> Optional[int]:
"""Return the start row for a hinted tableau source stack."""
if self.hint_move is None:
return None
if self.hint_move.source_type != "col" or self.hint_move.source_index != index:
return None
return len(self.columns[index]) - len(self.hint_move.cards)
def hinted_column_destination(self, index: int) -> bool:
"""Return True when a tableau column is the hinted destination."""
return (
self.hint_move is not None
and self.hint_move.dest_type == "col"
and self.hint_move.dest_index == index
)
2026-03-18 17:59:01 -04:00
def enter_visual_mode(self) -> None:
"""Enter tableau visual selection mode on the current column."""
if self.held is not None:
self.status = "Drop the held cards before selecting a new stack."
return
2026-03-18 17:25:39 -04:00
if self.cursor_zone != "bottom":
2026-03-18 17:59:01 -04:00
self.status = "Visual mode only works on tableau columns."
2026-03-18 17:25:39 -04:00
return
column = self.columns[self.cursor_index]
if not column:
self.status = "That column is empty."
return
2026-03-18 17:59:01 -04:00
self.visual_mode = True
self.visual_row = len(column) - 1
cards, reason = self.current_visual_stack()
if reason:
self.status = f"Visual: {len(cards)} cards selected. {reason}"
else:
self.status = (
f"Visual: {len(cards)} card{'s' if len(cards) != 1 else ''} selected."
)
def exit_visual_mode(self, message: Optional[str] = None) -> None:
"""Leave tableau visual selection mode."""
self.visual_mode = False
self.visual_row = 0
if message is not None:
self.status = message
def move_visual_selection(self, delta: int) -> None:
"""Adjust the visual selection start within the valid tail."""
if not self.visual_mode:
self.status = "Press v to start selecting a stack."
return
column = self.columns[self.cursor_index]
tail_start = self.valid_tail_start_index(self.cursor_index)
if not column or tail_start is None:
self.exit_visual_mode("That column is empty.")
return
old_row = self.visual_row
self.visual_row = max(tail_start, min(len(column) - 1, self.visual_row + delta))
cards, reason = self.current_visual_stack()
2026-03-18 17:25:39 -04:00
2026-03-18 17:59:01 -04:00
if self.visual_row == old_row:
self.status = f"Visual selection stays at {len(cards)} card{'s' if len(cards) != 1 else ''}."
2026-03-18 17:25:39 -04:00
return
if reason:
2026-03-18 17:59:01 -04:00
self.status = f"Visual: {len(cards)} cards selected. {reason}"
2026-03-18 17:25:39 -04:00
else:
self.status = (
2026-03-18 17:59:01 -04:00
f"Visual: {len(cards)} card{'s' if len(cards) != 1 else ''} selected."
2026-03-18 17:25:39 -04:00
)
# -----------------------------------------------------------------------
# Movement / interactions
# -----------------------------------------------------------------------
def pick_up(self) -> None:
"""
Pick up the bottom visible card from either:
- a tableau column
- a freecell slot
"""
if self.held is not None:
self.status = "You are already holding cards."
return
if self.cursor_zone == "bottom":
cards, reason = self.selected_stack_from_column(self.cursor_index)
if not cards:
self.status = reason
return
if reason:
self.status = reason
return
moving_count = len(cards)
2026-03-18 19:03:02 -04:00
self.clear_hint()
2026-03-18 17:25:39 -04:00
del self.columns[self.cursor_index][-moving_count:]
self.held = HeldCards(
cards=cards, source_type="col", source_index=self.cursor_index
)
if moving_count == 1:
self.status = f"Picked up {cards[0].short_name()} from column {self.cursor_index + 1}."
else:
self.status = f"Picked up {moving_count} cards from column {self.cursor_index + 1}."
2026-03-18 17:59:01 -04:00
self.exit_visual_mode()
2026-03-18 17:25:39 -04:00
return
# top zone: indexes 0..3 freecells, 4..7 foundations
if self.cursor_index < 4:
card = self.freecells[self.cursor_index]
if card is None:
self.status = "That free cell is empty."
return
2026-03-18 19:03:02 -04:00
self.clear_hint()
2026-03-18 17:25:39 -04:00
self.freecells[self.cursor_index] = None
self.held = HeldCards(
cards=[card], source_type="free", source_index=self.cursor_index
)
self.status = (
f"Picked up {card.short_name()} from free cell {self.cursor_index + 1}."
)
return
self.status = "Cannot pick up directly from foundations."
def restore_held(self) -> None:
"""Put the held card back where it came from if a drop fails."""
if self.held is None:
return
cards = self.held.cards
if self.held.source_type == "col":
self.columns[self.held.source_index].extend(cards)
elif self.held.source_type == "free":
self.freecells[self.held.source_index] = cards[0]
self.held = None
2026-03-18 19:03:02 -04:00
self.clear_hint()
2026-03-18 17:25:39 -04:00
def drop(self) -> None:
"""
Drop the held card onto:
- a column
- a freecell
- a foundation
depending on the current cursor position and legal rules.
"""
if self.held is None:
self.status = "You are not holding a card."
return
cards = self.held.cards
moving_count = len(cards)
moving_top = cards[0]
if self.cursor_zone == "bottom":
if self.can_place_stack_on_column(cards, self.cursor_index):
2026-03-18 19:03:02 -04:00
self.clear_hint()
2026-03-18 17:25:39 -04:00
self.columns[self.cursor_index].extend(cards)
if moving_count == 1:
self.status = f"Placed {moving_top.short_name()} on column {self.cursor_index + 1}."
else:
self.status = f"Placed {moving_count} cards on column {self.cursor_index + 1}."
self.held = None
else:
self.status = "Illegal move for tableau column."
return
# top zone
if self.cursor_index < 4:
# drop to freecell
if moving_count != 1:
self.status = "Free cells accept only one card."
return
if self.freecells[self.cursor_index] is None:
2026-03-18 19:03:02 -04:00
self.clear_hint()
2026-03-18 17:25:39 -04:00
self.freecells[self.cursor_index] = moving_top
self.status = f"Placed {moving_top.short_name()} in free cell {self.cursor_index + 1}."
self.held = None
else:
self.status = "That free cell is occupied."
return
# drop to foundation
if moving_count != 1:
self.status = "Foundations accept only one card at a time."
return
if self.can_move_to_foundation(moving_top):
2026-03-18 19:03:02 -04:00
self.clear_hint()
2026-03-18 17:25:39 -04:00
foundation = self.foundation_for_suit(moving_top.suit.name)
foundation.append(moving_top)
self.status = f"Moved {moving_top.short_name()} to foundation."
self.held = None
else:
self.status = "Illegal move for foundation."
def quick_to_freecell(self) -> None:
"""
Move the currently selected bottom/freecell card into the first open
freecell, if possible.
"""
if self.held is not None:
self.status = "Drop the held card first."
return
free_idx = self.first_empty_freecell()
if free_idx is None:
self.status = "No free freecells available."
return
if self.cursor_zone == "bottom":
col = self.columns[self.cursor_index]
if not col:
self.status = "That column is empty."
return
2026-03-18 17:59:01 -04:00
if self.visual_mode:
2026-03-18 17:25:39 -04:00
self.status = "Quick freecell move uses only the bottom card."
return
2026-03-18 19:03:02 -04:00
self.clear_hint()
2026-03-18 17:25:39 -04:00
self.freecells[free_idx] = col.pop()
self.status = f"Moved card to free cell {free_idx + 1}."
return
if self.cursor_zone == "top" and self.cursor_index < 4:
self.status = "Already on a free cell."
return
self.status = "Select a tableau card first."
def quick_to_foundation(self) -> None:
"""
Move the selected card directly to foundation if legal.
Works from:
- column bottoms
- freecells
"""
if self.held is not None:
self.status = "Drop the held card first."
return
card: Optional[Card] = None
source: Optional[Tuple[str, int]] = None
if self.cursor_zone == "bottom":
col = self.columns[self.cursor_index]
if col:
2026-03-18 17:59:01 -04:00
if self.visual_mode:
2026-03-18 17:25:39 -04:00
self.status = "Quick foundation move uses only the bottom card."
return
card = col[-1]
source = ("col", self.cursor_index)
elif self.cursor_zone == "top" and self.cursor_index < 4:
fc = self.freecells[self.cursor_index]
if fc is not None:
card = fc
source = ("free", self.cursor_index)
if card is None or source is None:
self.status = "No movable card selected."
return
if not self.can_move_to_foundation(card):
self.status = "That card cannot go to foundation yet."
return
foundation = self.foundation_for_suit(card.suit.name)
2026-03-18 19:03:02 -04:00
self.clear_hint()
2026-03-18 17:25:39 -04:00
foundation.append(card)
if source[0] == "col":
self.columns[source[1]].pop()
else:
self.freecells[source[1]] = None
self.status = f"Moved {card.short_name()} to foundation."
def is_won(self) -> bool:
"""Return True when all cards are in foundations."""
return sum(len(f) for f in self.foundations) == 52
# ---------------------------------------------------------------------------
# UI rendering
# ---------------------------------------------------------------------------
CARD_W = 7
TOP_Y = 1
COL_Y = 6
def init_colors() -> None:
"""Initialize curses color pairs."""
curses.start_color()
curses.use_default_colors()
# Pair 1: regular text
curses.init_pair(1, curses.COLOR_WHITE, -1)
# Pair 2: red cards / symbols
curses.init_pair(2, curses.COLOR_RED, -1)
# Pair 3: black-group cards rendered as cyan for visibility on dark terms
curses.init_pair(3, curses.COLOR_CYAN, -1)
# Pair 4: highlighted cursor
curses.init_pair(4, curses.COLOR_BLACK, curses.COLOR_YELLOW)
# Pair 5: held card
curses.init_pair(5, curses.COLOR_BLACK, curses.COLOR_GREEN)
2026-03-18 19:03:02 -04:00
# Pair 6: hint highlight
curses.init_pair(6, curses.COLOR_BLACK, curses.COLOR_CYAN)
2026-03-18 17:25:39 -04:00
def card_color_pair(card: Card) -> int:
"""Choose a color pair for a card."""
return 2 if card.color_group == "red" else 3
def draw_box(stdscr, y: int, x: int, w: int, label: str, attr: int) -> None:
"""Draw a simple one-line boxed area."""
text = f"[{label:^{w - 2}}]"
stdscr.addnstr(y, x, text.ljust(w), w, attr)
def draw_card_label(card: Optional[Card]) -> str:
"""Return a compact label used to display a card or empty slot."""
if card is None:
return " "
return card.short_name()
def draw_top_row(stdscr, game: FreeCellGame) -> None:
"""Draw freecells and foundations."""
2026-03-18 21:03:31 -04:00
stdscr.addstr(TOP_Y, 2, "Free Cells", curses.color_pair(1) | curses.A_BOLD)
stdscr.addstr(TOP_Y, 40, "Foundations", curses.color_pair(1) | curses.A_BOLD)
2026-03-18 17:25:39 -04:00
# Freecells at indexes 0..3
for i in range(4):
x = 2 + i * (CARD_W + 1)
selected = game.cursor_zone == "top" and game.cursor_index == i
2026-03-18 19:03:02 -04:00
hinted = game.hinted_freecell(i)
attr = (
curses.color_pair(4)
if selected
else curses.color_pair(6)
if hinted
else curses.color_pair(1)
)
2026-03-18 17:25:39 -04:00
card = game.freecells[i]
2026-03-18 21:03:31 -04:00
draw_box(stdscr, TOP_Y + 1, x, CARD_W, draw_card_label(card), attr)
2026-03-18 17:25:39 -04:00
if card:
stdscr.addnstr(
2026-03-18 21:03:31 -04:00
TOP_Y + 1,
2026-03-18 17:25:39 -04:00
x + 1,
card.short_name(),
CARD_W - 2,
curses.color_pair(card_color_pair(card)),
)
# Foundations at indexes 4..7 in cursor space
for i in range(4):
x = 40 + i * (CARD_W + 1)
selected = game.cursor_zone == "top" and game.cursor_index == i + 4
2026-03-18 19:03:02 -04:00
hinted = game.hinted_foundation(i)
attr = (
curses.color_pair(4)
if selected
else curses.color_pair(6)
if hinted
else curses.color_pair(1)
)
2026-03-18 17:25:39 -04:00
foundation = game.foundations[i]
top_card = foundation[-1] if foundation else None
label = draw_card_label(top_card) if top_card else SUITS[i].glyph
2026-03-18 21:03:31 -04:00
draw_box(stdscr, TOP_Y + 1, x, CARD_W, label, attr)
2026-03-18 17:25:39 -04:00
if top_card:
stdscr.addnstr(
2026-03-18 21:03:31 -04:00
TOP_Y + 1,
2026-03-18 17:25:39 -04:00
x + 1,
top_card.short_name(),
CARD_W - 2,
curses.color_pair(card_color_pair(top_card)),
)
else:
# Show suit glyph placeholder in foundation slot color
dummy_color = 2 if SUITS[i].color_group == "red" else 3
stdscr.addnstr(
2026-03-18 21:03:31 -04:00
TOP_Y + 1, x + 2, SUITS[i].glyph, 1, curses.color_pair(dummy_color)
2026-03-18 17:25:39 -04:00
)
def draw_columns(stdscr, game: FreeCellGame) -> None:
"""Draw the tableau columns."""
stdscr.addstr(COL_Y - 1, 2, "Tableau", curses.color_pair(1) | curses.A_BOLD)
max_height = max((len(col) for col in game.columns), default=0)
for col_idx in range(8):
x = 2 + col_idx * 9
selected = game.cursor_zone == "bottom" and game.cursor_index == col_idx
2026-03-18 19:03:02 -04:00
hinted_dest = game.hinted_column_destination(col_idx)
header_attr = (
curses.color_pair(4)
if selected
else curses.color_pair(6)
if hinted_dest
else curses.color_pair(1)
)
2026-03-18 17:25:39 -04:00
draw_box(stdscr, COL_Y, x, CARD_W, str(col_idx + 1), header_attr)
column = game.columns[col_idx]
if not column:
stdscr.addstr(COL_Y + 1, x + 2, ".", curses.color_pair(1))
continue
selected_cards: List[Card] = []
selection_reason = ""
2026-03-18 17:59:01 -04:00
valid_tail_start = game.valid_tail_start_index(col_idx)
if valid_tail_start is None:
valid_tail_start = len(column)
2026-03-18 19:03:02 -04:00
hint_source_start = game.hinted_column_source_start(col_idx)
2026-03-18 17:59:01 -04:00
if selected and game.visual_mode:
2026-03-18 17:25:39 -04:00
selected_cards, selection_reason = game.selected_stack_from_column(col_idx)
selected_start = (
len(column) - len(selected_cards) if selected_cards else len(column)
)
for row_idx, card in enumerate(column):
y = COL_Y + 1 + row_idx
2026-03-18 17:59:01 -04:00
in_valid_tail = (
selected and game.visual_mode and row_idx >= valid_tail_start
)
in_selected_stack = (
selected and game.visual_mode and row_idx >= selected_start
)
2026-03-18 19:03:02 -04:00
in_hint_source = (
hint_source_start is not None and row_idx >= hint_source_start
)
2026-03-18 17:25:39 -04:00
if in_selected_stack:
attr = curses.color_pair(4)
if selection_reason:
attr |= curses.A_DIM
else:
attr |= curses.A_BOLD
2026-03-18 19:03:02 -04:00
elif in_hint_source:
attr = curses.color_pair(6) | curses.A_BOLD
2026-03-18 17:59:01 -04:00
elif in_valid_tail:
attr = curses.color_pair(card_color_pair(card)) | curses.A_DIM
elif selected and row_idx == len(column) - 1:
attr = curses.color_pair(4)
2026-03-18 17:25:39 -04:00
else:
attr = curses.color_pair(card_color_pair(card))
stdscr.addnstr(
y, x + 1, card.short_name().ljust(CARD_W - 2), CARD_W - 2, attr
)
# Clear a few lines under the tallest used area to avoid visual leftovers.
for extra_y in range(COL_Y + 2 + max_height, COL_Y + 8 + max_height):
stdscr.move(extra_y, 0)
stdscr.clrtoeol()
def draw_held(stdscr, game: FreeCellGame, max_y: int) -> None:
"""Draw the currently held card stack near the bottom of the screen."""
y = max_y - 3
stdscr.move(y, 0)
stdscr.clrtoeol()
if game.held is None:
stdscr.addstr(y, 2, "Held: (none)", curses.color_pair(1))
return
stdscr.addstr(y, 2, "Held: ", curses.color_pair(1))
cards = game.held.cards
if len(cards) == 1:
stdscr.addstr(y, 8, cards[0].short_name(), curses.color_pair(5) | curses.A_BOLD)
return
summary = f"{len(cards)} cards ({cards[0].short_name()}..{cards[-1].short_name()})"
stdscr.addnstr(
y, 8, summary, max(0, curses.COLS - 10), curses.color_pair(5) | curses.A_BOLD
)
def draw_status(stdscr, game: FreeCellGame, max_y: int) -> None:
"""Draw a one-line status message."""
y = max_y - 2
stdscr.move(y, 0)
stdscr.clrtoeol()
stdscr.addnstr(y, 2, game.status, max(0, curses.COLS - 4), curses.color_pair(1))
def draw_help(stdscr, max_y: int) -> None:
"""Draw a compact help line."""
y = max_y - 1
stdscr.move(y, 0)
stdscr.clrtoeol()
2026-03-18 19:03:02 -04:00
help_text = "Move: arrows/hjkl v: visual y/p: pick/drop ?: hint Enter/Space: pick/drop f: freecell d: foundation Esc: cancel q: quit"
2026-03-18 17:25:39 -04:00
stdscr.addnstr(y, 2, help_text, max(0, curses.COLS - 4), curses.color_pair(1))
def render(stdscr, game: FreeCellGame) -> None:
"""Redraw the full screen."""
stdscr.erase()
2026-03-18 21:03:31 -04:00
max_y, max_x = stdscr.getmaxyx()
2026-03-18 17:25:39 -04:00
2026-03-18 21:03:31 -04:00
title = "mcfreecell 0.1"
if len(title) > max_x - 4:
title = "mcfreecell"
title_x = max(2, (max_x - len(title)) // 2)
stdscr.addnstr(
0,
title_x,
title,
max(0, max_x - title_x - 1),
curses.color_pair(1) | curses.A_BOLD,
)
2026-03-18 17:25:39 -04:00
2026-03-18 17:59:01 -04:00
if game.visual_mode:
stdscr.addstr(0, 30, "-- VISUAL --", curses.color_pair(5) | curses.A_BOLD)
2026-03-18 17:25:39 -04:00
draw_top_row(stdscr, game)
draw_columns(stdscr, game)
draw_held(stdscr, game, max_y)
draw_status(stdscr, game, max_y)
draw_help(stdscr, max_y)
if game.is_won():
stdscr.addstr(3, 2, "You won.", curses.color_pair(5) | curses.A_BOLD)
stdscr.refresh()
# ---------------------------------------------------------------------------
# Input handling
# ---------------------------------------------------------------------------
def move_cursor(game: FreeCellGame, dy: int, dx: int) -> None:
"""
Move between the top zone and bottom zone, and across valid slot ranges.
"""
if dy < 0:
2026-03-18 17:59:01 -04:00
if game.visual_mode and game.cursor_zone == "bottom":
game.move_visual_selection(-1)
return
2026-03-18 17:25:39 -04:00
game.cursor_zone = "top"
game.cursor_index = min(game.cursor_index, 7)
return
if dy > 0:
2026-03-18 17:59:01 -04:00
if game.visual_mode and game.cursor_zone == "bottom":
game.move_visual_selection(1)
return
2026-03-18 17:25:39 -04:00
game.cursor_zone = "bottom"
game.cursor_index = min(game.cursor_index, 7)
return
# Horizontal motion
2026-03-18 17:59:01 -04:00
if game.visual_mode and dx != 0:
game.exit_visual_mode("Visual selection cancelled.")
return
2026-03-18 17:25:39 -04:00
if game.cursor_zone == "top":
game.cursor_index = max(0, min(7, game.cursor_index + dx))
else:
game.cursor_index = max(0, min(7, game.cursor_index + dx))
def handle_key(game: FreeCellGame, ch: int) -> bool:
"""
Handle one input event.
Returns False if the game should exit.
"""
if ch in (ord("q"), ord("Q")):
return False
# Movement
if ch in (curses.KEY_LEFT, ord("h")):
move_cursor(game, 0, -1)
return True
if ch in (curses.KEY_RIGHT, ord("l")):
move_cursor(game, 0, 1)
return True
if ch in (curses.KEY_UP, ord("k")):
move_cursor(game, -1, 0)
return True
if ch in (curses.KEY_DOWN, ord("j")):
move_cursor(game, 1, 0)
return True
2026-03-18 17:59:01 -04:00
if ch in (ord("v"), ord("V")):
if game.visual_mode:
game.exit_visual_mode("Visual selection cancelled.")
else:
game.enter_visual_mode()
2026-03-18 17:25:39 -04:00
return True
# Pick up / drop
if ch in (curses.KEY_ENTER, 10, 13, ord(" ")):
if game.held is None:
game.pick_up()
else:
game.drop()
return True
2026-03-18 17:59:01 -04:00
if ch in (ord("y"), ord("Y")):
if game.held is not None:
game.status = "You are already holding cards."
else:
game.pick_up()
return True
if ch in (ord("p"), ord("P")):
if game.held is None:
game.status = "You are not holding any cards."
else:
game.drop()
return True
2026-03-18 19:03:02 -04:00
if ch == ord("?"):
game.show_hint()
return True
2026-03-18 17:25:39 -04:00
# Quick helpers
if ch in (ord("f"), ord("F")):
game.quick_to_freecell()
return True
if ch in (ord("d"), ord("D")):
game.quick_to_foundation()
return True
# Escape: cancel held card by restoring it
if ch == 27:
2026-03-18 17:59:01 -04:00
if game.visual_mode:
game.exit_visual_mode("Visual selection cancelled.")
elif game.held is not None:
2026-03-18 17:25:39 -04:00
game.restore_held()
game.status = "Cancelled move."
return True
return True
# ---------------------------------------------------------------------------
# Main curses loop
# ---------------------------------------------------------------------------
def curses_main(stdscr) -> None:
"""Main UI entry point."""
curses.curs_set(0)
stdscr.keypad(True)
init_colors()
game = FreeCellGame()
while True:
render(stdscr, game)
ch = stdscr.getch()
if not handle_key(game, ch):
break
def main() -> None:
"""Program entry point."""
curses.wrapper(curses_main)
if __name__ == "__main__":
main()