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 Controls
-------- --------
Arrow keys / h j k l : Move cursor 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 Space or Enter : Pick up / drop a card or stack
[ / ] : Expand / shrink tableau stack selection
f : Quick move selected card to a free cell f : Quick move selected card to a free cell
d : Quick move selected card to foundation d : Quick move selected card to foundation
Esc : Cancel held move Esc : Cancel held move
@ -102,7 +104,8 @@ class FreeCellGame:
self.freecells: List[Optional[Card]] = [None] * 4 self.freecells: List[Optional[Card]] = [None] * 4
self.foundations: List[List[Card]] = [[] for _ in range(4)] self.foundations: List[List[Card]] = [[] for _ in range(4)]
self.held: Optional[HeldCards] = None self.held: Optional[HeldCards] = None
self.selection_offsets: List[int] = [0] * 8 self.visual_mode = False
self.visual_row = 0
# Cursor zones: # Cursor zones:
# "top" -> freecells + foundations # "top" -> freecells + foundations
@ -110,7 +113,7 @@ class FreeCellGame:
self.cursor_zone = "bottom" self.cursor_zone = "bottom"
self.cursor_index = 0 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() self._deal_new_game()
def _deal_new_game(self) -> None: def _deal_new_game(self) -> None:
@ -160,25 +163,55 @@ class FreeCellGame:
) )
return (self.count_empty_freecells() + 1) * (2**empty_columns) 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]: def selected_stack_from_column(self, col_index: int) -> Tuple[List[Card], str]:
"""Return the currently selected stack slice for a tableau column.""" """Return the currently selected stack slice for a tableau column."""
column = self.columns[col_index] column = self.columns[col_index]
if not column: if not column:
return [], "That column is empty." return [], "That column is empty."
offset = min(self.selection_offsets[col_index], len(column) - 1) if self.visual_mode and self.cursor_zone == "bottom":
moving_count = offset + 1 return self.current_visual_stack()
cards = column[-moving_count:]
if not self.is_valid_tableau_sequence(cards): cards = [column[-1]]
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."
return cards, "" return cards, ""
@ -236,35 +269,62 @@ class FreeCellGame:
return i return i
return None return None
def adjust_selection_offset(self, delta: int) -> None: def enter_visual_mode(self) -> None:
"""Grow or shrink the selected tableau stack depth for the current column.""" """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": if self.cursor_zone != "bottom":
self.status = "Stack depth only applies to tableau columns." self.status = "Visual mode only works on tableau columns."
return return
column = self.columns[self.cursor_index] column = self.columns[self.cursor_index]
if not column: if not column:
self.selection_offsets[self.cursor_index] = 0
self.status = "That column is empty." self.status = "That column is empty."
return return
max_offset = len(column) - 1 self.visual_mode = True
old_offset = self.selection_offsets[self.cursor_index] self.visual_row = len(column) - 1
new_offset = max(0, min(max_offset, old_offset + delta)) cards, reason = self.current_visual_stack()
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)
if reason: if reason:
self.status = f"Selecting {selected_count} cards. {reason}" self.status = f"Visual: {len(cards)} cards selected. {reason}"
else: else:
self.status = ( 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}." self.status = f"Picked up {cards[0].short_name()} from column {self.cursor_index + 1}."
else: else:
self.status = f"Picked up {moving_count} cards from column {self.cursor_index + 1}." self.status = f"Picked up {moving_count} cards from column {self.cursor_index + 1}."
self.exit_visual_mode()
return return
# top zone: indexes 0..3 freecells, 4..7 foundations # top zone: indexes 0..3 freecells, 4..7 foundations
@ -406,7 +467,7 @@ class FreeCellGame:
if not col: if not col:
self.status = "That column is empty." self.status = "That column is empty."
return return
if self.selection_offsets[self.cursor_index] > 0: if self.visual_mode:
self.status = "Quick freecell move uses only the bottom card." self.status = "Quick freecell move uses only the bottom card."
return return
self.freecells[free_idx] = col.pop() self.freecells[free_idx] = col.pop()
@ -436,7 +497,7 @@ class FreeCellGame:
if self.cursor_zone == "bottom": if self.cursor_zone == "bottom":
col = self.columns[self.cursor_index] col = self.columns[self.cursor_index]
if col: if col:
if self.selection_offsets[self.cursor_index] > 0: if self.visual_mode:
self.status = "Quick foundation move uses only the bottom card." self.status = "Quick foundation move uses only the bottom card."
return return
card = col[-1] card = col[-1]
@ -589,7 +650,11 @@ def draw_columns(stdscr, game: FreeCellGame) -> None:
selected_cards: List[Card] = [] selected_cards: List[Card] = []
selection_reason = "" 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_cards, selection_reason = game.selected_stack_from_column(col_idx)
selected_start = ( selected_start = (
@ -599,13 +664,22 @@ def draw_columns(stdscr, game: FreeCellGame) -> None:
for row_idx, card in enumerate(column): for row_idx, card in enumerate(column):
y = COL_Y + 1 + row_idx 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: if in_selected_stack:
attr = curses.color_pair(4) attr = curses.color_pair(4)
if selection_reason: if selection_reason:
attr |= curses.A_DIM attr |= curses.A_DIM
else: else:
attr |= curses.A_BOLD 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: else:
attr = curses.color_pair(card_color_pair(card)) attr = curses.color_pair(card_color_pair(card))
@ -654,7 +728,7 @@ def draw_help(stdscr, max_y: int) -> None:
y = max_y - 1 y = max_y - 1
stdscr.move(y, 0) stdscr.move(y, 0)
stdscr.clrtoeol() 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)) 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" title = "Linux FreeCell Prototype"
stdscr.addstr(0, 2, title, curses.color_pair(1) | curses.A_BOLD) 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_top_row(stdscr, game)
draw_columns(stdscr, game) draw_columns(stdscr, game)
draw_held(stdscr, game, max_y) 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. Move between the top zone and bottom zone, and across valid slot ranges.
""" """
if dy < 0: if dy < 0:
if game.visual_mode and game.cursor_zone == "bottom":
game.move_visual_selection(-1)
return
game.cursor_zone = "top" game.cursor_zone = "top"
game.cursor_index = min(game.cursor_index, 7) game.cursor_index = min(game.cursor_index, 7)
return return
if dy > 0: if dy > 0:
if game.visual_mode and game.cursor_zone == "bottom":
game.move_visual_selection(1)
return
game.cursor_zone = "bottom" game.cursor_zone = "bottom"
game.cursor_index = min(game.cursor_index, 7) game.cursor_index = min(game.cursor_index, 7)
return return
# Horizontal motion # Horizontal motion
if game.visual_mode and dx != 0:
game.exit_visual_mode("Visual selection cancelled.")
return
if game.cursor_zone == "top": if game.cursor_zone == "top":
game.cursor_index = max(0, min(7, game.cursor_index + dx)) game.cursor_index = max(0, min(7, game.cursor_index + dx))
else: else:
@ -729,12 +816,11 @@ def handle_key(game: FreeCellGame, ch: int) -> bool:
move_cursor(game, 1, 0) move_cursor(game, 1, 0)
return True return True
if ch == ord("["): if ch in (ord("v"), ord("V")):
game.adjust_selection_offset(1) if game.visual_mode:
return True game.exit_visual_mode("Visual selection cancelled.")
else:
if ch == ord("]"): game.enter_visual_mode()
game.adjust_selection_offset(-1)
return True return True
# Pick up / drop # Pick up / drop
@ -745,6 +831,20 @@ def handle_key(game: FreeCellGame, ch: int) -> bool:
game.drop() game.drop()
return True 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 # Quick helpers
if ch in (ord("f"), ord("F")): if ch in (ord("f"), ord("F")):
game.quick_to_freecell() game.quick_to_freecell()
@ -756,7 +856,9 @@ def handle_key(game: FreeCellGame, ch: int) -> bool:
# Escape: cancel held card by restoring it # Escape: cancel held card by restoring it
if ch == 27: 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.restore_held()
game.status = "Cancelled move." game.status = "Cancelled move."
return True return True