ptdu 0.1.0__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.
ptdu/ui.py ADDED
@@ -0,0 +1,1247 @@
1
+ """UI components for PTDU."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import json
7
+ import tkinter as tk
8
+ from enum import Enum, auto
9
+ from pathlib import Path
10
+ from tkinter import filedialog, messagebox, ttk
11
+ from typing import Optional, TypedDict
12
+
13
+
14
+ class ExportDataItem(TypedDict):
15
+ """Type for export data items."""
16
+
17
+ path: str
18
+ name: str
19
+ size: int
20
+ size_human: int
21
+ is_dir: bool
22
+ item_count: int
23
+
24
+ from ptdu.cache import ScanCache
25
+ from ptdu.errors import ErrorHandler, PathValidator, get_error_handler
26
+ from ptdu.fonts import FontManager
27
+ from ptdu.models import DirNode
28
+ from ptdu.performance import LargeDirectoryHandler, VirtualScroller
29
+ from ptdu.scanner import Scanner
30
+ from ptdu.threads import (
31
+ ScanComplete,
32
+ ScanError,
33
+ ScanMessage,
34
+ ScanProgress,
35
+ ScanThreadManager,
36
+ )
37
+ from ptdu.treeview import DirectoryTreeview
38
+
39
+
40
+ class SortMode(Enum):
41
+ """Sorting modes for directory contents."""
42
+
43
+ SIZE = auto()
44
+ NAME = auto()
45
+ COUNT = auto()
46
+
47
+
48
+ class MainWindow:
49
+ """Main application window for PTDU."""
50
+
51
+ DEFAULT_WIDTH: int = 900
52
+ DEFAULT_HEIGHT: int = 600
53
+ MESSAGE_CHECK_INTERVAL: int = 50 # milliseconds
54
+
55
+ # Sort mode display names
56
+ SORT_MODE_NAMES: dict[SortMode, str] = {
57
+ SortMode.SIZE: "Size",
58
+ SortMode.NAME: "Name",
59
+ SortMode.COUNT: "Items",
60
+ }
61
+
62
+ def __init__(self, root: Optional[tk.Tk] = None) -> None:
63
+ """Initialize the main window.
64
+
65
+ Args:
66
+ root: Optional Tk root window. Creates one if not provided.
67
+ """
68
+ self.root: tk.Tk = root if root is not None else tk.Tk()
69
+ self._setup_window()
70
+
71
+ # Font manager for OS-aware fonts (must be before _create_layout)
72
+ self._font_manager: FontManager = FontManager(self.root)
73
+
74
+ self._create_layout()
75
+
76
+ # Thread manager for background scanning
77
+ self._scan_manager: ScanThreadManager = ScanThreadManager()
78
+ self._scanner: Optional[Scanner] = None
79
+ self._after_id: Optional[str] = None
80
+ self._root_path: Path = Path(".")
81
+
82
+ # Breadcrumb UI components (initialized on demand)
83
+ self._breadcrumb_container: ttk.Frame | None = None
84
+
85
+ # Sorting mode
86
+ self._sort_mode: SortMode = SortMode.SIZE
87
+
88
+ # Filter state
89
+ self._show_hidden: bool = True
90
+
91
+ # Error handler
92
+ self._error_handler: ErrorHandler = get_error_handler()
93
+ self._error_handler.set_parent(self.root)
94
+
95
+ # Performance optimizations
96
+ self._virtual_scroller: VirtualScroller = VirtualScroller()
97
+ self._large_dir_handler: LargeDirectoryHandler = LargeDirectoryHandler()
98
+ self._max_depth: Optional[int] = None
99
+
100
+ # Cache reference
101
+ self._cache: Optional[ScanCache] = None
102
+
103
+ # Start checking for messages
104
+ self._schedule_message_check()
105
+
106
+ # Bind events
107
+ self._bind_events()
108
+
109
+ def _bind_events(self) -> None:
110
+ """Bind keyboard and mouse events."""
111
+ # Treeview selection and expansion events
112
+ self.treeview.bind("<Double-1>", self._on_double_click)
113
+ self.treeview.bind("<Return>", self._on_enter_key)
114
+
115
+ # Keyboard navigation (vim-style)
116
+ self.root.bind("j", self._on_down)
117
+ self.root.bind("k", self._on_up)
118
+ self.root.bind("<Down>", self._on_down)
119
+ self.root.bind("<Up>", self._on_up)
120
+
121
+ # Expand/collapse
122
+ self.root.bind("h", self._on_collapse)
123
+ self.root.bind("l", self._on_expand)
124
+ self.root.bind("<Left>", self._on_collapse)
125
+ self.root.bind("<Right>", self._on_expand)
126
+
127
+ # Jump to top/bottom
128
+ self.root.bind("g", self._on_jump_top)
129
+ self.root.bind("G", self._on_jump_bottom)
130
+
131
+ # Action keys
132
+ self.root.bind("q", self._on_quit)
133
+ self.root.bind("r", self._on_rescan)
134
+ self.root.bind("d", self._on_delete)
135
+ self.root.bind("b", self._on_toggle_breadcrumb)
136
+ self.root.bind("u", self._on_parent_directory)
137
+
138
+ # Font size controls (Ctrl+Plus/Ctrl+Minus)
139
+ self.root.bind("<Control-plus>", self._on_increase_font)
140
+ self.root.bind(
141
+ "<Control-equal>", self._on_increase_font
142
+ ) # For keyboards without numpad
143
+ self.root.bind("<Control-minus>", self._on_decrease_font)
144
+ self.root.bind("<Control-0>", self._on_reset_font)
145
+
146
+ # Sorting (s key to cycle)
147
+ self.root.bind("s", self._on_cycle_sort)
148
+
149
+ # Toggle hidden files (. key)
150
+ self.root.bind(".", self._on_toggle_hidden)
151
+
152
+ # Export (e key)
153
+ self.root.bind("e", self._on_export)
154
+
155
+ # Open folder (Ctrl+O)
156
+ self.root.bind("<Control-o>", self._on_open_folder_event)
157
+
158
+ # Breadcrumb frame (initially hidden)
159
+ self._breadcrumb_visible: bool = False
160
+ self._breadcrumb_frame: ttk.Frame | None = None
161
+ self._breadcrumb_labels: list[ttk.Label] = []
162
+
163
+ def _on_double_click(self, event: tk.Event) -> str | None:
164
+ """Handle double-click on treeview item."""
165
+ item = self.treeview.identify_row(event.y)
166
+ if item:
167
+ self._toggle_expand_collapse(item)
168
+ return None
169
+
170
+ def _on_enter_key(self, event: tk.Event) -> str | None:
171
+ """Handle Enter key press."""
172
+ selection = self.treeview.selection()
173
+ if selection:
174
+ self._toggle_expand_collapse(selection[0])
175
+ return None
176
+
177
+ def _toggle_expand_collapse(self, item: str) -> None:
178
+ """Toggle expand/collapse state of an item."""
179
+ node = self.treeview.get_node(item)
180
+ if node is None or not node.is_dir:
181
+ return
182
+
183
+ if self.treeview.item(item, "open"):
184
+ self.treeview.collapse_node(item)
185
+ else:
186
+ self.treeview.expand_node(item)
187
+
188
+ def _on_down(self, event: tk.Event) -> str | None:
189
+ """Move selection down."""
190
+ selection = self.treeview.selection()
191
+ if not selection:
192
+ # Select first item
193
+ children = self.treeview.get_children()
194
+ if children:
195
+ self.treeview.select_node(children[0])
196
+ else:
197
+ # Get next sibling or parent's next sibling
198
+ current = selection[0]
199
+ next_item = self.treeview.next(current)
200
+ if next_item:
201
+ self.treeview.select_node(next_item)
202
+ else:
203
+ # Try to find next of parent
204
+ parent = self.treeview.parent(current)
205
+ if parent:
206
+ next_of_parent = self.treeview.next(parent)
207
+ if next_of_parent:
208
+ self.treeview.select_node(next_of_parent)
209
+ return "break"
210
+
211
+ def _on_up(self, event: tk.Event) -> str | None:
212
+ """Move selection up."""
213
+ selection = self.treeview.selection()
214
+ if not selection:
215
+ return "break"
216
+
217
+ current = selection[0]
218
+ prev_item = self.treeview.prev(current)
219
+ if prev_item:
220
+ self.treeview.select_node(prev_item)
221
+ else:
222
+ # Select parent
223
+ parent = self.treeview.parent(current)
224
+ if parent:
225
+ self.treeview.select_node(parent)
226
+ return "break"
227
+
228
+ def _on_expand(self, event: tk.Event) -> str | None:
229
+ """Expand selected directory."""
230
+ selection = self.treeview.selection()
231
+ if selection:
232
+ self.treeview.expand_node(selection[0])
233
+ return "break"
234
+
235
+ def _on_collapse(self, event: tk.Event) -> str | None:
236
+ """Collapse selected directory."""
237
+ selection = self.treeview.selection()
238
+ if selection:
239
+ self.treeview.collapse_node(selection[0])
240
+ return "break"
241
+
242
+ def _on_jump_top(self, event: tk.Event) -> str | None:
243
+ """Jump to first item."""
244
+ children = self.treeview.get_children()
245
+ if children:
246
+ self.treeview.select_node(children[0])
247
+ return "break"
248
+
249
+ def _on_jump_bottom(self, event: tk.Event) -> str | None:
250
+ """Jump to last visible item."""
251
+
252
+ # Get all visible items
253
+ def get_last_visible(item: str) -> str:
254
+ if self.treeview.item(item, "open"):
255
+ children = self.treeview.get_children(item)
256
+ if children:
257
+ return get_last_visible(children[-1])
258
+ return item
259
+
260
+ children = self.treeview.get_children()
261
+ if children:
262
+ last = get_last_visible(children[-1])
263
+ self.treeview.select_node(last)
264
+ return "break"
265
+
266
+ def _on_quit(self, event: tk.Event) -> str | None:
267
+ """Quit application."""
268
+ self.quit()
269
+ return "break"
270
+
271
+ def _on_rescan(self, event: tk.Event) -> str | None:
272
+ """Rescan current directory."""
273
+ selection = self.treeview.selection()
274
+ if selection:
275
+ node = self.treeview.get_node(selection[0])
276
+ if node is not None and node.is_dir:
277
+ self.treeview.delete(*self.treeview.get_children(selection[0]))
278
+ node.children.clear()
279
+ self.start_background_scan(node.path, node, selection[0])
280
+ else:
281
+ # Rescan root
282
+ self.treeview.clear()
283
+ if self._scanner is not None:
284
+ self.start_background_scan(self._root_path)
285
+ return "break"
286
+
287
+ def _on_delete(self, event: tk.Event) -> str | None:
288
+ """Delete selected item with confirmation."""
289
+ selection = self.treeview.selection()
290
+ if not selection:
291
+ return "break"
292
+
293
+ node = self.treeview.get_node(selection[0])
294
+ if node is None:
295
+ return "break"
296
+
297
+ # Show confirmation dialog
298
+ item_type = "directory" if node.is_dir else "file"
299
+ confirm = messagebox.askyesno(
300
+ "Confirm Delete",
301
+ f"Are you sure you want to delete this {item_type}?\n\n{node.path}",
302
+ icon="warning",
303
+ )
304
+
305
+ if not confirm:
306
+ return "break"
307
+
308
+ try:
309
+ # Try to use send2trash first (safer - moves to trash)
310
+ try:
311
+ from send2trash import send2trash
312
+
313
+ send2trash(str(node.path))
314
+ method = "moved to trash"
315
+ except ImportError:
316
+ # Fallback to permanent deletion
317
+ import shutil
318
+
319
+ if node.is_dir:
320
+ shutil.rmtree(node.path)
321
+ else:
322
+ node.path.unlink()
323
+ method = "deleted permanently"
324
+
325
+ # Remove from treeview
326
+ self.treeview.delete_node(selection[0])
327
+
328
+ # Update status
329
+ self.set_status(f"Deleted: {node.name} ({method})")
330
+
331
+ except Exception as e:
332
+ messagebox.showerror(
333
+ "Delete Failed",
334
+ f"Failed to delete {node.name}:\n{str(e)}",
335
+ )
336
+
337
+ return "break"
338
+
339
+ def _on_toggle_breadcrumb(self, event: tk.Event) -> str | None:
340
+ """Toggle breadcrumb path bar visibility."""
341
+ if self._breadcrumb_frame is None:
342
+ self._create_breadcrumb()
343
+
344
+ if self._breadcrumb_frame is not None:
345
+ if self._breadcrumb_visible:
346
+ self._breadcrumb_frame.grid_remove()
347
+ self._breadcrumb_visible = False
348
+ else:
349
+ self._breadcrumb_frame.grid()
350
+ self._update_breadcrumb()
351
+ self._breadcrumb_visible = True
352
+
353
+ return "break"
354
+
355
+ def _create_breadcrumb(self) -> None:
356
+ """Create the breadcrumb path bar."""
357
+ self._breadcrumb_frame = ttk.Frame(
358
+ self.main_frame, relief="groove", padding="2"
359
+ )
360
+ self._breadcrumb_frame.grid(row=0, column=0, sticky="ew", pady=(0, 5))
361
+ self._breadcrumb_frame.grid_remove() # Initially hidden
362
+
363
+ # Container for path segments
364
+ self._breadcrumb_container = ttk.Frame(self._breadcrumb_frame)
365
+ self._breadcrumb_container.pack(fill="x", expand=True)
366
+
367
+ def _update_breadcrumb(self) -> None:
368
+ """Update breadcrumb display with current path."""
369
+ if self._breadcrumb_frame is None:
370
+ return
371
+
372
+ # Clear existing labels
373
+ for label in self._breadcrumb_labels:
374
+ label.destroy()
375
+ self._breadcrumb_labels.clear()
376
+
377
+ # Get current path from selection or root
378
+ selection = self.treeview.selection()
379
+ if selection:
380
+ node = self.treeview.get_node(selection[0])
381
+ current_path = node.path if node else self._root_path
382
+ else:
383
+ current_path = getattr(self, "_root_path", Path("."))
384
+
385
+ # Create path segments
386
+ parts: list[Path] = []
387
+ current = current_path
388
+ while current != current.parent:
389
+ parts.insert(0, current)
390
+ current = current.parent
391
+ parts.insert(0, current) # Add root
392
+
393
+ # Create labels for each segment
394
+ for i, part in enumerate(parts):
395
+ # Separator
396
+ if i > 0:
397
+ sep = ttk.Label(self._breadcrumb_container, text=" > ")
398
+ sep.pack(side="left")
399
+ self._breadcrumb_labels.append(sep)
400
+
401
+ # Clickable path segment
402
+ label = ttk.Label(
403
+ self._breadcrumb_container,
404
+ text=part.name if part.name else str(part),
405
+ foreground="blue",
406
+ cursor="hand2",
407
+ )
408
+ label.pack(side="left")
409
+ label.bind(
410
+ "<Button-1>",
411
+ lambda e, p=part: self._navigate_to_path(p), # type: ignore[misc]
412
+ )
413
+ self._breadcrumb_labels.append(label)
414
+
415
+ def _navigate_to_path(self, path: Path) -> None:
416
+ """Navigate to a specific path."""
417
+ # Find the item in treeview
418
+ iid = str(path)
419
+ node = self.treeview.get_node(iid)
420
+
421
+ if node is not None:
422
+ # Select and show the node
423
+ self.treeview.select_node(iid)
424
+ # Expand all parents
425
+ current = iid
426
+ while True:
427
+ parent = self.treeview.parent(current)
428
+ if not parent:
429
+ break
430
+ self.treeview.expand_node(parent)
431
+ current = parent
432
+
433
+ def _on_parent_directory(self, event: tk.Event) -> str | None:
434
+ """Navigate to parent directory."""
435
+ selection = self.treeview.selection()
436
+ if not selection:
437
+ # If nothing selected, select first item
438
+ children = self.treeview.get_children()
439
+ if children:
440
+ self.treeview.select_node(children[0])
441
+ return "break"
442
+
443
+ current = selection[0]
444
+ parent = self.treeview.parent(current)
445
+
446
+ if parent:
447
+ self.treeview.select_node(parent)
448
+ else:
449
+ # Already at root, try to find parent in filesystem
450
+ node = self.treeview.get_node(current)
451
+ if node and node.path != node.path.parent:
452
+ # Create parent node if needed
453
+ parent_path = node.path.parent
454
+ parent_iid = str(parent_path)
455
+
456
+ # Check if parent already exists
457
+ if self.treeview.get_node(parent_iid) is None:
458
+ parent_node = DirNode(
459
+ path=parent_path,
460
+ name=parent_path.name if parent_path.name else str(parent_path),
461
+ is_dir=True,
462
+ )
463
+ parent_node.is_expanded = True
464
+ # Insert at top level (we'll need to reorganize tree)
465
+ self.treeview.insert_node("", parent_node)
466
+
467
+ self.treeview.select_node(parent_iid)
468
+
469
+ return "break"
470
+
471
+ def _on_cycle_sort(self, event: tk.Event) -> str | None:
472
+ """Cycle through sorting modes."""
473
+ # Get next sort mode
474
+ modes = list(SortMode)
475
+ current_index = modes.index(self._sort_mode)
476
+ next_index = (current_index + 1) % len(modes)
477
+ self._sort_mode = modes[next_index]
478
+
479
+ # Show status
480
+ self.set_status(f"Sort: {self.SORT_MODE_NAMES[self._sort_mode]}")
481
+
482
+ # Re-sort the current view if we have items
483
+ self._resort_treeview()
484
+
485
+ return "break"
486
+
487
+ def _on_toggle_hidden(self, event: tk.Event) -> str | None:
488
+ """Toggle visibility of hidden files (dotfiles)."""
489
+ self._show_hidden = not self._show_hidden
490
+
491
+ status = "shown" if self._show_hidden else "hidden"
492
+ self.set_status(f"Hidden files: {status}")
493
+
494
+ # Trigger a rescan to apply the filter
495
+ self._on_rescan(event)
496
+
497
+ return "break"
498
+
499
+ def _on_open_folder_event(self, event: tk.Event) -> str | None:
500
+ """Event handler for open folder."""
501
+ self._on_open_folder()
502
+ return "break"
503
+
504
+ def _on_export(self, event: tk.Event) -> str | None:
505
+ """Export current scan results to JSON or CSV."""
506
+ # Ask user for export format
507
+ formats = [
508
+ ("JSON files", "*.json"),
509
+ ("CSV files", "*.csv"),
510
+ ("All files", "*.*"),
511
+ ]
512
+ filename = filedialog.asksaveasfilename(
513
+ defaultextension=".json",
514
+ filetypes=formats,
515
+ title="Export Scan Results",
516
+ )
517
+
518
+ if not filename:
519
+ return "break"
520
+
521
+ try:
522
+ filepath = Path(filename)
523
+ if filepath.suffix.lower() == ".json":
524
+ self._export_json(filepath)
525
+ elif filepath.suffix.lower() == ".csv":
526
+ self._export_csv(filepath)
527
+ else:
528
+ # Default to JSON
529
+ self._export_json(filepath.with_suffix(".json"))
530
+
531
+ self.set_status(f"Exported to {filepath.name}")
532
+
533
+ except Exception as e:
534
+ messagebox.showerror(
535
+ "Export Failed",
536
+ f"Failed to export results:\n{str(e)}",
537
+ )
538
+
539
+ return "break"
540
+
541
+ def _collect_export_data(self) -> list[ExportDataItem]:
542
+ """Collect current treeview data for export."""
543
+ data: list[ExportDataItem] = []
544
+
545
+ def collect_items(parent_iid: str = "") -> None:
546
+ for iid in self.treeview.get_children(parent_iid):
547
+ node = self.treeview.get_node(iid)
548
+ if node is not None:
549
+ data.append(
550
+ {
551
+ "path": str(node.path),
552
+ "name": node.name,
553
+ "size": node.size,
554
+ "size_human": self.treeview._font_manager.measure_text_width(
555
+ node.name, "treeview"
556
+ ),
557
+ "is_dir": node.is_dir,
558
+ "item_count": node.get_item_count() if node.is_dir else 0,
559
+ }
560
+ )
561
+ # Recursively collect children
562
+ if node.is_dir:
563
+ collect_items(iid)
564
+
565
+ collect_items()
566
+ return data
567
+
568
+ def _export_json(self, filepath: Path) -> None:
569
+ """Export data to JSON format."""
570
+ data = self._collect_export_data()
571
+
572
+ # Add metadata
573
+ export_data = {
574
+ "exported_from": str(self._root_path),
575
+ "total_items": len(data),
576
+ "items": data,
577
+ }
578
+
579
+ with open(filepath, "w", encoding="utf-8") as f:
580
+ json.dump(export_data, f, indent=2, ensure_ascii=False)
581
+
582
+ def _export_csv(self, filepath: Path) -> None:
583
+ """Export data to CSV format."""
584
+ data = self._collect_export_data()
585
+
586
+ with open(filepath, "w", newline="", encoding="utf-8") as f:
587
+ writer = csv.DictWriter(
588
+ f,
589
+ fieldnames=["path", "name", "size", "is_dir", "item_count"],
590
+ )
591
+ writer.writeheader()
592
+ writer.writerows(data)
593
+
594
+ def _get_sort_key(self, node: DirNode) -> tuple[object, ...]:
595
+ """Get sort key for a node based on current sort mode.
596
+
597
+ Returns:
598
+ Tuple for sorting (directories first, then by sort criteria)
599
+ """
600
+ # Always put directories first, then sort by criteria
601
+ if self._sort_mode == SortMode.SIZE:
602
+ # Sort by size descending, then by name ascending
603
+ return (0 if node.is_dir else 1, -node.size, node.name.lower())
604
+ elif self._sort_mode == SortMode.NAME:
605
+ # Sort by name ascending
606
+ return (0 if node.is_dir else 1, node.name.lower())
607
+ else: # SortMode.COUNT
608
+ # Sort by item count descending, then by name
609
+ count = node.get_item_count() if node.is_dir else 0
610
+ return (0 if node.is_dir else 1, -count, node.name.lower())
611
+
612
+ def _resort_treeview(self) -> None:
613
+ """Re-sort all items in the treeview."""
614
+ # Get all items and their nodes
615
+ items_to_move: list[tuple[str, str, DirNode]] = [] # (parent_iid, iid, node)
616
+
617
+ def collect_items(parent_iid: str = "") -> None:
618
+ for iid in self.treeview.get_children(parent_iid):
619
+ node = self.treeview.get_node(iid)
620
+ if node is not None:
621
+ items_to_move.append((parent_iid, iid, node))
622
+ # Recursively collect children
623
+ if node.is_dir:
624
+ collect_items(iid)
625
+
626
+ collect_items()
627
+
628
+ # Sort by current sort mode
629
+ items_to_move.sort(key=lambda x: self._get_sort_key(x[2]))
630
+
631
+ # Move items to their new positions within each parent
632
+ # Group by parent
633
+ from itertools import groupby
634
+
635
+ items_to_move.sort(key=lambda x: x[0]) # Sort by parent first
636
+ for parent_iid, group in groupby(items_to_move, key=lambda x: x[0]):
637
+ group_list = list(group)
638
+ # Sort this parent's children
639
+ group_list.sort(key=lambda x: self._get_sort_key(x[2]))
640
+ # Move each item to the end in sorted order
641
+ for _, iid, _ in group_list:
642
+ self.treeview.move(iid, parent_iid, "end")
643
+
644
+ def _on_increase_font(self, event: tk.Event) -> str | None:
645
+ """Increase font size."""
646
+ new_size = self._font_manager.increase_font_size()
647
+ self.treeview.refresh_fonts()
648
+ self._refresh_status_fonts()
649
+ self.set_status(f"Font size: {new_size}pt")
650
+ return "break"
651
+
652
+ def _on_decrease_font(self, event: tk.Event) -> str | None:
653
+ """Decrease font size."""
654
+ new_size = self._font_manager.decrease_font_size()
655
+ self.treeview.refresh_fonts()
656
+ self._refresh_status_fonts()
657
+ self.set_status(f"Font size: {new_size}pt")
658
+ return "break"
659
+
660
+ def _on_reset_font(self, event: tk.Event) -> str | None:
661
+ """Reset font size to default."""
662
+ new_size = self._font_manager.reset_font_size()
663
+ self.treeview.refresh_fonts()
664
+ self._refresh_status_fonts()
665
+ self.set_status(f"Font size: {new_size}pt (default)")
666
+ return "break"
667
+
668
+ def _refresh_status_fonts(self) -> None:
669
+ """Refresh status bar fonts after size change."""
670
+ status_font = self._font_manager.get_status_font()
671
+ self.path_label.configure(font=status_font)
672
+ self.items_label.configure(font=status_font)
673
+ self.status_label.configure(font=status_font)
674
+
675
+ def _setup_window(self) -> None:
676
+ """Configure the main window properties."""
677
+ self.root.title("PTDU - Python Tkinter Disk Usage")
678
+ self.root.geometry(self._get_centered_geometry())
679
+ self.root.minsize(600, 400)
680
+
681
+ # Set up grid weights for resizing
682
+ self.root.columnconfigure(0, weight=1)
683
+ self.root.rowconfigure(0, weight=1)
684
+
685
+ def _get_centered_geometry(self) -> str:
686
+ """Calculate centered window geometry string.
687
+
688
+ Returns:
689
+ Geometry string in format 'WIDTHxHEIGHT+X+Y'
690
+ """
691
+ screen_width = self.root.winfo_screenwidth()
692
+ screen_height = self.root.winfo_screenheight()
693
+
694
+ x = (screen_width - self.DEFAULT_WIDTH) // 2
695
+ y = (screen_height - self.DEFAULT_HEIGHT) // 2
696
+
697
+ return f"{self.DEFAULT_WIDTH}x{self.DEFAULT_HEIGHT}+{x}+{y}"
698
+
699
+ def _create_menubar(self) -> None:
700
+ """Create the application menubar with keyboard shortcuts."""
701
+ menubar = tk.Menu(self.root)
702
+ self.root.config(menu=menubar)
703
+
704
+ # File Menu
705
+ file_menu = tk.Menu(menubar, tearoff=0)
706
+ menubar.add_cascade(label="File", menu=file_menu)
707
+ file_menu.add_command(
708
+ label="Open Folder...",
709
+ command=self._on_open_folder,
710
+ accelerator="Ctrl+O",
711
+ )
712
+ file_menu.add_separator()
713
+ file_menu.add_command(
714
+ label="Export...",
715
+ command=self._on_export_menu,
716
+ accelerator="E",
717
+ )
718
+ file_menu.add_separator()
719
+ file_menu.add_command(
720
+ label="Quit",
721
+ command=self.quit,
722
+ accelerator="Q",
723
+ )
724
+
725
+ # View Menu
726
+ view_menu = tk.Menu(menubar, tearoff=0)
727
+ menubar.add_cascade(label="View", menu=view_menu)
728
+ view_menu.add_command(
729
+ label="Toggle Hidden Files",
730
+ command=self._on_toggle_hidden_menu,
731
+ accelerator=".",
732
+ )
733
+ view_menu.add_command(
734
+ label="Toggle Breadcrumb",
735
+ command=self._on_toggle_breadcrumb_menu,
736
+ accelerator="B",
737
+ )
738
+ view_menu.add_separator()
739
+ view_menu.add_command(
740
+ label="Increase Font Size",
741
+ command=self._on_increase_font_menu,
742
+ accelerator="Ctrl++",
743
+ )
744
+ view_menu.add_command(
745
+ label="Decrease Font Size",
746
+ command=self._on_decrease_font_menu,
747
+ accelerator="Ctrl+-",
748
+ )
749
+ view_menu.add_command(
750
+ label="Reset Font Size",
751
+ command=self._on_reset_font_menu,
752
+ accelerator="Ctrl+0",
753
+ )
754
+
755
+ # Sort Menu
756
+ sort_menu = tk.Menu(menubar, tearoff=0)
757
+ menubar.add_cascade(label="Sort", menu=sort_menu)
758
+ sort_menu.add_command(
759
+ label="Cycle Sort Mode",
760
+ command=self._on_cycle_sort_menu,
761
+ accelerator="S",
762
+ )
763
+
764
+ # Navigation Menu
765
+ nav_menu = tk.Menu(menubar, tearoff=0)
766
+ menubar.add_cascade(label="Navigation", menu=nav_menu)
767
+ nav_menu.add_command(
768
+ label="Up",
769
+ command=self._on_up_menu,
770
+ accelerator="K or ↑",
771
+ )
772
+ nav_menu.add_command(
773
+ label="Down",
774
+ command=self._on_down_menu,
775
+ accelerator="J or ↓",
776
+ )
777
+ nav_menu.add_separator()
778
+ nav_menu.add_command(
779
+ label="Expand",
780
+ command=self._on_expand_menu,
781
+ accelerator="L or →",
782
+ )
783
+ nav_menu.add_command(
784
+ label="Collapse",
785
+ command=self._on_collapse_menu,
786
+ accelerator="H or ←",
787
+ )
788
+ nav_menu.add_separator()
789
+ nav_menu.add_command(
790
+ label="Jump to Top",
791
+ command=self._on_jump_top_menu,
792
+ accelerator="G",
793
+ )
794
+ nav_menu.add_command(
795
+ label="Jump to Bottom",
796
+ command=self._on_jump_bottom_menu,
797
+ accelerator="Shift+G",
798
+ )
799
+ nav_menu.add_separator()
800
+ nav_menu.add_command(
801
+ label="Go to Parent",
802
+ command=self._on_parent_directory_menu,
803
+ accelerator="U",
804
+ )
805
+
806
+ # Actions Menu
807
+ actions_menu = tk.Menu(menubar, tearoff=0)
808
+ menubar.add_cascade(label="Actions", menu=actions_menu)
809
+ actions_menu.add_command(
810
+ label="Rescan",
811
+ command=self._on_rescan_menu,
812
+ accelerator="R",
813
+ )
814
+ actions_menu.add_command(
815
+ label="Delete Selected",
816
+ command=self._on_delete_menu,
817
+ accelerator="D",
818
+ )
819
+
820
+ def _on_open_folder(self) -> None:
821
+ """Open a different folder."""
822
+ folder = filedialog.askdirectory(title="Select Folder to Analyze")
823
+ if folder:
824
+ path = Path(folder)
825
+ if path.exists() and path.is_dir():
826
+ self._root_path = path
827
+ self.treeview.clear()
828
+ self.start_background_scan(path)
829
+
830
+ def _on_export_menu(self) -> None:
831
+ """Menu callback for export."""
832
+ self._on_export(tk.Event())
833
+
834
+ def _on_toggle_hidden_menu(self) -> None:
835
+ """Menu callback for toggle hidden."""
836
+ self._on_toggle_hidden(tk.Event())
837
+
838
+ def _on_toggle_breadcrumb_menu(self) -> None:
839
+ """Menu callback for toggle breadcrumb."""
840
+ self._on_toggle_breadcrumb(tk.Event())
841
+
842
+ def _on_increase_font_menu(self) -> None:
843
+ """Menu callback for increase font."""
844
+ self._on_increase_font(tk.Event())
845
+
846
+ def _on_decrease_font_menu(self) -> None:
847
+ """Menu callback for decrease font."""
848
+ self._on_decrease_font(tk.Event())
849
+
850
+ def _on_reset_font_menu(self) -> None:
851
+ """Menu callback for reset font."""
852
+ self._on_reset_font(tk.Event())
853
+
854
+ def _on_cycle_sort_menu(self) -> None:
855
+ """Menu callback for cycle sort."""
856
+ self._on_cycle_sort(tk.Event())
857
+
858
+ def _on_up_menu(self) -> None:
859
+ """Menu callback for up navigation."""
860
+ self._on_up(tk.Event())
861
+
862
+ def _on_down_menu(self) -> None:
863
+ """Menu callback for down navigation."""
864
+ self._on_down(tk.Event())
865
+
866
+ def _on_expand_menu(self) -> None:
867
+ """Menu callback for expand."""
868
+ self._on_expand(tk.Event())
869
+
870
+ def _on_collapse_menu(self) -> None:
871
+ """Menu callback for collapse."""
872
+ self._on_collapse(tk.Event())
873
+
874
+ def _on_jump_top_menu(self) -> None:
875
+ """Menu callback for jump top."""
876
+ self._on_jump_top(tk.Event())
877
+
878
+ def _on_jump_bottom_menu(self) -> None:
879
+ """Menu callback for jump bottom."""
880
+ self._on_jump_bottom(tk.Event())
881
+
882
+ def _on_parent_directory_menu(self) -> None:
883
+ """Menu callback for parent directory."""
884
+ self._on_parent_directory(tk.Event())
885
+
886
+ def _on_rescan_menu(self) -> None:
887
+ """Menu callback for rescan."""
888
+ self._on_rescan(tk.Event())
889
+
890
+ def _on_delete_menu(self) -> None:
891
+ """Menu callback for delete."""
892
+ self._on_delete(tk.Event())
893
+
894
+ def _create_layout(self) -> None:
895
+ """Create the main window layout."""
896
+ # Create menubar first
897
+ self._create_menubar()
898
+
899
+ # Main frame
900
+ self.main_frame = ttk.Frame(self.root, padding="5")
901
+ self.main_frame.grid(row=0, column=0, sticky="nsew")
902
+ self.main_frame.columnconfigure(0, weight=1)
903
+ self.main_frame.rowconfigure(0, weight=1)
904
+
905
+ # Treeview frame with scrollbar
906
+ self.tree_frame = ttk.Frame(self.main_frame)
907
+ self.tree_frame.grid(row=0, column=0, sticky="nsew", pady=(0, 5))
908
+ self.tree_frame.columnconfigure(0, weight=1)
909
+ self.tree_frame.rowconfigure(0, weight=1)
910
+
911
+ # Directory treeview with font manager
912
+ self.treeview = DirectoryTreeview(self.tree_frame, self._font_manager)
913
+ self.treeview.grid(row=0, column=0, sticky="nsew")
914
+
915
+ # Vertical scrollbar
916
+ self.vscrollbar = ttk.Scrollbar(
917
+ self.tree_frame,
918
+ orient="vertical",
919
+ command=self.treeview.yview,
920
+ )
921
+ self.vscrollbar.grid(row=0, column=1, sticky="ns")
922
+ self.treeview.configure(yscrollcommand=self.vscrollbar.set)
923
+
924
+ # Horizontal scrollbar
925
+ self.hscrollbar = ttk.Scrollbar(
926
+ self.tree_frame,
927
+ orient="horizontal",
928
+ command=self.treeview.xview,
929
+ )
930
+ self.hscrollbar.grid(row=1, column=0, sticky="ew")
931
+ self.treeview.configure(xscrollcommand=self.hscrollbar.set)
932
+
933
+ # Status bar
934
+ self._create_status_bar()
935
+
936
+ def _create_status_bar(self) -> None:
937
+ """Create the status bar at the bottom."""
938
+ status_font = self._font_manager.get_status_font()
939
+
940
+ # Use a frame with custom background for better styling
941
+ self.status_frame = tk.Frame(
942
+ self.main_frame,
943
+ relief="sunken",
944
+ borderwidth=1,
945
+ background="#f0f0f0",
946
+ )
947
+ self.status_frame.grid(row=1, column=0, sticky="ew", pady=(5, 0))
948
+ self.status_frame.columnconfigure(0, weight=1)
949
+
950
+ # Path label
951
+ self.path_var = tk.StringVar(value="Ready")
952
+ self.path_label = tk.Label(
953
+ self.status_frame,
954
+ textvariable=self.path_var,
955
+ font=status_font,
956
+ background="#f0f0f0",
957
+ foreground="#333333",
958
+ padx=10,
959
+ pady=4,
960
+ )
961
+ self.path_label.grid(row=0, column=0, sticky="w")
962
+
963
+ # Separator
964
+ separator = tk.Frame(
965
+ self.status_frame,
966
+ width=1,
967
+ background="#cccccc",
968
+ )
969
+ separator.grid(row=0, column=1, sticky="ns", padx=5, pady=4)
970
+
971
+ # Items count label
972
+ self.items_var = tk.StringVar(value="Items: 0")
973
+ self.items_label = tk.Label(
974
+ self.status_frame,
975
+ textvariable=self.items_var,
976
+ font=status_font,
977
+ background="#f0f0f0",
978
+ foreground="#666666",
979
+ padx=10,
980
+ pady=4,
981
+ )
982
+ self.items_label.grid(row=0, column=2, sticky="e")
983
+
984
+ # Separator
985
+ separator2 = tk.Frame(
986
+ self.status_frame,
987
+ width=1,
988
+ background="#cccccc",
989
+ )
990
+ separator2.grid(row=0, column=3, sticky="ns", padx=5, pady=4)
991
+
992
+ # Status label
993
+ self.status_var = tk.StringVar(value="")
994
+ self.status_label = tk.Label(
995
+ self.status_frame,
996
+ textvariable=self.status_var,
997
+ font=status_font,
998
+ background="#f0f0f0",
999
+ foreground="#0066cc",
1000
+ padx=10,
1001
+ pady=4,
1002
+ )
1003
+ self.status_label.grid(row=0, column=4, sticky="e")
1004
+
1005
+ def set_scanner(self, scanner: Scanner) -> None:
1006
+ """Set the scanner instance to use for background scans.
1007
+
1008
+ Args:
1009
+ scanner: Scanner instance
1010
+ """
1011
+ self._scanner = scanner
1012
+ # Extract cache from scanner if available
1013
+ if hasattr(scanner, "_cache"):
1014
+ self._cache = scanner._cache
1015
+
1016
+ def set_max_depth(self, depth: int) -> None:
1017
+ """Set maximum scan depth.
1018
+
1019
+ Args:
1020
+ depth: Maximum recursion depth
1021
+ """
1022
+ self._max_depth = depth
1023
+
1024
+ def start_background_scan(
1025
+ self,
1026
+ path: Path,
1027
+ parent_node: Optional[DirNode] = None,
1028
+ parent_iid: str = "",
1029
+ ) -> None:
1030
+ """Start a background scan of a directory.
1031
+
1032
+ Args:
1033
+ path: Directory path to scan
1034
+ parent_node: Optional parent node in the tree
1035
+ parent_iid: Parent item ID in the treeview
1036
+ """
1037
+ if self._scanner is None:
1038
+ raise RuntimeError("Scanner not set. Call set_scanner() first.")
1039
+
1040
+ # Validate path
1041
+ is_valid, error_msg = PathValidator.validate_path(path)
1042
+ if not is_valid:
1043
+ self._error_handler.show_error("Invalid Path", error_msg or "Unknown error")
1044
+ return
1045
+
1046
+ # Check for long path
1047
+ if len(str(path)) > PathValidator.MAX_SAFE_PATH_LENGTH:
1048
+ self._error_handler.handle_path_too_long(path)
1049
+
1050
+ # Configure scanner with current filter settings
1051
+ self._scanner.set_show_hidden(self._show_hidden)
1052
+
1053
+ # Update status
1054
+ self.set_path(str(path))
1055
+ self.set_status("Scanning...")
1056
+
1057
+ # Store root path for rescan
1058
+ self._root_path = path
1059
+
1060
+ # Start the scan in background
1061
+ try:
1062
+ self._scan_manager.start_scan(path, self._scanner, parent_node)
1063
+ except Exception as e:
1064
+ self._error_handler.handle_scan_error(path, e, continue_scan=False)
1065
+
1066
+ def _schedule_message_check(self) -> None:
1067
+ """Schedule periodic checking for scan messages."""
1068
+ self._check_messages()
1069
+ self._after_id = self.root.after(
1070
+ self.MESSAGE_CHECK_INTERVAL, self._schedule_message_check
1071
+ )
1072
+
1073
+ def _check_messages(self) -> None:
1074
+ """Check for and process messages from scan threads."""
1075
+ messages = self._scan_manager.get_messages()
1076
+
1077
+ for msg in messages:
1078
+ self._process_message(msg)
1079
+
1080
+ # Update status if scans are active
1081
+ if self._scan_manager.has_active_scans():
1082
+ self.set_status("Scanning...")
1083
+ else:
1084
+ self.set_status("Ready")
1085
+
1086
+ def _process_message(self, msg: ScanMessage) -> None:
1087
+ """Process a single scan message.
1088
+
1089
+ Args:
1090
+ msg: Message from scan thread
1091
+ """
1092
+ if isinstance(msg, ScanProgress):
1093
+ # Update progress display
1094
+ self.items_var.set(f"Items: {msg.items_scanned}")
1095
+
1096
+ elif isinstance(msg, ScanComplete):
1097
+ # Scan completed - populate treeview
1098
+ self._handle_scan_complete(msg)
1099
+
1100
+ elif isinstance(msg, ScanError):
1101
+ # Scan error - show in status and handle properly
1102
+ self.set_status(f"Error: {msg.error_message}")
1103
+ self._error_handler.handle_scan_error(
1104
+ msg.path,
1105
+ Exception(msg.error_message),
1106
+ continue_scan=True,
1107
+ )
1108
+
1109
+ def _handle_scan_complete(self, msg: ScanComplete) -> None:
1110
+ """Handle scan completion message.
1111
+
1112
+ Builds a proper tree structure from recursive scan results.
1113
+
1114
+ Args:
1115
+ msg: Completion message with results
1116
+ """
1117
+ # Calculate max size for percentages (use total size for proper percentages)
1118
+ max_size = msg.total_size if msg.total_size > 0 else 1
1119
+ self.treeview.set_max_size(max_size)
1120
+
1121
+ # Build node map: path -> DirNode
1122
+ node_map: dict[Path, DirNode] = {}
1123
+
1124
+ # First pass: create all nodes
1125
+ for result in msg.results:
1126
+ if result.error:
1127
+ continue
1128
+
1129
+ node = DirNode(
1130
+ path=result.path,
1131
+ name=result.name,
1132
+ is_dir=result.is_dir,
1133
+ )
1134
+ node.size = result.size if not result.is_dir else 0
1135
+ node_map[result.path] = node
1136
+
1137
+ # Create root node if not in results
1138
+ root_iid = str(msg.path)
1139
+ if msg.path not in node_map:
1140
+ root_node = DirNode(
1141
+ path=msg.path,
1142
+ name=msg.path.name if msg.path.name else str(msg.path),
1143
+ is_dir=True,
1144
+ )
1145
+ root_node.is_expanded = True
1146
+ node_map[msg.path] = root_node
1147
+ self.treeview.insert_node("", root_node)
1148
+
1149
+ # Second pass: establish parent-child relationships and calculate dir sizes
1150
+ for path, node in node_map.items():
1151
+ if path == msg.path:
1152
+ continue # Skip root
1153
+
1154
+ # Find parent
1155
+ parent_path = path.parent
1156
+ parent_node = node_map.get(parent_path)
1157
+
1158
+ if parent_node is not None:
1159
+ node.parent = parent_node
1160
+ parent_node.add_child(node)
1161
+
1162
+ # Add size to parent directory
1163
+ if not node.is_dir:
1164
+ parent_node.size += node.size
1165
+
1166
+ # Propagate sizes up the tree (bottom-up)
1167
+ # Sort by depth (deepest first) to propagate sizes correctly
1168
+ sorted_paths = sorted(node_map.keys(), key=lambda p: len(p.parts), reverse=True)
1169
+ for path in sorted_paths:
1170
+ node = node_map[path]
1171
+ if node.parent is not None and node.is_dir:
1172
+ node.parent.size += node.size
1173
+
1174
+ # Insert nodes into treeview, sorted by current sort mode
1175
+ # Collect all nodes to insert
1176
+ nodes_to_insert: list[tuple[Path, DirNode]] = []
1177
+ for path, node in node_map.items():
1178
+ if path == msg.path:
1179
+ continue # Root already inserted
1180
+ nodes_to_insert.append((path, node))
1181
+
1182
+ # Sort by depth first (parents before children), then by sort mode
1183
+ nodes_to_insert.sort(
1184
+ key=lambda item: (len(item[0].parts), self._get_sort_key(item[1]))
1185
+ )
1186
+
1187
+ # Insert in sorted order
1188
+ for path, node in nodes_to_insert:
1189
+ parent_path = path.parent
1190
+ parent_iid = str(parent_path)
1191
+ self.treeview.insert_node(parent_iid, node)
1192
+
1193
+ # Update root node size
1194
+ existing_root = node_map.get(msg.path)
1195
+ if isinstance(existing_root, DirNode):
1196
+ existing_root.size = msg.total_size
1197
+ root_iid = str(msg.path)
1198
+ if self.treeview.get_node(root_iid) is not None:
1199
+ self.treeview.update_node(root_iid)
1200
+
1201
+ # Update counts
1202
+ self.set_item_count(len(msg.results))
1203
+
1204
+ def set_path(self, path: str) -> None:
1205
+ """Update the path display in the status bar.
1206
+
1207
+ Args:
1208
+ path: Current path to display
1209
+ """
1210
+ self.path_var.set(path)
1211
+
1212
+ def set_item_count(self, count: int) -> None:
1213
+ """Update the item count display.
1214
+
1215
+ Args:
1216
+ count: Number of items
1217
+ """
1218
+ self.items_var.set(f"Items: {count}")
1219
+
1220
+ def set_status(self, status: str) -> None:
1221
+ """Update the status message.
1222
+
1223
+ Args:
1224
+ status: Status message to display
1225
+ """
1226
+ self.status_var.set(status)
1227
+
1228
+ def run(self) -> None:
1229
+ """Start the main event loop."""
1230
+ self.root.mainloop()
1231
+
1232
+ def cleanup(self) -> None:
1233
+ """Clean up resources before destruction."""
1234
+ # Stop all background scans
1235
+ self._scan_manager.stop_all()
1236
+ # Cancel scheduled after call
1237
+ if self._after_id is not None:
1238
+ self.root.after_cancel(self._after_id)
1239
+ self._after_id = None
1240
+ # Close cache connection
1241
+ if self._cache is not None:
1242
+ self._cache.close()
1243
+
1244
+ def quit(self) -> None:
1245
+ """Quit the application."""
1246
+ self.cleanup()
1247
+ self.root.destroy()