refactor: Convert AppState to pure dataclass with functional state management
This commit is contained in:
parent
9406908d2f
commit
ddb1119bb5
332
gtm
332
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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue