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

332
gtm
View File

@ -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)