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/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()