Vim style controls added

This commit is contained in:
markmental 2026-03-18 17:59:01 -04:00
commit 9aa5c5ef06

View file

@ -7,8 +7,10 @@ A curses-based FreeCell prototype that uses Linux distro themed suits.
Controls
--------
Arrow keys / h j k l : Move cursor
v : Enter / exit tableau visual selection
y : Pick up selected card / stack
p : Drop held card / stack
Space or Enter : Pick up / drop a card or stack
[ / ] : Expand / shrink tableau stack selection
f : Quick move selected card to a free cell
d : Quick move selected card to foundation
Esc : Cancel held move
@ -102,7 +104,8 @@ class FreeCellGame:
self.freecells: List[Optional[Card]] = [None] * 4
self.foundations: List[List[Card]] = [[] for _ in range(4)]
self.held: Optional[HeldCards] = None
self.selection_offsets: List[int] = [0] * 8
self.visual_mode = False
self.visual_row = 0
# Cursor zones:
# "top" -> freecells + foundations
@ -110,7 +113,7 @@ class FreeCellGame:
self.cursor_zone = "bottom"
self.cursor_index = 0
self.status = "Arrow keys move, Enter picks up/drops, [ ] change stack depth."
self.status = "Arrow keys move, v selects a stack, Enter picks up/drops."
self._deal_new_game()
def _deal_new_game(self) -> None:
@ -160,25 +163,55 @@ class FreeCellGame:
)
return (self.count_empty_freecells() + 1) * (2**empty_columns)
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, ""
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."
offset = min(self.selection_offsets[col_index], len(column) - 1)
moving_count = offset + 1
cards = column[-moving_count:]
if self.visual_mode and self.cursor_zone == "bottom":
return self.current_visual_stack()
if not self.is_valid_tableau_sequence(cards):
return (
cards,
"Selected cards are not a valid alternating descending sequence.",
)
max_cards = self.max_movable_cards(col_index, moving_count)
if moving_count > max_cards:
return cards, f"Move limit is {max_cards} cards with current free space."
cards = [column[-1]]
return cards, ""
@ -236,35 +269,62 @@ class FreeCellGame:
return i
return None
def adjust_selection_offset(self, delta: int) -> None:
"""Grow or shrink the selected tableau stack depth for the current column."""
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
if self.cursor_zone != "bottom":
self.status = "Stack depth only applies to tableau columns."
self.status = "Visual mode only works on tableau columns."
return
column = self.columns[self.cursor_index]
if not column:
self.selection_offsets[self.cursor_index] = 0
self.status = "That column is empty."
return
max_offset = len(column) - 1
old_offset = self.selection_offsets[self.cursor_index]
new_offset = max(0, min(max_offset, old_offset + delta))
self.selection_offsets[self.cursor_index] = new_offset
if new_offset == old_offset:
selected_count = new_offset + 1
self.status = f"Selection stays at {selected_count} card{'s' if selected_count != 1 else ''}."
return
selected_count = new_offset + 1
_, reason = self.selected_stack_from_column(self.cursor_index)
self.visual_mode = True
self.visual_row = len(column) - 1
cards, reason = self.current_visual_stack()
if reason:
self.status = f"Selecting {selected_count} cards. {reason}"
self.status = f"Visual: {len(cards)} cards selected. {reason}"
else:
self.status = (
f"Selecting {selected_count} cards from column {self.cursor_index + 1}."
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()
if self.visual_row == old_row:
self.status = f"Visual selection stays at {len(cards)} card{'s' if len(cards) != 1 else ''}."
return
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."
)
# -----------------------------------------------------------------------
@ -300,6 +360,7 @@ class FreeCellGame:
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}."
self.exit_visual_mode()
return
# top zone: indexes 0..3 freecells, 4..7 foundations
@ -406,7 +467,7 @@ class FreeCellGame:
if not col:
self.status = "That column is empty."
return
if self.selection_offsets[self.cursor_index] > 0:
if self.visual_mode:
self.status = "Quick freecell move uses only the bottom card."
return
self.freecells[free_idx] = col.pop()
@ -436,7 +497,7 @@ class FreeCellGame:
if self.cursor_zone == "bottom":
col = self.columns[self.cursor_index]
if col:
if self.selection_offsets[self.cursor_index] > 0:
if self.visual_mode:
self.status = "Quick foundation move uses only the bottom card."
return
card = col[-1]
@ -589,7 +650,11 @@ def draw_columns(stdscr, game: FreeCellGame) -> None:
selected_cards: List[Card] = []
selection_reason = ""
if selected:
valid_tail_start = game.valid_tail_start_index(col_idx)
if valid_tail_start is None:
valid_tail_start = len(column)
if selected and game.visual_mode:
selected_cards, selection_reason = game.selected_stack_from_column(col_idx)
selected_start = (
@ -599,13 +664,22 @@ def draw_columns(stdscr, game: FreeCellGame) -> None:
for row_idx, card in enumerate(column):
y = COL_Y + 1 + row_idx
in_selected_stack = selected and row_idx >= selected_start
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
)
if in_selected_stack:
attr = curses.color_pair(4)
if selection_reason:
attr |= curses.A_DIM
else:
attr |= curses.A_BOLD
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)
else:
attr = curses.color_pair(card_color_pair(card))
@ -654,7 +728,7 @@ def draw_help(stdscr, max_y: int) -> None:
y = max_y - 1
stdscr.move(y, 0)
stdscr.clrtoeol()
help_text = "Move: arrows/hjkl Pick/Drop: Enter/Space [ / ]: stack size f: freecell d: foundation Esc: cancel q: quit"
help_text = "Move: arrows/hjkl v: visual y/p: pick/drop Enter/Space: pick/drop f: freecell d: foundation Esc: cancel q: quit"
stdscr.addnstr(y, 2, help_text, max(0, curses.COLS - 4), curses.color_pair(1))
@ -666,6 +740,9 @@ def render(stdscr, game: FreeCellGame) -> None:
title = "Linux FreeCell Prototype"
stdscr.addstr(0, 2, title, curses.color_pair(1) | curses.A_BOLD)
if game.visual_mode:
stdscr.addstr(0, 30, "-- VISUAL --", curses.color_pair(5) | curses.A_BOLD)
draw_top_row(stdscr, game)
draw_columns(stdscr, game)
draw_held(stdscr, game, max_y)
@ -688,16 +765,26 @@ 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:
if game.visual_mode and game.cursor_zone == "bottom":
game.move_visual_selection(-1)
return
game.cursor_zone = "top"
game.cursor_index = min(game.cursor_index, 7)
return
if dy > 0:
if game.visual_mode and game.cursor_zone == "bottom":
game.move_visual_selection(1)
return
game.cursor_zone = "bottom"
game.cursor_index = min(game.cursor_index, 7)
return
# Horizontal motion
if game.visual_mode and dx != 0:
game.exit_visual_mode("Visual selection cancelled.")
return
if game.cursor_zone == "top":
game.cursor_index = max(0, min(7, game.cursor_index + dx))
else:
@ -729,12 +816,11 @@ def handle_key(game: FreeCellGame, ch: int) -> bool:
move_cursor(game, 1, 0)
return True
if ch == ord("["):
game.adjust_selection_offset(1)
return True
if ch == ord("]"):
game.adjust_selection_offset(-1)
if ch in (ord("v"), ord("V")):
if game.visual_mode:
game.exit_visual_mode("Visual selection cancelled.")
else:
game.enter_visual_mode()
return True
# Pick up / drop
@ -745,6 +831,20 @@ def handle_key(game: FreeCellGame, ch: int) -> bool:
game.drop()
return True
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
# Quick helpers
if ch in (ord("f"), ord("F")):
game.quick_to_freecell()
@ -756,7 +856,9 @@ def handle_key(game: FreeCellGame, ch: int) -> bool:
# Escape: cancel held card by restoring it
if ch == 27:
if game.held is not None:
if game.visual_mode:
game.exit_visual_mode("Visual selection cancelled.")
elif game.held is not None:
game.restore_held()
game.status = "Cancelled move."
return True