gamr 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gamr/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Gamr: A git-aware file browser TUI."""
2
+
3
+ __all__ = ["GamrApp"]
gamr/app.py ADDED
@@ -0,0 +1,498 @@
1
+ """Gamr TUI application — main entry point and orchestration.
2
+
3
+ Lifecycle: loads persisted state, initializes services (git, scanner, index),
4
+ composes the UI (filter bar, split pane with tree table + preview), starts
5
+ the file watcher, and manages background workers for expensive git data.
6
+
7
+ Data flow on file change:
8
+ watchdog → queue → _poll_filesystem worker → _handle_file_changes (main thread)
9
+ → FileIndex.build() → _apply_filters() → tree.load_entries() → _sync_table()
10
+
11
+ Data flow on git state change (.git/index, HEAD):
12
+ _GitHandler → GIT_STATE_CHANGED event → same path as above
13
+ → only refreshes preview if previewed file's git_status actually changed
14
+
15
+ Domain model owns all preview decisions:
16
+ _previewed_path — which file is shown (prevents spurious switches)
17
+ _previewed_git_status — last-rendered git status (prevents unnecessary re-renders)
18
+ _scroll_positions — per-file source line cache (persists across file switches)
19
+ restore_line — passed atomically through show methods (no post-render scroll)
20
+ """
21
+
22
+ from pathlib import Path
23
+
24
+ from textual import work
25
+ from textual.app import App, ComposeResult
26
+ from textual.binding import Binding
27
+ from textual.css.query import NoMatches
28
+ from textual.widgets import Footer, Header
29
+ from textual.worker import get_current_worker
30
+
31
+ from gamr.commands import GamrCommands
32
+ from gamr.config import TIMESTAMP_REFRESH_INTERVAL, WATCHER_POLL_INTERVAL
33
+ from gamr.models import DiffMode, FileEntry, GitStatus
34
+ from gamr.services.file_index import FileIndex
35
+ from gamr.services.file_scanner import FileScanner
36
+ from gamr.services.filter import filter_by_status, fuzzy_filter
37
+ from gamr.services.git_provider import DulwichGitProvider, NullGitProvider
38
+ from gamr.state import AppState
39
+ from gamr.widgets.file_tree_table import FileTreeTable
40
+ from gamr.widgets.filter_bar import FilterBar
41
+ from gamr.widgets.preview_pane import PreviewPane
42
+ from gamr.widgets.split import HorizontalSplit, SplitHandle
43
+
44
+
45
+ class GamrApp(App):
46
+ """A git-aware file browser TUI."""
47
+
48
+ COMMANDS = App.COMMANDS | {GamrCommands}
49
+ CSS_PATH = "gamr.tcss"
50
+ TITLE = "Gamr"
51
+
52
+ # All bindings use priority=True so they work regardless of which widget has focus.
53
+ # See docs/UI_DESIGN.md for the full interaction specification.
54
+ BINDINGS = [
55
+ # Navigation & focus
56
+ Binding("tab", "switch_pane", "Switch pane", show=True, priority=True),
57
+ Binding("ctrl+f", "focus_filter", "Filter", show=True, priority=True),
58
+ # Modes
59
+ Binding("f", "toggle_follow", "Follow", show=True, priority=True),
60
+ Binding("v", "cycle_view", "View mode", show=True, priority=True),
61
+ Binding("d", "toggle_diff", "Diff toggle", show=True, priority=True),
62
+ Binding("D", "toggle_diff_reverse", show=False, priority=True),
63
+ # Columns
64
+ Binding("b", "toggle_blame", "Blame cols", show=True, priority=True),
65
+ Binding("1", "toggle_col('status')", "Status col", show=False, priority=True),
66
+ Binding("2", "toggle_col('lines')", "Lines col", show=False, priority=True),
67
+ Binding("3", "toggle_col('size')", "Size col", show=False, priority=True),
68
+ Binding("4", "toggle_col('mtime')", "Mtime col", show=False, priority=True),
69
+ Binding("5", "toggle_col('author')", "Author col", show=False, priority=True),
70
+ Binding("6", "toggle_col('git_time')", "Git time col", show=False, priority=True),
71
+ # Filters
72
+ Binding("m", "toggle_modified", "Modified", show=True, priority=True),
73
+ # App lifecycle
74
+ Binding("q", "quit", "Quit", show=True, priority=True),
75
+ ]
76
+
77
+ # -------------------------------------------------------------------------
78
+ # Lifecycle
79
+ # -------------------------------------------------------------------------
80
+
81
+ def __init__(self, path: Path | None = None, **kwargs) -> None:
82
+ super().__init__(**kwargs)
83
+ self.target_path = (path or Path.cwd()).resolve()
84
+ self._all_entries: list[FileEntry] = []
85
+ # Load persisted state from ~/.config/gamr/state.json
86
+ self._state = AppState.load(self.target_path)
87
+ self._diff_mode: DiffMode = self._state.diff_mode
88
+ self._follow_mode: bool = False
89
+ self._previewed_path: Path | None = None
90
+ self._previewed_git_status = None
91
+ self._scroll_positions: dict[Path, int] = {} # path → source line
92
+
93
+ def compose(self) -> ComposeResult:
94
+ yield Header()
95
+ yield FilterBar()
96
+ with HorizontalSplit(id="main"):
97
+ yield FileTreeTable(id="left-pane")
98
+ yield SplitHandle()
99
+ yield PreviewPane(id="right-pane")
100
+ yield Footer()
101
+
102
+ def on_mount(self) -> None:
103
+ # --- Initialize services ---
104
+ git = DulwichGitProvider(self.target_path)
105
+ if not git.is_git_repo():
106
+ git = NullGitProvider()
107
+ scanner = FileScanner(self.target_path, ignore_filter=git.get_ignore_filter())
108
+ self._git = git
109
+ self._scanner = scanner
110
+ self._file_index = FileIndex(scanner, git)
111
+ self._all_entries = self._file_index.build()
112
+
113
+ # --- Restore widget state from persisted session ---
114
+ tree = self.query_one(FileTreeTable)
115
+ self._update_global_mtime_range(tree)
116
+ filter_bar = self.query_one(FilterBar)
117
+ split = self.query_one(HorizontalSplit)
118
+ self._state.apply_to_widgets(tree, filter_bar, split)
119
+ filtered = self._apply_filters(filter_bar.active_statuses, filter_bar.search_query)
120
+ tree.load_entries(
121
+ filtered,
122
+ self.target_path,
123
+ collapsed_dirs=self._state.collapsed_dirs,
124
+ )
125
+ if self._state.selected_path:
126
+ tree.restore_cursor(Path(self._state.selected_path))
127
+
128
+ # --- Start background services ---
129
+ self._scanner.start_watching(git_root=self._git.git_dir if self._git.is_git_repo() else None)
130
+ self._poll_filesystem()
131
+
132
+ # --- Git-specific UI adjustments ---
133
+ if not self._git.is_git_repo():
134
+ tree.show_status = False
135
+ tree.show_lines = False
136
+ for btn in self.query(".filter-btn"):
137
+ btn.display = False
138
+ else:
139
+ self._load_diff_stats()
140
+
141
+ tree.focus()
142
+ self.set_interval(TIMESTAMP_REFRESH_INTERVAL, self._refresh_timestamps)
143
+
144
+ def on_unmount(self) -> None:
145
+ """Release native filesystem watcher resources during every shutdown path."""
146
+ scanner = getattr(self, "_scanner", None)
147
+ if scanner is not None:
148
+ scanner.stop()
149
+
150
+ def watch_theme(self, theme: str) -> None:
151
+ """Switch syntax highlight theme when Textual theme changes."""
152
+ try:
153
+ is_dark = self.current_theme.dark if self.current_theme else True
154
+ except Exception:
155
+ is_dark = True
156
+ preview = self.query_one(PreviewPane)
157
+ preview.syntax_theme = "monokai" if is_dark else "default"
158
+ # Re-render current preview with new theme
159
+ if self._previewed_path:
160
+ entry = self._file_index.entries.get(self._previewed_path)
161
+ if entry and self._is_previewable(entry):
162
+ preview.invalidate()
163
+ source_line = preview.get_source_line_at_scroll()
164
+ self._show_preview_for(entry, scroll_to_top=False, restore_line=source_line)
165
+
166
+ # -------------------------------------------------------------------------
167
+ # File watching and live updates
168
+ # -------------------------------------------------------------------------
169
+
170
+ @work(thread=True, group="watcher")
171
+ def _poll_filesystem(self) -> None:
172
+ """Long-running thread worker: drains file change events from the scanner queue."""
173
+ import time
174
+
175
+ from gamr.services.file_scanner import ChangeType
176
+
177
+ worker = get_current_worker()
178
+ while not worker.is_cancelled:
179
+ time.sleep(WATCHER_POLL_INTERVAL)
180
+ self._scanner.poll_changes()
181
+ changes = self._scanner.drain()
182
+ if changes:
183
+ git_changed = any(c.change_type == ChangeType.GIT_STATE_CHANGED for c in changes)
184
+ changed_paths = [c.path for c in changes if c.change_type != ChangeType.GIT_STATE_CHANGED]
185
+ self.call_from_thread(self._handle_file_changes, changed_paths, git_changed)
186
+
187
+ def _handle_file_changes(self, changed_paths: list[Path] | None = None, git_changed: bool = False) -> None:
188
+ """Respond to filesystem changes: rebuild index, re-filter, sync table."""
189
+ tree = self.query_one(FileTreeTable)
190
+ collapsed = tree.get_collapsed_dirs()
191
+
192
+ # Auto-expand parents of changed files so they're always visible
193
+ if changed_paths:
194
+ for path in changed_paths:
195
+ parent = path.parent
196
+ while parent != self.target_path:
197
+ try:
198
+ collapsed.discard(str(parent.relative_to(self.target_path)))
199
+ except ValueError:
200
+ break
201
+ parent = parent.parent
202
+
203
+ # Cancel background workers that reference old entries before rebuilding
204
+ self.workers.cancel_group(self, "diff_stats")
205
+ self.workers.cancel_group(self, "blame")
206
+
207
+ # Rebuild index (picks up new git statuses, file additions/deletions)
208
+ self._all_entries = self._file_index.build()
209
+ self._update_global_mtime_range(tree)
210
+
211
+ # Re-apply filters and sync the table (incremental — only changed rows update)
212
+ filter_bar = self.query_one(FilterBar)
213
+ filtered = self._apply_filters(filter_bar.active_statuses, filter_bar.search_query)
214
+ tree.load_entries(filtered, self.target_path, collapsed_dirs=collapsed)
215
+
216
+ # Update preview if the currently previewed file's content or git status changed
217
+ if self._previewed_path:
218
+ file_content_changed = changed_paths and self._previewed_path in set(changed_paths)
219
+ entry = self._file_index.entries.get(self._previewed_path)
220
+ # On git state change, only refresh if this file's status actually differs
221
+ git_status_changed = False
222
+ if git_changed and entry:
223
+ old_status = getattr(self, "_previewed_git_status", None)
224
+ if entry.git_status != old_status:
225
+ git_status_changed = True
226
+ if (file_content_changed or git_status_changed) and entry and self._is_previewable(entry):
227
+ preview = self.query_one(PreviewPane)
228
+ source_line = preview.get_source_line_at_scroll()
229
+ preview.invalidate()
230
+ self._show_preview_for(entry, scroll_to_top=False, restore_line=source_line)
231
+ if entry:
232
+ self._previewed_git_status = entry.git_status
233
+
234
+ # Follow mode: jump to the last changed file
235
+ if self._follow_mode and changed_paths:
236
+ follow_path = changed_paths[-1]
237
+ tree.restore_cursor(follow_path)
238
+ self._previewed_path = follow_path
239
+ self._show_followed_path(follow_path)
240
+
241
+ # Re-trigger background workers (only when git state changed or files modified)
242
+ if self._git.is_git_repo() and (git_changed or changed_paths):
243
+ self._load_diff_stats()
244
+ if tree.show_author or tree.show_git_time:
245
+ self._load_blame_data()
246
+
247
+ # -------------------------------------------------------------------------
248
+ # Preview pane management
249
+ # -------------------------------------------------------------------------
250
+
251
+ def on_file_tree_table_node_highlighted(self, event: FileTreeTable.NodeHighlighted) -> None:
252
+ """Domain decision: only update preview when user navigates to a new file."""
253
+ entry = event.entry
254
+ if entry is None or not self._is_previewable(entry):
255
+ return
256
+ if entry.path == self._previewed_path:
257
+ return
258
+ # Save scroll position of the file we're leaving
259
+ try:
260
+ preview = self.query_one(PreviewPane)
261
+ if self._previewed_path:
262
+ self._scroll_positions[self._previewed_path] = preview.get_source_line_at_scroll()
263
+ except NoMatches:
264
+ pass
265
+ self._previewed_path = entry.path
266
+ self._previewed_git_status = entry.git_status
267
+ try:
268
+ saved = self._scroll_positions.get(entry.path, 0)
269
+ self._show_preview_for(entry, restore_line=saved)
270
+ except NoMatches:
271
+ pass # Preview pane may not be mounted yet during startup
272
+
273
+ def _show_preview_for(self, entry: FileEntry, *, scroll_to_top: bool = True, restore_line: int = 0) -> None:
274
+ """Render file content or diff in the preview pane based on current diff mode."""
275
+ preview = self.query_one(PreviewPane)
276
+ preview.show_diff = self._diff_mode
277
+ is_diffable = entry.git_status and self._git.is_git_repo()
278
+
279
+ if is_diffable and self._diff_mode == DiffMode.UNIFIED:
280
+ diff = self._git.get_diff(entry.path)
281
+ if diff:
282
+ preview.show_diff_content(diff, path=entry.path, scroll_to_top=scroll_to_top, restore_line=restore_line)
283
+ return
284
+ elif is_diffable and self._diff_mode == DiffMode.FULL:
285
+ diff = self._git.get_diff(entry.path)
286
+ if diff:
287
+ preview.show_full_diff(entry.path, diff, scroll_to_top=scroll_to_top, restore_line=restore_line)
288
+ return
289
+ elif is_diffable and self._diff_mode == DiffMode.GUTTER:
290
+ diff = self._git.get_diff(entry.path)
291
+ if diff:
292
+ preview.show_gutter_diff(entry.path, diff, scroll_to_top=scroll_to_top, restore_line=restore_line)
293
+ return
294
+
295
+ preview.show_file(entry.path, scroll_to_top=scroll_to_top, restore_line=restore_line)
296
+
297
+ @staticmethod
298
+ def _is_previewable(entry: FileEntry) -> bool:
299
+ """Return whether an entry has file contents or a deletion diff to show."""
300
+ return entry.path.is_file() or entry.git_status in {
301
+ GitStatus.DELETED,
302
+ GitStatus.STAGED_DELETED,
303
+ }
304
+
305
+ def _show_followed_path(self, path: Path) -> None:
306
+ """Force preview update for a followed file; scroll to first diff hunk."""
307
+ import re
308
+
309
+ entry = self._file_index.entries.get(path)
310
+ if not entry or not self._is_previewable(entry):
311
+ return
312
+
313
+ # Scroll to the first diff hunk if the file is git-modified
314
+ restore_line = 0
315
+ if entry.git_status and self._git.is_git_repo():
316
+ diff = self._git.get_diff(path)
317
+ if diff:
318
+ m = re.search(r"@@ [^+]*\+(\d+)", diff)
319
+ if m:
320
+ restore_line = int(m.group(1))
321
+
322
+ preview = self.query_one(PreviewPane)
323
+ preview.invalidate()
324
+ self._show_preview_for(entry, restore_line=restore_line)
325
+
326
+ # -------------------------------------------------------------------------
327
+ # Background data workers
328
+ # -------------------------------------------------------------------------
329
+
330
+ @work(thread=True, group="blame")
331
+ def _load_blame_data(self) -> None:
332
+ """Populate last_author and last_git_modified for all entries (expensive)."""
333
+ worker = get_current_worker()
334
+ for path, _entry in list(self._file_index.entries.items()):
335
+ if worker.is_cancelled:
336
+ return
337
+ self._file_index.update_blame(path)
338
+ if not worker.is_cancelled:
339
+ self.call_from_thread(self._refresh_tree_labels)
340
+
341
+ @work(thread=True, group="diff_stats")
342
+ def _load_diff_stats(self) -> None:
343
+ """Populate lines_added/removed for modified files."""
344
+ worker = get_current_worker()
345
+ for path, entry in list(self._file_index.entries.items()):
346
+ if worker.is_cancelled:
347
+ return
348
+ if entry.git_status:
349
+ self._file_index.update_diff_stats(path)
350
+ if not worker.is_cancelled:
351
+ self.call_from_thread(self._refresh_tree_labels)
352
+
353
+ def _refresh_tree_labels(self) -> None:
354
+ """Refresh tree data after a background worker completes."""
355
+ self.query_one(FileTreeTable).refresh_data()
356
+
357
+ def _refresh_timestamps(self) -> None:
358
+ """Called every 10s to update relative time displays in-place."""
359
+ tree = self.query_one(FileTreeTable)
360
+ if tree.show_mtime or tree.show_git_time:
361
+ tree.refresh_time_cells()
362
+
363
+ # -------------------------------------------------------------------------
364
+ # Filter logic
365
+ # -------------------------------------------------------------------------
366
+
367
+ def on_filter_bar_filters_changed(self, event: FilterBar.FiltersChanged) -> None:
368
+ """Re-filter and reload tree when filter buttons or search text change."""
369
+ # Debounce: cancel pending filter and schedule a new one
370
+ if hasattr(self, "_filter_timer") and self._filter_timer:
371
+ self._filter_timer.stop()
372
+ self._pending_filter = (event.active_statuses, event.search_query)
373
+ self._filter_timer = self.set_timer(0.15, self._apply_pending_filter)
374
+
375
+ def _apply_pending_filter(self) -> None:
376
+ """Apply the debounced filter after 150ms of inactivity."""
377
+ if not hasattr(self, "_pending_filter"):
378
+ return
379
+ statuses, query = self._pending_filter
380
+ tree = self.query_one(FileTreeTable)
381
+ collapsed = tree.get_collapsed_dirs()
382
+ filtered = self._apply_filters(statuses, query)
383
+
384
+ # Skip rebuild if result set hasn't changed
385
+ new_paths = {e.path for e in filtered}
386
+ if hasattr(self, "_last_filtered_paths") and new_paths == self._last_filtered_paths:
387
+ return
388
+ self._last_filtered_paths = new_paths
389
+
390
+ tree.load_entries(filtered, self.target_path, collapsed_dirs=collapsed)
391
+
392
+ def _update_global_mtime_range(self, tree: FileTreeTable) -> None:
393
+ """Set the global mtime range from all entries (stable across filters)."""
394
+ mtimes = [e.mtime for e in self._all_entries if e.mtime > 0]
395
+ if mtimes:
396
+ tree.set_global_mtime_range(min(mtimes), max(mtimes))
397
+
398
+ def _apply_filters(self, statuses: set[GitStatus], search_query: str) -> list[FileEntry]:
399
+ """Apply git status filter then fuzzy search to the full entry list."""
400
+ entries = filter_by_status(self._all_entries, statuses)
401
+ if search_query.strip():
402
+ entries = fuzzy_filter(entries, search_query.strip())
403
+ return entries
404
+
405
+ # -------------------------------------------------------------------------
406
+ # Keybinding actions
407
+ # -------------------------------------------------------------------------
408
+
409
+ def action_focus_filter(self) -> None:
410
+ self.query_one("#search-input").focus()
411
+
412
+ def action_toggle_follow(self) -> None:
413
+ """Toggle follow mode — auto-select last changed file on watch events."""
414
+ self._follow_mode = not self._follow_mode
415
+ self.notify(f"Follow mode: {'ON' if self._follow_mode else 'OFF'}")
416
+
417
+ def action_toggle_diff(self) -> None:
418
+ self._cycle_diff_mode(1)
419
+
420
+ def action_toggle_diff_reverse(self) -> None:
421
+ self._cycle_diff_mode(-1)
422
+
423
+ def _cycle_diff_mode(self, direction: int) -> None:
424
+ """Cycle through diff modes, preserving scroll position by source line."""
425
+ modes = list(DiffMode)
426
+ idx = modes.index(self._diff_mode)
427
+ self._diff_mode = modes[(idx + direction) % len(modes)]
428
+ tree = self.query_one(FileTreeTable)
429
+ entry = tree.get_current_entry()
430
+ if entry and self._is_previewable(entry):
431
+ preview = self.query_one(PreviewPane)
432
+ source_line = preview.get_source_line_at_scroll()
433
+ preview.invalidate()
434
+ self._show_preview_for(entry, scroll_to_top=False, restore_line=source_line)
435
+
436
+ def action_toggle_blame(self) -> None:
437
+ """Toggle blame columns (author + git time) and load data if needed."""
438
+ tree = self.query_one(FileTreeTable)
439
+ show = not tree.show_author
440
+ tree.show_author = show
441
+ tree.show_git_time = show
442
+ if show and self._git.is_git_repo():
443
+ self._load_blame_data()
444
+
445
+ def action_toggle_col(self, col: str) -> None:
446
+ """Toggle a column by its reactive attribute name (e.g. 'size', 'mtime')."""
447
+ tree = self.query_one(FileTreeTable)
448
+ attr = f"show_{col}"
449
+ if hasattr(tree, attr):
450
+ setattr(tree, attr, not getattr(tree, attr))
451
+
452
+ def action_switch_pane(self) -> None:
453
+ """Move focus between the file tree and the preview pane."""
454
+ tree = self.query_one(FileTreeTable)
455
+ preview = self.query_one(PreviewPane)
456
+ (preview if tree.has_focus else tree).focus()
457
+
458
+ def action_cycle_view(self) -> None:
459
+ self.query_one(FileTreeTable).action_cycle_view()
460
+
461
+ def action_toggle_modified(self) -> None:
462
+ self.query_one("#filter-modified").press()
463
+
464
+ # -------------------------------------------------------------------------
465
+ # State persistence
466
+ # -------------------------------------------------------------------------
467
+
468
+ def _save_state(self) -> None:
469
+ """Capture all app state from live widgets and persist to disk."""
470
+ tree = self.query_one(FileTreeTable)
471
+ split = self.query_one(HorizontalSplit)
472
+ filter_bar = self.query_one(FilterBar)
473
+ entry = tree.get_current_entry()
474
+
475
+ self._state.capture_from_widgets(
476
+ tree,
477
+ filter_bar,
478
+ split,
479
+ diff_mode=self._diff_mode,
480
+ selected_path=entry.path if entry else None,
481
+ )
482
+ self._state.save()
483
+
484
+ def action_quit(self) -> None:
485
+ """Save state and exit."""
486
+ self._save_state()
487
+ self.exit()
488
+
489
+
490
+ def main() -> None:
491
+ import sys
492
+
493
+ path = Path(sys.argv[1]) if len(sys.argv) > 1 else None
494
+ GamrApp(path=path).run()
495
+
496
+
497
+ if __name__ == "__main__":
498
+ main()
gamr/commands.py ADDED
@@ -0,0 +1,52 @@
1
+ """Command palette provider for Gamr settings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textual.command import Hit, Hits, Provider
6
+
7
+ from gamr.widgets.file_tree_table import FileTreeTable
8
+ from gamr.widgets.preview_pane import DiffOverview
9
+
10
+
11
+ class GamrCommands(Provider):
12
+ """Command palette commands for Gamr settings.
13
+
14
+ Textual's Provider protocol: search() yields Hit objects scored by fuzzy match.
15
+ """
16
+
17
+ async def search(self, query: str) -> Hits:
18
+ matcher = self.matcher(query)
19
+
20
+ commands = [
21
+ (
22
+ "Toggle spaced paths (src / worker / foo.py)",
23
+ self._toggle_spaced_paths,
24
+ "Toggle spaces around / in flat path view",
25
+ ),
26
+ (
27
+ "Toggle gradient colors on age column",
28
+ self._toggle_gradient,
29
+ "Color-code modification times by recency",
30
+ ),
31
+ (
32
+ "Toggle diff overview style (line/braille)",
33
+ self._toggle_braille,
34
+ "Switch between line and braille overview bar",
35
+ ),
36
+ ]
37
+ for label, callback, help_text in commands:
38
+ score = matcher.match(label)
39
+ if score > 0:
40
+ yield Hit(score, label, callback, help=help_text)
41
+
42
+ def _toggle_spaced_paths(self) -> None:
43
+ tree = self.app.query_one(FileTreeTable)
44
+ tree.spaced_paths = not tree.spaced_paths
45
+
46
+ def _toggle_gradient(self) -> None:
47
+ tree = self.app.query_one(FileTreeTable)
48
+ tree.gradient_colors = not tree.gradient_colors
49
+
50
+ def _toggle_braille(self) -> None:
51
+ overview = self.app.query_one(DiffOverview)
52
+ overview.use_braille = not overview.use_braille
gamr/config.py ADDED
@@ -0,0 +1,33 @@
1
+ """UI constants and configuration values.
2
+
3
+ All magic numbers and design tokens referenced by the UI are defined here
4
+ so they're easy to find and adjust. See docs/UI_DESIGN.md for the rationale
5
+ behind these values.
6
+ """
7
+
8
+ # Gradient color ramp (256-color codes): cool → hot
9
+ # Applied to Size and Modified columns. See ADR-011.
10
+ GRADIENT_COLORS = [15, 51, 45, 39, 33, 27, 57, 93, 129, 165, 201, 200, 199, 198, 197, 196]
11
+
12
+ # Background colors for full-diff mode (hex)
13
+ DIFF_BG_ADDED = "#002200"
14
+ DIFF_BG_REMOVED = "#300000"
15
+
16
+ # Monokai theme background for the preview pane
17
+ PREVIEW_BG = "#272822"
18
+
19
+ # Padding width for diff backgrounds to fill the pane
20
+ DIFF_PAD_WIDTH = 200
21
+
22
+ # Fuzzy search score threshold (0-100). Files below this are excluded.
23
+ FUZZY_THRESHOLD = 50
24
+
25
+ # File watcher poll interval in seconds
26
+ WATCHER_POLL_INTERVAL = 0.5
27
+
28
+ # Relative timestamp refresh interval in seconds
29
+ TIMESTAMP_REFRESH_INTERVAL = 10
30
+
31
+ # Split handle constraints (fraction of total width)
32
+ SPLIT_MIN = 0.1
33
+ SPLIT_MAX = 0.9
gamr/gamr.tcss ADDED
@@ -0,0 +1,13 @@
1
+ #main {
2
+ height: 1fr;
3
+ }
4
+
5
+ #left-pane {
6
+ width: 1fr;
7
+ height: 100%;
8
+ }
9
+
10
+ #right-pane {
11
+ width: 1fr;
12
+ height: 100%;
13
+ }
gamr/models.py ADDED
@@ -0,0 +1,59 @@
1
+ """Data models for Gamr."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import Enum
7
+ from pathlib import Path
8
+
9
+
10
+ class GitStatus(Enum):
11
+ UNTRACKED = "?"
12
+ MODIFIED = "M"
13
+ ADDED = "A"
14
+ DELETED = "D"
15
+ STAGED_MODIFIED = "SM"
16
+ STAGED_ADDED = "SA"
17
+ STAGED_DELETED = "SD"
18
+
19
+
20
+ class DiffMode(Enum):
21
+ """Preview pane diff display modes."""
22
+
23
+ FULL = "full" # Full file with diff highlighting
24
+ GUTTER = "gutter" # File with gutter markers (plain if no changes)
25
+ UNIFIED = "unified" # Standard unified diff
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class FileStats:
30
+ lines_added: int
31
+ lines_removed: int
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class BlameInfo:
36
+ last_author: str
37
+ last_modified: int # unix timestamp from git author_time
38
+
39
+
40
+ @dataclass
41
+ class FileEntry:
42
+ """All displayable data for a single file.
43
+
44
+ Mutable fields (lines_added, last_author, etc.) are populated lazily
45
+ by background workers after initial build.
46
+ """
47
+
48
+ path: Path
49
+ size: int = 0
50
+ mtime: float = 0.0
51
+ git_status: GitStatus | None = None
52
+ lines_added: int | None = None
53
+ lines_removed: int | None = None
54
+ last_author: str | None = None
55
+ last_git_modified: int | None = None # unix timestamp
56
+
57
+ @property
58
+ def name(self) -> str:
59
+ return self.path.name
gamr/py.typed ADDED
File without changes
@@ -0,0 +1,3 @@
1
+ """Gamr services layer."""
2
+
3
+ __all__ = ["FileIndex", "FileScanner", "GitProvider", "DulwichGitProvider", "NullGitProvider"]