refactor: Convert AppState to pure dataclass with functional state management

This commit is contained in:
n loewen (aider) 2025-06-08 00:59:58 +01:00
parent 9406908d2f
commit ddb1119bb5
1 changed files with 171 additions and 161 deletions

316
gtm
View File

@ -5,6 +5,8 @@ import os
import subprocess import subprocess
import sys import sys
import argparse import argparse
from dataclasses import dataclass, field, replace
from typing import List, Optional, Tuple
VERSION = "2025-06-07.3" VERSION = "2025-06-07.3"
@ -91,121 +93,129 @@ def get_diff_info(current_commit, prev_commit, filename):
i += 1 i += 1
return added_lines, deleted_lines return added_lines, deleted_lines
# --- Application State Class --- @dataclass
class AppState: class AppState:
def __init__(self, filename, width, height, show_diff, show_add, show_del, mouse): filename: str
self.filename = filename width: int
self.width = width height: int
self.height = height show_whole_diff: bool
self.show_whole_diff = show_diff show_additions: bool
self.show_additions = show_add show_deletions: bool
self.show_deletions = show_del enable_mouse: bool
self.enable_mouse = mouse
self.show_sidebar = True
self.commits = get_commits(filename) show_sidebar: bool = True
self.file_lines = [] commits: List[str] = field(default_factory=list)
self.added_lines = [] file_lines: List[str] = field(default_factory=list)
self.deleted_lines = [] added_lines: List[Tuple[int, str]] = field(default_factory=list)
deleted_lines: List[Tuple[int, str]] = field(default_factory=list)
self.focus = "left" focus: str = "left"
self.divider_col = 40 divider_col: int = 40
self.selected_commit_idx = 0 selected_commit_idx: int = 0
self.left_scroll_offset = 0 left_scroll_offset: int = 0
self.right_scroll_offset = 0 right_scroll_offset: int = 0
self.dragging_divider = False dragging_divider: bool = False
self.should_exit = False should_exit: bool = False
self.is_selecting = False is_selecting: bool = False
self.selection_start_coord = None selection_start_coord: Optional[Tuple[int, int]] = None
self.selection_end_coord = None selection_end_coord: Optional[Tuple[int, int]] = None
self.click_position = None # Store click position to detect clicks vs. drags click_position: Optional[Tuple[int, int]] = None
self.last_bstate = 0 last_bstate: int = 0
self.mouse_x = -1 mouse_x: int = -1
self.mouse_y = -1 mouse_y: int = -1
def update_dimensions(self, height, width): # --- Actions (Controller) ---
self.height = height
self.width = width
def toggle_mouse(self): def load_commit_content(state: AppState) -> AppState:
self.enable_mouse = not self.enable_mouse if not state.commits:
if self.enable_mouse: return state
curses.mousemask(curses.ALL_MOUSE_EVENTS | curses.REPORT_MOUSE_POSITION)
else:
curses.mousemask(0)
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 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 page_commit_selection(self, direction):
page_size = self.height - 1
delta = page_size * direction
self.move_commit_selection(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 page_right_pane(self, direction):
page_size = self.height - 1
delta = page_size * direction
self.scroll_right_pane(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 load_commit_content(self):
if not self.commits:
return
reference_line = None reference_line = None
scroll_percentage = 0 scroll_percentage = 0
if len(self.file_lines) > 0: if len(state.file_lines) > 0:
max_scroll_old = max(0, len(self.file_lines) - (self.height - 1)) max_scroll_old = max(0, len(state.file_lines) - (state.height - 1))
if max_scroll_old > 0: if max_scroll_old > 0:
scroll_percentage = self.right_scroll_offset / max_scroll_old scroll_percentage = state.right_scroll_offset / max_scroll_old
if self.right_scroll_offset < len(self.file_lines): if state.right_scroll_offset < len(state.file_lines):
reference_line = self.file_lines[self.right_scroll_offset] reference_line = state.file_lines[state.right_scroll_offset]
commit_hash = self.commits[self.selected_commit_idx].split()[0] commit_hash = state.commits[state.selected_commit_idx].split()[0]
self.file_lines = get_file_at_commit(commit_hash, self.filename) file_lines = get_file_at_commit(commit_hash, state.filename)
prev_commit_hash = None prev_commit_hash = None
if self.selected_commit_idx < len(self.commits) - 1: if state.selected_commit_idx < len(state.commits) - 1:
prev_commit_hash = self.commits[self.selected_commit_idx + 1].split()[0] prev_commit_hash = state.commits[state.selected_commit_idx + 1].split()[0]
if self.show_whole_diff or self.show_additions or self.show_deletions: if state.show_whole_diff or state.show_additions or state.show_deletions:
self.added_lines, self.deleted_lines = get_diff_info(commit_hash, prev_commit_hash, self.filename) added_lines, deleted_lines = get_diff_info(commit_hash, prev_commit_hash, state.filename)
else: else:
self.added_lines, self.deleted_lines = [], [] added_lines, deleted_lines = [], []
max_scroll_new = max(0, len(self.file_lines) - (self.height - 1)) max_scroll_new = max(0, len(file_lines) - (state.height - 1))
right_scroll_offset = state.right_scroll_offset
if reference_line: if reference_line:
matching_line_idx = find_best_matching_line(reference_line, self.file_lines, 1000) matching_line_idx = find_best_matching_line(reference_line, file_lines, 1000)
if matching_line_idx is not None: if matching_line_idx is not None:
self.right_scroll_offset = matching_line_idx right_scroll_offset = matching_line_idx
else: else:
self.right_scroll_offset = int(scroll_percentage * max_scroll_new) right_scroll_offset = int(scroll_percentage * max_scroll_new)
else: else:
self.right_scroll_offset = int(scroll_percentage * max_scroll_new) 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(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(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(state: AppState, direction: int) -> AppState:
page_size = state.height - 1
delta = page_size * direction
return move_commit_selection(state, delta)
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(state: AppState, direction: int) -> AppState:
page_size = state.height - 1
delta = page_size * direction
return scroll_right_pane(state, delta)
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)
self.right_scroll_offset = max(0, min(self.right_scroll_offset, max_scroll_new))
# --- Rendering Functions (View) --- # --- Rendering Functions (View) ---
@ -507,42 +517,32 @@ def copy_selection_to_clipboard(stdscr, state):
except (FileNotFoundError, subprocess.CalledProcessError): except (FileNotFoundError, subprocess.CalledProcessError):
pass pass
def handle_mouse_input(stdscr, state): def handle_mouse_input(stdscr, state: AppState) -> AppState:
try: try:
_, mx, my, _, bstate = curses.getmouse() _, mx, my, _, bstate = curses.getmouse()
state.last_bstate = bstate state = replace(state, last_bstate=bstate, mouse_x=mx, mouse_y=my)
state.mouse_x, state.mouse_y = mx, my
# Handle mouse button press # Handle mouse button press
if bstate & curses.BUTTON1_PRESSED: 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: if state.show_sidebar and abs(mx - state.divider_col) <= 1:
state.dragging_divider = True return replace(state, dragging_divider=True)
else: else:
# Switch panes immediately on click (only if sidebar is visible) focus = "right"
if state.show_sidebar: if state.show_sidebar:
state.focus = "left" if mx < state.divider_col else "right" focus = "left" if mx < state.divider_col else "right"
else:
state.focus = "right" # When sidebar is hidden, focus is always right
# Also start a potential selection (in case this becomes a drag) return replace(state,
state.is_selecting = True focus=focus,
state.selection_start_coord = (mx, my) is_selecting=True,
state.selection_end_coord = (mx, my) selection_start_coord=(mx, my),
state.click_position = (mx, my) selection_end_coord=(mx, my),
click_position=(mx, my))
# Redraw immediately to show selection highlight and focus change
draw_ui(stdscr, state)
return
# Handle mouse button release # Handle mouse button release
if bstate & curses.BUTTON1_RELEASED: if bstate & curses.BUTTON1_RELEASED:
# End of divider drag
if state.dragging_divider: if state.dragging_divider:
state.dragging_divider = False return replace(state, dragging_divider=False)
return
# End of selection
if state.is_selecting: if state.is_selecting:
# Check if this was a click (press and release at same position) # Check if this was a click (press and release at same position)
# or very close to the same position (within 1-2 pixels) # 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[0] - mx) <= 2 and
abs(state.click_position[1] - my) <= 2): abs(state.click_position[1] - my) <= 2):
# This was a click, so switch panes (only if sidebar is visible) # This was a click, so switch panes (only if sidebar is visible)
focus = "right"
if state.show_sidebar: if state.show_sidebar:
state.focus = "left" if mx < state.divider_col else "right" focus = "left" if mx < state.divider_col else "right"
else:
state.focus = "right" # When sidebar is hidden, focus is always right new_state = replace(state, focus=focus)
# If clicking in the left pane on a commit entry, select that commit # 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): 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 new_commit_idx = my + state.left_scroll_offset
if 0 <= new_commit_idx < len(state.commits): if 0 <= new_commit_idx < len(state.commits):
state.selected_commit_idx = new_commit_idx new_state = replace(new_state, selected_commit_idx=new_commit_idx)
state.load_commit_content() 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: elif state.selection_start_coord and state.selection_end_coord:
# This was a drag selection, copy the text # This was a drag selection, copy the text
copy_selection_to_clipboard(stdscr, state) copy_selection_to_clipboard(stdscr, state)
# Reset selection state # Reset selection state
state.is_selecting = False return replace(state, is_selecting=False, selection_start_coord=None, selection_end_coord=None, click_position=None)
state.selection_start_coord = None
state.selection_end_coord = None
state.click_position = None
return
# Handle mouse movement during drag operations # Handle mouse movement during drag operations
if state.dragging_divider: if state.dragging_divider:
# Update divider position during drag return update_divider(state, mx)
state.update_divider(mx)
elif state.is_selecting: elif state.is_selecting:
# Update selection end coordinates during drag return replace(state, selection_end_coord=(mx, my))
state.selection_end_coord = (mx, my)
# Redraw immediately to show selection highlight during drag
draw_ui(stdscr, state)
except curses.error: except curses.error:
pass pass
return state
def handle_keyboard_input(key, state): def handle_keyboard_input(key, state: AppState) -> AppState:
if key in [ord('q')]: if key in [ord('q')]:
state.should_exit = True return replace(state, should_exit=True)
elif key == 27: # Escape key elif key == 27: # Escape key
if state.is_selecting: if state.is_selecting:
state.is_selecting = False return replace(state, is_selecting=False, selection_start_coord=None, selection_end_coord=None)
state.selection_start_coord = None
state.selection_end_coord = None
else: else:
state.should_exit = True return replace(state, should_exit=True)
elif key == ord('m'): elif key == ord('m'):
state.toggle_mouse() return toggle_mouse(state)
elif key == ord('s'): elif key == ord('s'):
state.toggle_sidebar() return toggle_sidebar(state)
elif key in [curses.KEY_LEFT, ord('h')]: elif key in [curses.KEY_LEFT, ord('h')]:
if state.show_sidebar: if state.show_sidebar:
state.focus = "left" return replace(state, focus="left")
elif key in [curses.KEY_RIGHT, ord('l')]: elif key in [curses.KEY_RIGHT, ord('l')]:
state.focus = "right" return replace(state, focus="right")
elif state.focus == "left": elif state.focus == "left":
if key in [curses.KEY_DOWN, ord('j')]: 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')]: 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(' ')]: 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]: 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": elif state.focus == "right":
if key in [curses.KEY_DOWN, ord('j')]: 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')]: 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(' ')]: 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]: 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 --- # --- 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) curses.init_pair(4, curses.COLOR_RED, -1)
height, width = stdscr.getmaxyx() 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: if state.enable_mouse:
curses.mousemask(curses.ALL_MOUSE_EVENTS | curses.REPORT_MOUSE_POSITION) 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: except Exception:
pass pass
state.load_commit_content() state = load_commit_content(state)
# Initial draw before the main loop starts # Initial draw before the main loop starts
draw_ui(stdscr, state) draw_ui(stdscr, state)
@ -665,16 +667,24 @@ def main(stdscr, filename, show_diff, show_add, show_del, mouse):
try: try:
key = stdscr.getch() key = stdscr.getch()
old_mouse_enabled = state.enable_mouse
# Process input and update the application state # Process input and update the application state
if key == -1: # No input available (timeout) if key == -1: # No input available (timeout)
pass # Just redraw the UI and continue pass # Just redraw the UI and continue
elif key == curses.KEY_RESIZE: elif key == curses.KEY_RESIZE:
h, w = stdscr.getmaxyx() h, w = stdscr.getmaxyx()
state.update_dimensions(h, w) state = update_dimensions(state, h, w)
elif state.enable_mouse and key == curses.KEY_MOUSE: elif state.enable_mouse and key == curses.KEY_MOUSE:
handle_mouse_input(stdscr, state) state = handle_mouse_input(stdscr, state)
else: 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. # After every action, redraw the UI to reflect changes immediately.
# This is crucial for real-time feedback during mouse drags. # This is crucial for real-time feedback during mouse drags.