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/__init__.py +38 -0
- ptdu/cache.py +374 -0
- ptdu/errors.py +627 -0
- ptdu/fonts.py +237 -0
- ptdu/main.py +118 -0
- ptdu/models.py +130 -0
- ptdu/performance.py +348 -0
- ptdu/scanner.py +490 -0
- ptdu/threads.py +250 -0
- ptdu/treeview.py +426 -0
- ptdu/ui.py +1247 -0
- ptdu/utils.py +80 -0
- ptdu-0.1.0.dist-info/METADATA +341 -0
- ptdu-0.1.0.dist-info/RECORD +17 -0
- ptdu-0.1.0.dist-info/WHEEL +4 -0
- ptdu-0.1.0.dist-info/entry_points.txt +2 -0
- ptdu-0.1.0.dist-info/licenses/LICENSE +21 -0
ptdu/threads.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Threading components for background directory scanning."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from queue import Queue
|
|
9
|
+
from typing import Callable, Optional
|
|
10
|
+
|
|
11
|
+
from ptdu.models import DirNode
|
|
12
|
+
from ptdu.scanner import Scanner, ScanResult
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class ScanProgress:
|
|
17
|
+
"""Progress update from a scan operation."""
|
|
18
|
+
|
|
19
|
+
current_path: Path
|
|
20
|
+
items_scanned: int
|
|
21
|
+
total_items: Optional[int] = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ScanEntry:
|
|
26
|
+
"""Single entry discovered during scan (for real-time updates)."""
|
|
27
|
+
|
|
28
|
+
result: ScanResult
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class ScanComplete:
|
|
33
|
+
"""Scan completion notification."""
|
|
34
|
+
|
|
35
|
+
path: Path
|
|
36
|
+
results: list[ScanResult]
|
|
37
|
+
total_size: int
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class ScanError:
|
|
42
|
+
"""Scan error notification."""
|
|
43
|
+
|
|
44
|
+
path: Path
|
|
45
|
+
error_message: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Union type for scan messages
|
|
49
|
+
ScanMessage = ScanProgress | ScanEntry | ScanComplete | ScanError
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ScanThread(threading.Thread):
|
|
53
|
+
"""Background thread for directory scanning."""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
path: Path,
|
|
58
|
+
scanner: Scanner,
|
|
59
|
+
message_queue: Queue[ScanMessage],
|
|
60
|
+
parent_node: Optional[DirNode] = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Initialize the scan thread.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
path: Directory path to scan
|
|
66
|
+
scanner: Scanner instance to use
|
|
67
|
+
message_queue: Queue for sending progress/results back to main thread
|
|
68
|
+
parent_node: Optional parent DirNode for tree structure
|
|
69
|
+
"""
|
|
70
|
+
super().__init__(daemon=True)
|
|
71
|
+
self.path: Path = path
|
|
72
|
+
self.scanner: Scanner = scanner
|
|
73
|
+
self.message_queue: Queue[ScanMessage] = message_queue
|
|
74
|
+
self.parent_node: Optional[DirNode] = parent_node
|
|
75
|
+
self._stop_event: threading.Event = threading.Event()
|
|
76
|
+
self._items_scanned: int = 0
|
|
77
|
+
self._results: list[ScanResult] = []
|
|
78
|
+
|
|
79
|
+
def run(self) -> None:
|
|
80
|
+
"""Execute the scan operation in background."""
|
|
81
|
+
try:
|
|
82
|
+
if self._stop_event.is_set():
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
# Send initial progress
|
|
86
|
+
self._send_progress()
|
|
87
|
+
|
|
88
|
+
# Define progress callback - send entries in real-time
|
|
89
|
+
def on_entry_scanned(result: ScanResult) -> None:
|
|
90
|
+
if self._stop_event.is_set():
|
|
91
|
+
return
|
|
92
|
+
self._items_scanned += 1
|
|
93
|
+
self._results.append(result)
|
|
94
|
+
# Send entry immediately for real-time display
|
|
95
|
+
self.message_queue.put(ScanEntry(result=result))
|
|
96
|
+
self._send_progress()
|
|
97
|
+
|
|
98
|
+
# Perform recursive scan (like ncdu)
|
|
99
|
+
results = self.scanner.scan_recursive(
|
|
100
|
+
self.path,
|
|
101
|
+
progress_callback=on_entry_scanned,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if self._stop_event.is_set():
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
# Calculate total size (all files recursively)
|
|
108
|
+
total_size = sum(r.size for r in results if not r.is_dir)
|
|
109
|
+
|
|
110
|
+
# Send completion message
|
|
111
|
+
self.message_queue.put(
|
|
112
|
+
ScanComplete(
|
|
113
|
+
path=self.path,
|
|
114
|
+
results=results,
|
|
115
|
+
total_size=total_size,
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
except Exception as e:
|
|
120
|
+
# Send error message
|
|
121
|
+
self.message_queue.put(
|
|
122
|
+
ScanError(
|
|
123
|
+
path=self.path,
|
|
124
|
+
error_message=str(e),
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def _send_progress(self) -> None:
|
|
129
|
+
"""Send a progress update to the main thread."""
|
|
130
|
+
self.message_queue.put(
|
|
131
|
+
ScanProgress(
|
|
132
|
+
current_path=self.path,
|
|
133
|
+
items_scanned=self._items_scanned,
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def stop(self) -> None:
|
|
138
|
+
"""Request the scan to stop."""
|
|
139
|
+
self._stop_event.set()
|
|
140
|
+
|
|
141
|
+
def is_stopped(self) -> bool:
|
|
142
|
+
"""Check if stop has been requested.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
True if stop has been requested
|
|
146
|
+
"""
|
|
147
|
+
return self._stop_event.is_set()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class ScanThreadManager:
|
|
151
|
+
"""Manages active scan threads and coordinates UI updates."""
|
|
152
|
+
|
|
153
|
+
def __init__(self) -> None:
|
|
154
|
+
"""Initialize the scan manager."""
|
|
155
|
+
self._active_threads: dict[Path, ScanThread] = {}
|
|
156
|
+
self._message_queue: Queue[ScanMessage] = Queue()
|
|
157
|
+
self._lock: threading.Lock = threading.Lock()
|
|
158
|
+
|
|
159
|
+
def start_scan(
|
|
160
|
+
self,
|
|
161
|
+
path: Path,
|
|
162
|
+
scanner: Scanner,
|
|
163
|
+
parent_node: Optional[DirNode] = None,
|
|
164
|
+
) -> ScanThread:
|
|
165
|
+
"""Start a new scan thread.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
path: Directory path to scan
|
|
169
|
+
scanner: Scanner instance
|
|
170
|
+
parent_node: Optional parent node
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
The started ScanThread
|
|
174
|
+
"""
|
|
175
|
+
with self._lock:
|
|
176
|
+
# Cancel any existing scan for this path
|
|
177
|
+
if path in self._active_threads:
|
|
178
|
+
self._active_threads[path].stop()
|
|
179
|
+
|
|
180
|
+
# Create and start new thread
|
|
181
|
+
thread = ScanThread(
|
|
182
|
+
path=path,
|
|
183
|
+
scanner=scanner,
|
|
184
|
+
message_queue=self._message_queue,
|
|
185
|
+
parent_node=parent_node,
|
|
186
|
+
)
|
|
187
|
+
self._active_threads[path] = thread
|
|
188
|
+
thread.start()
|
|
189
|
+
return thread
|
|
190
|
+
|
|
191
|
+
def get_messages(self) -> list[ScanMessage]:
|
|
192
|
+
"""Retrieve all pending messages from the queue.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
List of messages (non-blocking)
|
|
196
|
+
"""
|
|
197
|
+
messages: list[ScanMessage] = []
|
|
198
|
+
while not self._message_queue.empty():
|
|
199
|
+
try:
|
|
200
|
+
msg = self._message_queue.get_nowait()
|
|
201
|
+
messages.append(msg)
|
|
202
|
+
except Exception:
|
|
203
|
+
break
|
|
204
|
+
return messages
|
|
205
|
+
|
|
206
|
+
def stop_scan(self, path: Path) -> None:
|
|
207
|
+
"""Stop a specific scan.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
path: Path of the scan to stop
|
|
211
|
+
"""
|
|
212
|
+
with self._lock:
|
|
213
|
+
if path in self._active_threads:
|
|
214
|
+
self._active_threads[path].stop()
|
|
215
|
+
|
|
216
|
+
def stop_all(self) -> None:
|
|
217
|
+
"""Stop all active scans."""
|
|
218
|
+
with self._lock:
|
|
219
|
+
for thread in self._active_threads.values():
|
|
220
|
+
thread.stop()
|
|
221
|
+
|
|
222
|
+
def cleanup_finished(self) -> None:
|
|
223
|
+
"""Remove finished threads from tracking."""
|
|
224
|
+
finished = [
|
|
225
|
+
path
|
|
226
|
+
for path, thread in self._active_threads.items()
|
|
227
|
+
if not thread.is_alive()
|
|
228
|
+
]
|
|
229
|
+
for path in finished:
|
|
230
|
+
del self._active_threads[path]
|
|
231
|
+
|
|
232
|
+
def has_active_scans(self) -> bool:
|
|
233
|
+
"""Check if any scans are currently running.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
True if active scans exist
|
|
237
|
+
"""
|
|
238
|
+
with self._lock:
|
|
239
|
+
self.cleanup_finished()
|
|
240
|
+
return len(self._active_threads) > 0
|
|
241
|
+
|
|
242
|
+
def get_active_paths(self) -> list[Path]:
|
|
243
|
+
"""Get list of paths currently being scanned.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
List of paths
|
|
247
|
+
"""
|
|
248
|
+
with self._lock:
|
|
249
|
+
self.cleanup_finished()
|
|
250
|
+
return list(self._active_threads.keys())
|
ptdu/treeview.py
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"""Treeview widget for displaying directory structure."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tkinter as tk
|
|
6
|
+
from tkinter import ttk
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from ptdu.fonts import FontManager
|
|
10
|
+
from ptdu.models import DirNode
|
|
11
|
+
from ptdu.utils import SizeCalculator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DirectoryTreeview(ttk.Treeview):
|
|
15
|
+
"""Custom Treeview for displaying directory contents with size information."""
|
|
16
|
+
|
|
17
|
+
# Column identifiers
|
|
18
|
+
COL_NAME = "name"
|
|
19
|
+
COL_SIZE = "size"
|
|
20
|
+
COL_PERCENT = "percent"
|
|
21
|
+
COL_SIZE_BAR = "size_bar"
|
|
22
|
+
COL_ITEMS = "items"
|
|
23
|
+
|
|
24
|
+
# Tag names for styling
|
|
25
|
+
TAG_DIRECTORY = "directory"
|
|
26
|
+
TAG_FILE = "file"
|
|
27
|
+
TAG_HIDDEN = "hidden"
|
|
28
|
+
TAG_LARGE_FILE = "large_file"
|
|
29
|
+
TAG_MEDIUM_FILE = "medium_file"
|
|
30
|
+
TAG_SELECTED = "selected"
|
|
31
|
+
|
|
32
|
+
# Size bar characters
|
|
33
|
+
SIZE_BAR_FULL = "█"
|
|
34
|
+
SIZE_BAR_EMPTY = "░"
|
|
35
|
+
SIZE_BAR_WIDTH = 10
|
|
36
|
+
|
|
37
|
+
# Size thresholds
|
|
38
|
+
LARGE_FILE_THRESHOLD = 100 * 1024 * 1024 # 100MB
|
|
39
|
+
MEDIUM_FILE_THRESHOLD = 10 * 1024 * 1024 # 10MB
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self, parent: tk.Widget, font_manager: Optional[FontManager] = None
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Initialize the directory treeview.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
parent: Parent widget
|
|
48
|
+
font_manager: Optional font manager for styling
|
|
49
|
+
"""
|
|
50
|
+
# Define columns
|
|
51
|
+
columns = (self.COL_SIZE, self.COL_SIZE_BAR, self.COL_PERCENT, self.COL_ITEMS)
|
|
52
|
+
|
|
53
|
+
super().__init__(
|
|
54
|
+
parent,
|
|
55
|
+
columns=columns,
|
|
56
|
+
show="tree headings",
|
|
57
|
+
selectmode="browse",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Font manager
|
|
61
|
+
self._font_manager: FontManager = (
|
|
62
|
+
font_manager if font_manager is not None else FontManager()
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Node reference storage: iid -> DirNode
|
|
66
|
+
self._node_map: dict[str, DirNode] = {}
|
|
67
|
+
|
|
68
|
+
# Track current max size for percentage bars
|
|
69
|
+
self._current_max_size: int = 0
|
|
70
|
+
|
|
71
|
+
self._setup_style()
|
|
72
|
+
self._setup_columns()
|
|
73
|
+
self._setup_tags()
|
|
74
|
+
|
|
75
|
+
def _setup_columns(self) -> None:
|
|
76
|
+
"""Configure column headings and widths."""
|
|
77
|
+
# Tree column (Name) - displays the file/directory name with icon
|
|
78
|
+
self.heading("#0", text="Name", anchor="w")
|
|
79
|
+
self.column("#0", width=400, minwidth=200, stretch=True)
|
|
80
|
+
|
|
81
|
+
# Size column
|
|
82
|
+
self.heading(self.COL_SIZE, text="Size", anchor="e")
|
|
83
|
+
self.column(self.COL_SIZE, width=90, minwidth=70, stretch=False, anchor="e")
|
|
84
|
+
|
|
85
|
+
# Size bar column (visual representation)
|
|
86
|
+
self.heading(self.COL_SIZE_BAR, text="", anchor="w")
|
|
87
|
+
self.column(
|
|
88
|
+
self.COL_SIZE_BAR, width=100, minwidth=80, stretch=False, anchor="w"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Percent column
|
|
92
|
+
self.heading(self.COL_PERCENT, text="Percent", anchor="e")
|
|
93
|
+
self.column(self.COL_PERCENT, width=70, minwidth=60, stretch=False, anchor="e")
|
|
94
|
+
|
|
95
|
+
# Items column
|
|
96
|
+
self.heading(self.COL_ITEMS, text="Items", anchor="e")
|
|
97
|
+
self.column(self.COL_ITEMS, width=60, minwidth=50, stretch=False, anchor="e")
|
|
98
|
+
|
|
99
|
+
def _setup_tags(self) -> None:
|
|
100
|
+
"""Configure tag styles for different item types."""
|
|
101
|
+
dir_font = self._font_manager.get_directory_font()
|
|
102
|
+
file_font = self._font_manager.get_file_font()
|
|
103
|
+
|
|
104
|
+
# Directory styling - blue text, bold
|
|
105
|
+
self.tag_configure(
|
|
106
|
+
self.TAG_DIRECTORY,
|
|
107
|
+
foreground="#0066cc",
|
|
108
|
+
font=dir_font,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# File styling - default text
|
|
112
|
+
self.tag_configure(
|
|
113
|
+
self.TAG_FILE,
|
|
114
|
+
foreground="#000000",
|
|
115
|
+
font=file_font,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Hidden file/directory styling - gray text
|
|
119
|
+
self.tag_configure(
|
|
120
|
+
self.TAG_HIDDEN,
|
|
121
|
+
foreground="#888888",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Large files (>100MB) - red text
|
|
125
|
+
self.tag_configure(
|
|
126
|
+
self.TAG_LARGE_FILE,
|
|
127
|
+
foreground="#cc0000",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Medium files (10-100MB) - yellow/orange text
|
|
131
|
+
self.tag_configure(
|
|
132
|
+
self.TAG_MEDIUM_FILE,
|
|
133
|
+
foreground="#cc6600",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Selected item styling
|
|
137
|
+
self.tag_configure(
|
|
138
|
+
self.TAG_SELECTED,
|
|
139
|
+
background="#d0e8ff",
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def _setup_style(self) -> None:
|
|
143
|
+
"""Set up ttk style for treeview with proper row height."""
|
|
144
|
+
style = ttk.Style()
|
|
145
|
+
treeview_font = self._font_manager.get_treeview_font()
|
|
146
|
+
|
|
147
|
+
# Calculate row height based on font metrics
|
|
148
|
+
metrics = treeview_font.metrics()
|
|
149
|
+
row_height = metrics["ascent"] + metrics["descent"] + 8
|
|
150
|
+
|
|
151
|
+
# Configure treeview style with row height
|
|
152
|
+
style.configure(
|
|
153
|
+
"DirectoryTreeview.Treeview",
|
|
154
|
+
rowheight=row_height,
|
|
155
|
+
font=treeview_font,
|
|
156
|
+
)
|
|
157
|
+
style.configure(
|
|
158
|
+
"DirectoryTreeview.Treeview.Heading",
|
|
159
|
+
font=self._font_manager.get_heading_font(),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Apply the custom style
|
|
163
|
+
self.configure(style="DirectoryTreeview.Treeview")
|
|
164
|
+
|
|
165
|
+
def _update_style(self) -> None:
|
|
166
|
+
"""Update ttk style after font size change."""
|
|
167
|
+
self._setup_style()
|
|
168
|
+
|
|
169
|
+
def _format_size_bar(self, size: int) -> str:
|
|
170
|
+
"""Format a visual size bar using Unicode blocks.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
size: Size in bytes
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Visual bar string (e.g., "████░░░░░")
|
|
177
|
+
"""
|
|
178
|
+
if self._current_max_size <= 0 or size <= 0:
|
|
179
|
+
return self.SIZE_BAR_EMPTY * self.SIZE_BAR_WIDTH
|
|
180
|
+
|
|
181
|
+
# Calculate filled portion
|
|
182
|
+
ratio = size / self._current_max_size
|
|
183
|
+
filled = int(ratio * self.SIZE_BAR_WIDTH)
|
|
184
|
+
filled = max(0, min(filled, self.SIZE_BAR_WIDTH)) # Clamp to valid range
|
|
185
|
+
|
|
186
|
+
empty = self.SIZE_BAR_WIDTH - filled
|
|
187
|
+
return self.SIZE_BAR_FULL * filled + self.SIZE_BAR_EMPTY * empty
|
|
188
|
+
|
|
189
|
+
def insert_node(
|
|
190
|
+
self,
|
|
191
|
+
parent_iid: str,
|
|
192
|
+
node: DirNode,
|
|
193
|
+
) -> str:
|
|
194
|
+
"""Insert a DirNode into the treeview.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
parent_iid: Parent item ID (empty string for root)
|
|
198
|
+
node: The DirNode to insert
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
The item ID (iid) of the inserted node
|
|
202
|
+
"""
|
|
203
|
+
# Generate unique iid based on path
|
|
204
|
+
iid = str(node.path)
|
|
205
|
+
|
|
206
|
+
# Determine display name and icon
|
|
207
|
+
display_name = node.name
|
|
208
|
+
if node.is_dir:
|
|
209
|
+
# Add folder indicator for collapsed directories
|
|
210
|
+
if not node.is_expanded:
|
|
211
|
+
display_name = f"📁 {node.name}"
|
|
212
|
+
else:
|
|
213
|
+
display_name = f"📂 {node.name}"
|
|
214
|
+
else:
|
|
215
|
+
display_name = f"📄 {node.name}"
|
|
216
|
+
|
|
217
|
+
# Calculate percentage if we have a max size
|
|
218
|
+
percent_str = ""
|
|
219
|
+
size_bar = ""
|
|
220
|
+
if self._current_max_size > 0 and node.size > 0:
|
|
221
|
+
percent = SizeCalculator.calculate_percentage(
|
|
222
|
+
node.size, self._current_max_size
|
|
223
|
+
)
|
|
224
|
+
percent_str = f"{percent:.0f}%"
|
|
225
|
+
size_bar = self._format_size_bar(node.size)
|
|
226
|
+
|
|
227
|
+
# Format values
|
|
228
|
+
values = (
|
|
229
|
+
SizeCalculator.format_size(node.size),
|
|
230
|
+
size_bar,
|
|
231
|
+
percent_str,
|
|
232
|
+
str(node.get_item_count()) if node.is_dir else "",
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# Determine tags
|
|
236
|
+
tags = self._get_tags_for_node(node)
|
|
237
|
+
|
|
238
|
+
# Insert the item
|
|
239
|
+
try:
|
|
240
|
+
self.insert(
|
|
241
|
+
parent=parent_iid,
|
|
242
|
+
index="end",
|
|
243
|
+
iid=iid,
|
|
244
|
+
text=display_name,
|
|
245
|
+
values=values,
|
|
246
|
+
tags=tags,
|
|
247
|
+
open=node.is_expanded,
|
|
248
|
+
)
|
|
249
|
+
except tk.TclError:
|
|
250
|
+
# Item already exists, update it
|
|
251
|
+
self.item(
|
|
252
|
+
iid, text=display_name, values=values, tags=tags, open=node.is_expanded
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
# Store node reference
|
|
256
|
+
self._node_map[iid] = node
|
|
257
|
+
|
|
258
|
+
return iid
|
|
259
|
+
|
|
260
|
+
def _get_tags_for_node(self, node: DirNode) -> tuple[str, ...]:
|
|
261
|
+
"""Determine appropriate tags for a node based on its properties.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
node: The DirNode to get tags for
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
Tuple of tag names
|
|
268
|
+
"""
|
|
269
|
+
tags: list[str] = []
|
|
270
|
+
|
|
271
|
+
if node.is_dir:
|
|
272
|
+
tags.append(self.TAG_DIRECTORY)
|
|
273
|
+
else:
|
|
274
|
+
tags.append(self.TAG_FILE)
|
|
275
|
+
|
|
276
|
+
# Size-based coloring
|
|
277
|
+
if node.size > self.LARGE_FILE_THRESHOLD: # > 100MB
|
|
278
|
+
tags.append(self.TAG_LARGE_FILE)
|
|
279
|
+
elif node.size > self.MEDIUM_FILE_THRESHOLD: # > 10MB
|
|
280
|
+
tags.append(self.TAG_MEDIUM_FILE)
|
|
281
|
+
|
|
282
|
+
# Hidden files/directories (start with .)
|
|
283
|
+
if node.name.startswith("."):
|
|
284
|
+
tags.append(self.TAG_HIDDEN)
|
|
285
|
+
|
|
286
|
+
return tuple(tags)
|
|
287
|
+
|
|
288
|
+
def get_node(self, iid: str) -> Optional[DirNode]:
|
|
289
|
+
"""Get the DirNode associated with an item ID.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
iid: The item ID
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
The DirNode or None if not found
|
|
296
|
+
"""
|
|
297
|
+
return self._node_map.get(iid)
|
|
298
|
+
|
|
299
|
+
def delete_node(self, iid: str) -> None:
|
|
300
|
+
"""Delete a node and remove from internal map.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
iid: The item ID to delete
|
|
304
|
+
"""
|
|
305
|
+
self.delete(iid)
|
|
306
|
+
self._node_map.pop(iid, None)
|
|
307
|
+
|
|
308
|
+
def clear(self) -> None:
|
|
309
|
+
"""Clear all items from the treeview."""
|
|
310
|
+
for item in self.get_children():
|
|
311
|
+
self.delete(item)
|
|
312
|
+
self._node_map.clear()
|
|
313
|
+
self._current_max_size = 0
|
|
314
|
+
|
|
315
|
+
def update_node(self, iid: str) -> None:
|
|
316
|
+
"""Update the display values for a node.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
iid: The item ID to update
|
|
320
|
+
"""
|
|
321
|
+
node = self._node_map.get(iid)
|
|
322
|
+
if node is None:
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
# Recalculate display values
|
|
326
|
+
display_name = node.name
|
|
327
|
+
if node.is_dir:
|
|
328
|
+
if not node.is_expanded:
|
|
329
|
+
display_name = f"📁 {node.name}"
|
|
330
|
+
else:
|
|
331
|
+
display_name = f"📂 {node.name}"
|
|
332
|
+
else:
|
|
333
|
+
display_name = f"📄 {node.name}"
|
|
334
|
+
|
|
335
|
+
# Calculate percentage and size bar
|
|
336
|
+
percent_str = ""
|
|
337
|
+
size_bar = ""
|
|
338
|
+
if self._current_max_size > 0 and node.size > 0:
|
|
339
|
+
percent = SizeCalculator.calculate_percentage(
|
|
340
|
+
node.size, self._current_max_size
|
|
341
|
+
)
|
|
342
|
+
percent_str = f"{percent:.0f}%"
|
|
343
|
+
size_bar = self._format_size_bar(node.size)
|
|
344
|
+
|
|
345
|
+
values = (
|
|
346
|
+
SizeCalculator.format_size(node.size),
|
|
347
|
+
size_bar,
|
|
348
|
+
percent_str,
|
|
349
|
+
str(node.get_item_count()) if node.is_dir else "",
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
tags = self._get_tags_for_node(node)
|
|
353
|
+
|
|
354
|
+
self.item(
|
|
355
|
+
iid, text=display_name, values=values, tags=tags, open=node.is_expanded
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
def set_max_size(self, max_size: int) -> None:
|
|
359
|
+
"""Set the maximum size for percentage calculations.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
max_size: Maximum size in bytes
|
|
363
|
+
"""
|
|
364
|
+
self._current_max_size = max_size
|
|
365
|
+
|
|
366
|
+
def get_selected_node(self) -> Optional[DirNode]:
|
|
367
|
+
"""Get the currently selected DirNode.
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
The selected DirNode or None
|
|
371
|
+
"""
|
|
372
|
+
selection = self.selection()
|
|
373
|
+
if not selection:
|
|
374
|
+
return None
|
|
375
|
+
return self._node_map.get(selection[0])
|
|
376
|
+
|
|
377
|
+
def select_node(self, iid: str) -> None:
|
|
378
|
+
"""Select a node by its ID.
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
iid: The item ID to select
|
|
382
|
+
"""
|
|
383
|
+
self.selection_set(iid)
|
|
384
|
+
self.focus(iid)
|
|
385
|
+
self.see(iid)
|
|
386
|
+
|
|
387
|
+
def expand_node(self, iid: str) -> None:
|
|
388
|
+
"""Expand a node and update its display.
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
iid: The item ID to expand
|
|
392
|
+
"""
|
|
393
|
+
node = self._node_map.get(iid)
|
|
394
|
+
if node and node.is_dir:
|
|
395
|
+
node.is_expanded = True
|
|
396
|
+
self.item(iid, open=True)
|
|
397
|
+
self.update_node(iid)
|
|
398
|
+
|
|
399
|
+
def collapse_node(self, iid: str) -> None:
|
|
400
|
+
"""Collapse a node and update its display.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
iid: The item ID to collapse
|
|
404
|
+
"""
|
|
405
|
+
node = self._node_map.get(iid)
|
|
406
|
+
if node and node.is_dir:
|
|
407
|
+
node.is_expanded = False
|
|
408
|
+
self.item(iid, open=False)
|
|
409
|
+
self.update_node(iid)
|
|
410
|
+
|
|
411
|
+
def set_font_manager(self, font_manager: FontManager) -> None:
|
|
412
|
+
"""Set a new font manager and update styling.
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
font_manager: New font manager to use
|
|
416
|
+
"""
|
|
417
|
+
self._font_manager = font_manager
|
|
418
|
+
self._setup_tags()
|
|
419
|
+
self._update_style()
|
|
420
|
+
|
|
421
|
+
def refresh_fonts(self) -> None:
|
|
422
|
+
"""Refresh all fonts after font size change."""
|
|
423
|
+
self._setup_tags()
|
|
424
|
+
self._update_style()
|
|
425
|
+
# Force redraw to apply new fonts
|
|
426
|
+
self.update()
|