From 9aa5c5ef06a1cd227a73ec082cde47b5c9d5dc51 Mon Sep 17 00:00:00 2001 From: markmental Date: Wed, 18 Mar 2026 17:59:01 -0400 Subject: [PATCH] Vim style controls added --- linux-freecell.py | 192 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 147 insertions(+), 45 deletions(-) diff --git a/linux-freecell.py b/linux-freecell.py index a84d75b..cab646c 100644 --- a/linux-freecell.py +++ b/linux-freecell.py @@ -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