From ddb1119bb557ed68501d24c6b7bb5d182e9c1d0d Mon Sep 17 00:00:00 2001 From: "n loewen (aider)" Date: Sun, 8 Jun 2025 00:59:58 +0100 Subject: [PATCH] refactor: Convert AppState to pure dataclass with functional state management --- gtm | 332 +++++++++++++++++++++++++++++++----------------------------- 1 file changed, 171 insertions(+), 161 deletions(-) diff --git a/gtm b/gtm index abeaba4..dc9d8a6 100755 --- a/gtm +++ b/gtm @@ -5,6 +5,8 @@ import os import subprocess import sys import argparse +from dataclasses import dataclass, field, replace +from typing import List, Optional, Tuple VERSION = "2025-06-07.3" @@ -91,121 +93,129 @@ def get_diff_info(current_commit, prev_commit, filename): i += 1 return added_lines, deleted_lines -# --- Application State Class --- - +@dataclass class AppState: - def __init__(self, filename, width, height, show_diff, show_add, show_del, mouse): - self.filename = filename - self.width = width - self.height = height - self.show_whole_diff = show_diff - self.show_additions = show_add - self.show_deletions = show_del - self.enable_mouse = mouse - self.show_sidebar = True + filename: str + width: int + height: int + show_whole_diff: bool + show_additions: bool + show_deletions: bool + enable_mouse: bool + + show_sidebar: bool = True + commits: List[str] = field(default_factory=list) + file_lines: List[str] = field(default_factory=list) + added_lines: List[Tuple[int, str]] = field(default_factory=list) + deleted_lines: List[Tuple[int, str]] = field(default_factory=list) - self.commits = get_commits(filename) - self.file_lines = [] - self.added_lines = [] - self.deleted_lines = [] + focus: str = "left" + divider_col: int = 40 + + selected_commit_idx: int = 0 + left_scroll_offset: int = 0 + right_scroll_offset: int = 0 - self.focus = "left" - self.divider_col = 40 - - self.selected_commit_idx = 0 - self.left_scroll_offset = 0 - self.right_scroll_offset = 0 + dragging_divider: bool = False + should_exit: bool = False - self.dragging_divider = False - self.should_exit = False + is_selecting: bool = False + selection_start_coord: Optional[Tuple[int, int]] = None + selection_end_coord: Optional[Tuple[int, int]] = None + click_position: Optional[Tuple[int, int]] = None + last_bstate: int = 0 + mouse_x: int = -1 + mouse_y: int = -1 - self.is_selecting = False - self.selection_start_coord = None - self.selection_end_coord = None - self.click_position = None # Store click position to detect clicks vs. drags - self.last_bstate = 0 - self.mouse_x = -1 - self.mouse_y = -1 +# --- Actions (Controller) --- - def update_dimensions(self, height, width): - self.height = height - self.width = width +def load_commit_content(state: AppState) -> AppState: + if not state.commits: + return state + + reference_line = None + scroll_percentage = 0 + if len(state.file_lines) > 0: + max_scroll_old = max(0, len(state.file_lines) - (state.height - 1)) + if max_scroll_old > 0: + scroll_percentage = state.right_scroll_offset / max_scroll_old + if state.right_scroll_offset < len(state.file_lines): + reference_line = state.file_lines[state.right_scroll_offset] + + commit_hash = state.commits[state.selected_commit_idx].split()[0] + file_lines = get_file_at_commit(commit_hash, state.filename) - def toggle_mouse(self): - self.enable_mouse = not self.enable_mouse - if self.enable_mouse: - curses.mousemask(curses.ALL_MOUSE_EVENTS | curses.REPORT_MOUSE_POSITION) + prev_commit_hash = None + if state.selected_commit_idx < len(state.commits) - 1: + prev_commit_hash = state.commits[state.selected_commit_idx + 1].split()[0] + + if state.show_whole_diff or state.show_additions or state.show_deletions: + added_lines, deleted_lines = get_diff_info(commit_hash, prev_commit_hash, state.filename) + else: + added_lines, deleted_lines = [], [] + + max_scroll_new = max(0, len(file_lines) - (state.height - 1)) + right_scroll_offset = state.right_scroll_offset + if reference_line: + matching_line_idx = find_best_matching_line(reference_line, file_lines, 1000) + if matching_line_idx is not None: + right_scroll_offset = matching_line_idx else: - curses.mousemask(0) + right_scroll_offset = int(scroll_percentage * max_scroll_new) + else: + right_scroll_offset = int(scroll_percentage * max_scroll_new) + + right_scroll_offset = max(0, min(right_scroll_offset, max_scroll_new)) + + return replace(state, + file_lines=file_lines, + added_lines=added_lines, + deleted_lines=deleted_lines, + right_scroll_offset=right_scroll_offset + ) + +def update_dimensions(state: AppState, height: int, width: int) -> AppState: + return replace(state, height=height, width=width) + +def toggle_mouse(state: AppState) -> AppState: + return replace(state, enable_mouse=not state.enable_mouse) - def toggle_sidebar(self): - self.show_sidebar = not self.show_sidebar - # When hiding sidebar, focus should be on right pane - if not self.show_sidebar: - self.focus = "right" +def toggle_sidebar(state: AppState) -> AppState: + new_show_sidebar = not state.show_sidebar + new_focus = state.focus + if not new_show_sidebar: + new_focus = "right" + return replace(state, show_sidebar=new_show_sidebar, focus=new_focus) - def move_commit_selection(self, delta): - new_idx = self.selected_commit_idx + delta - if 0 <= new_idx < len(self.commits): - self.selected_commit_idx = new_idx - self.load_commit_content() +def move_commit_selection(state: AppState, delta: int) -> AppState: + new_idx = state.selected_commit_idx + delta + if 0 <= new_idx < len(state.commits): + new_state = replace(state, selected_commit_idx=new_idx) + return load_commit_content(new_state) + return state - def page_commit_selection(self, direction): - page_size = self.height - 1 - delta = page_size * direction - self.move_commit_selection(delta) +def page_commit_selection(state: AppState, direction: int) -> AppState: + page_size = state.height - 1 + delta = page_size * direction + return move_commit_selection(state, delta) - def scroll_right_pane(self, delta): - max_scroll = max(0, len(self.file_lines) - (self.height - 1)) - new_offset = self.right_scroll_offset + delta - self.right_scroll_offset = max(0, min(new_offset, max_scroll)) +def scroll_right_pane(state: AppState, delta: int) -> AppState: + max_scroll = max(0, len(state.file_lines) - (state.height - 1)) + new_offset = state.right_scroll_offset + delta + new_offset = max(0, min(new_offset, max_scroll)) + return replace(state, right_scroll_offset=new_offset) - def page_right_pane(self, direction): - page_size = self.height - 1 - delta = page_size * direction - self.scroll_right_pane(delta) +def page_right_pane(state: AppState, direction: int) -> AppState: + page_size = state.height - 1 + delta = page_size * direction + return scroll_right_pane(state, delta) - def update_divider(self, mx): - min_col = 10 - max_col = self.width - 20 - self.divider_col = max(min_col, min(mx, max_col)) +def update_divider(state: AppState, mx: int) -> AppState: + min_col = 10 + max_col = state.width - 20 + new_divider_col = max(min_col, min(mx, max_col)) + return replace(state, divider_col=new_divider_col) - def load_commit_content(self): - if not self.commits: - return - - reference_line = None - scroll_percentage = 0 - if len(self.file_lines) > 0: - max_scroll_old = max(0, len(self.file_lines) - (self.height - 1)) - if max_scroll_old > 0: - scroll_percentage = self.right_scroll_offset / max_scroll_old - if self.right_scroll_offset < len(self.file_lines): - reference_line = self.file_lines[self.right_scroll_offset] - - commit_hash = self.commits[self.selected_commit_idx].split()[0] - self.file_lines = get_file_at_commit(commit_hash, self.filename) - - prev_commit_hash = None - if self.selected_commit_idx < len(self.commits) - 1: - prev_commit_hash = self.commits[self.selected_commit_idx + 1].split()[0] - - if self.show_whole_diff or self.show_additions or self.show_deletions: - self.added_lines, self.deleted_lines = get_diff_info(commit_hash, prev_commit_hash, self.filename) - else: - self.added_lines, self.deleted_lines = [], [] - - max_scroll_new = max(0, len(self.file_lines) - (self.height - 1)) - if reference_line: - matching_line_idx = find_best_matching_line(reference_line, self.file_lines, 1000) - if matching_line_idx is not None: - self.right_scroll_offset = matching_line_idx - else: - self.right_scroll_offset = int(scroll_percentage * max_scroll_new) - else: - self.right_scroll_offset = int(scroll_percentage * max_scroll_new) - - self.right_scroll_offset = max(0, min(self.right_scroll_offset, max_scroll_new)) # --- Rendering Functions (View) --- @@ -507,42 +517,32 @@ def copy_selection_to_clipboard(stdscr, state): except (FileNotFoundError, subprocess.CalledProcessError): pass -def handle_mouse_input(stdscr, state): +def handle_mouse_input(stdscr, state: AppState) -> AppState: try: _, mx, my, _, bstate = curses.getmouse() - state.last_bstate = bstate - state.mouse_x, state.mouse_y = mx, my + state = replace(state, last_bstate=bstate, mouse_x=mx, mouse_y=my) # Handle mouse button press if bstate & curses.BUTTON1_PRESSED: - # Check if clicking near divider (only if sidebar is visible) if state.show_sidebar and abs(mx - state.divider_col) <= 1: - state.dragging_divider = True + return replace(state, dragging_divider=True) else: - # Switch panes immediately on click (only if sidebar is visible) + focus = "right" if state.show_sidebar: - state.focus = "left" if mx < state.divider_col else "right" - else: - state.focus = "right" # When sidebar is hidden, focus is always right + focus = "left" if mx < state.divider_col else "right" - # Also start a potential selection (in case this becomes a drag) - state.is_selecting = True - state.selection_start_coord = (mx, my) - state.selection_end_coord = (mx, my) - state.click_position = (mx, my) - - # Redraw immediately to show selection highlight and focus change - draw_ui(stdscr, state) - return + return replace(state, + focus=focus, + is_selecting=True, + selection_start_coord=(mx, my), + selection_end_coord=(mx, my), + click_position=(mx, my)) # Handle mouse button release if bstate & curses.BUTTON1_RELEASED: - # End of divider drag if state.dragging_divider: - state.dragging_divider = False - return + return replace(state, dragging_divider=False) - # End of selection if state.is_selecting: # Check if this was a click (press and release at same position) # or very close to the same position (within 1-2 pixels) @@ -550,80 +550,77 @@ def handle_mouse_input(stdscr, state): abs(state.click_position[0] - mx) <= 2 and abs(state.click_position[1] - my) <= 2): # This was a click, so switch panes (only if sidebar is visible) + focus = "right" if state.show_sidebar: - state.focus = "left" if mx < state.divider_col else "right" - else: - state.focus = "right" # When sidebar is hidden, focus is always right + focus = "left" if mx < state.divider_col else "right" + new_state = replace(state, focus=focus) + # If clicking in the left pane on a commit entry, select that commit if state.show_sidebar and mx < state.divider_col and my < min(state.height - 1, len(state.commits) - state.left_scroll_offset): new_commit_idx = my + state.left_scroll_offset if 0 <= new_commit_idx < len(state.commits): - state.selected_commit_idx = new_commit_idx - state.load_commit_content() + new_state = replace(new_state, selected_commit_idx=new_commit_idx) + new_state = load_commit_content(new_state) + + # Reset selection state + return replace(new_state, is_selecting=False, selection_start_coord=None, selection_end_coord=None, click_position=None) + elif state.selection_start_coord and state.selection_end_coord: # This was a drag selection, copy the text copy_selection_to_clipboard(stdscr, state) # Reset selection state - state.is_selecting = False - state.selection_start_coord = None - state.selection_end_coord = None - state.click_position = None - return + return replace(state, is_selecting=False, selection_start_coord=None, selection_end_coord=None, click_position=None) # Handle mouse movement during drag operations if state.dragging_divider: - # Update divider position during drag - state.update_divider(mx) + return update_divider(state, mx) elif state.is_selecting: - # Update selection end coordinates during drag - state.selection_end_coord = (mx, my) - # Redraw immediately to show selection highlight during drag - draw_ui(stdscr, state) + return replace(state, selection_end_coord=(mx, my)) except curses.error: pass + return state -def handle_keyboard_input(key, state): +def handle_keyboard_input(key, state: AppState) -> AppState: if key in [ord('q')]: - state.should_exit = True + return replace(state, should_exit=True) elif key == 27: # Escape key if state.is_selecting: - state.is_selecting = False - state.selection_start_coord = None - state.selection_end_coord = None + return replace(state, is_selecting=False, selection_start_coord=None, selection_end_coord=None) else: - state.should_exit = True + return replace(state, should_exit=True) elif key == ord('m'): - state.toggle_mouse() + return toggle_mouse(state) elif key == ord('s'): - state.toggle_sidebar() + return toggle_sidebar(state) elif key in [curses.KEY_LEFT, ord('h')]: if state.show_sidebar: - state.focus = "left" + return replace(state, focus="left") elif key in [curses.KEY_RIGHT, ord('l')]: - state.focus = "right" + return replace(state, focus="right") elif state.focus == "left": if key in [curses.KEY_DOWN, ord('j')]: - state.move_commit_selection(1) + return move_commit_selection(state, 1) elif key in [curses.KEY_UP, ord('k')]: - state.move_commit_selection(-1) + return move_commit_selection(state, -1) elif key in [curses.KEY_NPAGE, ord(' ')]: - state.page_commit_selection(1) + return page_commit_selection(state, 1) elif key in [curses.KEY_PPAGE, 8, 127, curses.KEY_SR]: - state.page_commit_selection(-1) + return page_commit_selection(state, -1) elif state.focus == "right": if key in [curses.KEY_DOWN, ord('j')]: - state.scroll_right_pane(1) + return scroll_right_pane(state, 1) elif key in [curses.KEY_UP, ord('k')]: - state.scroll_right_pane(-1) + return scroll_right_pane(state, -1) elif key in [curses.KEY_NPAGE, ord(' ')]: - state.page_right_pane(1) + return page_right_pane(state, 1) elif key in [curses.KEY_PPAGE, 8, 127, curses.KEY_SR]: - state.page_right_pane(-1) + return page_right_pane(state, -1) + return state # --- Main Application --- @@ -644,7 +641,12 @@ def main(stdscr, filename, show_diff, show_add, show_del, mouse): curses.init_pair(4, curses.COLOR_RED, -1) height, width = stdscr.getmaxyx() - state = AppState(filename, width, height, show_diff, show_add, show_del, mouse) + state = AppState( + filename=filename, width=width, height=height, + show_whole_diff=show_diff, show_additions=show_add, + show_deletions=show_del, enable_mouse=mouse, + commits=get_commits(filename) + ) if state.enable_mouse: curses.mousemask(curses.ALL_MOUSE_EVENTS | curses.REPORT_MOUSE_POSITION) @@ -656,7 +658,7 @@ def main(stdscr, filename, show_diff, show_add, show_del, mouse): except Exception: pass - state.load_commit_content() + state = load_commit_content(state) # Initial draw before the main loop starts draw_ui(stdscr, state) @@ -665,17 +667,25 @@ def main(stdscr, filename, show_diff, show_add, show_del, mouse): try: key = stdscr.getch() + old_mouse_enabled = state.enable_mouse + # Process input and update the application state if key == -1: # No input available (timeout) pass # Just redraw the UI and continue elif key == curses.KEY_RESIZE: h, w = stdscr.getmaxyx() - state.update_dimensions(h, w) + state = update_dimensions(state, h, w) elif state.enable_mouse and key == curses.KEY_MOUSE: - handle_mouse_input(stdscr, state) + state = handle_mouse_input(stdscr, state) else: - handle_keyboard_input(key, state) + state = handle_keyboard_input(key, state) + if old_mouse_enabled != state.enable_mouse: + if state.enable_mouse: + curses.mousemask(curses.ALL_MOUSE_EVENTS | curses.REPORT_MOUSE_POSITION) + else: + curses.mousemask(0) + # After every action, redraw the UI to reflect changes immediately. # This is crucial for real-time feedback during mouse drags. draw_ui(stdscr, state)