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/fonts.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Font configuration utility for PTDU.
|
|
2
|
+
|
|
3
|
+
Provides OS-aware font detection and configuration.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
import tkinter as tk
|
|
10
|
+
from tkinter import font as tkfont
|
|
11
|
+
from typing import Literal, Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FontManager:
|
|
15
|
+
"""Manages font detection and configuration for different operating systems."""
|
|
16
|
+
|
|
17
|
+
# Font size recommendations (increased for better readability)
|
|
18
|
+
BASE_SIZE: int = 13
|
|
19
|
+
HEADING_SIZE: int = 12
|
|
20
|
+
STATUS_SIZE: int = 12
|
|
21
|
+
|
|
22
|
+
# Font size adjustment
|
|
23
|
+
SIZE_STEP: int = 1
|
|
24
|
+
MIN_SIZE: int = 8
|
|
25
|
+
MAX_SIZE: int = 20
|
|
26
|
+
|
|
27
|
+
# OS-specific font preferences (prioritize cleaner, more modern fonts)
|
|
28
|
+
LINUX_FONTS: tuple[str, ...] = (
|
|
29
|
+
"Ubuntu", # Clean, modern, highly readable
|
|
30
|
+
"Noto Sans", # Google's unified font, very readable
|
|
31
|
+
"DejaVu Sans", # Widely available, clean
|
|
32
|
+
"Cantarell", # GNOME's default, modern
|
|
33
|
+
"Liberation Sans", # Metric-compatible with Arial
|
|
34
|
+
)
|
|
35
|
+
MACOS_FONTS: tuple[str, ...] = (".SF NS", "Helvetica Neue", "Helvetica", "Arial")
|
|
36
|
+
WINDOWS_FONTS: tuple[str, ...] = ("Segoe UI", "Tahoma", "Arial")
|
|
37
|
+
|
|
38
|
+
def __init__(self, root: Optional[tk.Tk] = None) -> None:
|
|
39
|
+
"""Initialize the font manager.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
root: Tk root window for font querying
|
|
43
|
+
"""
|
|
44
|
+
self._root: Optional[tk.Tk] = root
|
|
45
|
+
self._family: Optional[str] = None
|
|
46
|
+
self._fonts: dict[str, tkfont.Font] = {}
|
|
47
|
+
|
|
48
|
+
def _get_available_fonts(self) -> set[str]:
|
|
49
|
+
"""Get set of available font families on the system.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Set of available font family names
|
|
53
|
+
"""
|
|
54
|
+
if self._root is None:
|
|
55
|
+
# Create temporary root for font querying if needed
|
|
56
|
+
temp_root = tk.Tk()
|
|
57
|
+
temp_root.withdraw()
|
|
58
|
+
families = set(tkfont.families(temp_root))
|
|
59
|
+
temp_root.destroy()
|
|
60
|
+
else:
|
|
61
|
+
families = set(tkfont.families(self._root))
|
|
62
|
+
|
|
63
|
+
return families
|
|
64
|
+
|
|
65
|
+
def detect_font_family(self) -> str:
|
|
66
|
+
"""Detect the best available font family for the current OS.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Font family name
|
|
70
|
+
"""
|
|
71
|
+
available = self._get_available_fonts()
|
|
72
|
+
system = sys.platform
|
|
73
|
+
|
|
74
|
+
if system == "darwin": # macOS
|
|
75
|
+
preferences = self.MACOS_FONTS
|
|
76
|
+
elif system == "win32": # Windows
|
|
77
|
+
preferences = self.WINDOWS_FONTS
|
|
78
|
+
else: # Linux and others
|
|
79
|
+
preferences = self.LINUX_FONTS
|
|
80
|
+
|
|
81
|
+
# Find first available preferred font
|
|
82
|
+
for family in preferences:
|
|
83
|
+
if family in available:
|
|
84
|
+
self._family = family
|
|
85
|
+
return family
|
|
86
|
+
|
|
87
|
+
# Fallback to TkDefaultFont
|
|
88
|
+
self._family = "TkDefaultFont"
|
|
89
|
+
return self._family
|
|
90
|
+
|
|
91
|
+
def get_family(self) -> str:
|
|
92
|
+
"""Get the detected or default font family.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Font family name
|
|
96
|
+
"""
|
|
97
|
+
if self._family is None:
|
|
98
|
+
return self.detect_font_family()
|
|
99
|
+
return self._family
|
|
100
|
+
|
|
101
|
+
def get_font(
|
|
102
|
+
self,
|
|
103
|
+
name: str,
|
|
104
|
+
size: Optional[int] = None,
|
|
105
|
+
weight: Literal["normal", "bold"] = "normal",
|
|
106
|
+
slant: Literal["roman", "italic"] = "roman",
|
|
107
|
+
) -> tkfont.Font:
|
|
108
|
+
"""Get or create a named font configuration.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
name: Font configuration name (e.g., 'treeview', 'heading', 'status')
|
|
112
|
+
size: Font size in points (uses default if None)
|
|
113
|
+
weight: Font weight ('normal', 'bold')
|
|
114
|
+
slant: Font slant ('roman', 'italic')
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Configured tkinter Font object
|
|
118
|
+
"""
|
|
119
|
+
if name in self._fonts:
|
|
120
|
+
return self._fonts[name]
|
|
121
|
+
|
|
122
|
+
family = self.get_family()
|
|
123
|
+
actual_size = size if size is not None else self.BASE_SIZE
|
|
124
|
+
|
|
125
|
+
font = tkfont.Font(family=family, size=actual_size, weight=weight, slant=slant)
|
|
126
|
+
self._fonts[name] = font
|
|
127
|
+
|
|
128
|
+
return font
|
|
129
|
+
|
|
130
|
+
def get_treeview_font(self) -> tkfont.Font:
|
|
131
|
+
"""Get font for treeview items.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Font for treeview content
|
|
135
|
+
"""
|
|
136
|
+
return self.get_font("treeview", size=self.BASE_SIZE)
|
|
137
|
+
|
|
138
|
+
def get_heading_font(self) -> tkfont.Font:
|
|
139
|
+
"""Get font for treeview headings.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Font for column headings
|
|
143
|
+
"""
|
|
144
|
+
return self.get_font("heading", size=self.HEADING_SIZE, weight="bold")
|
|
145
|
+
|
|
146
|
+
def get_status_font(self) -> tkfont.Font:
|
|
147
|
+
"""Get font for status bar.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Font for status bar text
|
|
151
|
+
"""
|
|
152
|
+
return self.get_font("status", size=self.STATUS_SIZE)
|
|
153
|
+
|
|
154
|
+
def get_directory_font(self) -> tkfont.Font:
|
|
155
|
+
"""Get font for directory entries (bold).
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
Font for directory names
|
|
159
|
+
"""
|
|
160
|
+
return self.get_font("directory", size=self.BASE_SIZE, weight="bold")
|
|
161
|
+
|
|
162
|
+
def get_file_font(self) -> tkfont.Font:
|
|
163
|
+
"""Get font for file entries.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
Font for file names
|
|
167
|
+
"""
|
|
168
|
+
return self.get_font("file", size=self.BASE_SIZE)
|
|
169
|
+
|
|
170
|
+
def update_font_sizes(self, base_size: int) -> None:
|
|
171
|
+
"""Update all font sizes relative to new base size.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
base_size: New base font size
|
|
175
|
+
"""
|
|
176
|
+
self.BASE_SIZE = base_size
|
|
177
|
+
self.HEADING_SIZE = base_size - 1
|
|
178
|
+
self.STATUS_SIZE = base_size - 1
|
|
179
|
+
|
|
180
|
+
# Recreate fonts with new sizes
|
|
181
|
+
for name, font in self._fonts.items():
|
|
182
|
+
if name == "heading":
|
|
183
|
+
font.configure(size=self.HEADING_SIZE)
|
|
184
|
+
elif name == "status":
|
|
185
|
+
font.configure(size=self.STATUS_SIZE)
|
|
186
|
+
else:
|
|
187
|
+
font.configure(size=self.BASE_SIZE)
|
|
188
|
+
|
|
189
|
+
def measure_text_width(self, text: str, font_name: str = "treeview") -> int:
|
|
190
|
+
"""Measure the pixel width of text.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
text: Text to measure
|
|
194
|
+
font_name: Name of font to use
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
Width in pixels
|
|
198
|
+
"""
|
|
199
|
+
font = self.get_font(font_name)
|
|
200
|
+
return font.measure(text)
|
|
201
|
+
|
|
202
|
+
def increase_font_size(self) -> int:
|
|
203
|
+
"""Increase all font sizes by SIZE_STEP.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
New base font size
|
|
207
|
+
"""
|
|
208
|
+
new_size = min(self.BASE_SIZE + self.SIZE_STEP, self.MAX_SIZE)
|
|
209
|
+
self.update_font_sizes(new_size)
|
|
210
|
+
return new_size
|
|
211
|
+
|
|
212
|
+
def decrease_font_size(self) -> int:
|
|
213
|
+
"""Decrease all font sizes by SIZE_STEP.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
New base font size
|
|
217
|
+
"""
|
|
218
|
+
new_size = max(self.BASE_SIZE - self.SIZE_STEP, self.MIN_SIZE)
|
|
219
|
+
self.update_font_sizes(new_size)
|
|
220
|
+
return new_size
|
|
221
|
+
|
|
222
|
+
def reset_font_size(self) -> int:
|
|
223
|
+
"""Reset font sizes to default.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
New base font size
|
|
227
|
+
"""
|
|
228
|
+
self.update_font_sizes(13)
|
|
229
|
+
return 13
|
|
230
|
+
|
|
231
|
+
def get_font_size(self) -> int:
|
|
232
|
+
"""Get current base font size.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
Current base font size
|
|
236
|
+
"""
|
|
237
|
+
return self.BASE_SIZE
|
ptdu/main.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Entry point for PTDU application."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ptdu.cache import ScanCache
|
|
8
|
+
from ptdu.scanner import Scanner
|
|
9
|
+
from ptdu.ui import MainWindow
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def create_argument_parser() -> argparse.ArgumentParser:
|
|
13
|
+
"""Create and configure the argument parser."""
|
|
14
|
+
parser = argparse.ArgumentParser(
|
|
15
|
+
prog="ptdu",
|
|
16
|
+
description="Python Tkinter Disk Usage analyzer - A graphical disk usage tool",
|
|
17
|
+
epilog="Keyboard shortcuts: j/k=navigate, h/l=expand/collapse, q=quit, r=rescan, d=delete",
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"path",
|
|
21
|
+
nargs="?",
|
|
22
|
+
default=".",
|
|
23
|
+
type=Path,
|
|
24
|
+
help="Directory to analyze (default: current directory)",
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"--follow-symlinks",
|
|
28
|
+
action="store_true",
|
|
29
|
+
help="Follow symbolic links when scanning",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--exclude",
|
|
33
|
+
"-e",
|
|
34
|
+
action="append",
|
|
35
|
+
default=[],
|
|
36
|
+
help="Additional patterns to exclude (can be used multiple times)",
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"--max-depth",
|
|
40
|
+
"-d",
|
|
41
|
+
type=int,
|
|
42
|
+
default=None,
|
|
43
|
+
help="Maximum scan depth (default: unlimited)",
|
|
44
|
+
)
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"--no-cache",
|
|
47
|
+
action="store_true",
|
|
48
|
+
help="Disable SQLite caching",
|
|
49
|
+
)
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"--clear-cache",
|
|
52
|
+
action="store_true",
|
|
53
|
+
help="Clear cache before starting",
|
|
54
|
+
)
|
|
55
|
+
parser.add_argument(
|
|
56
|
+
"--version",
|
|
57
|
+
action="version",
|
|
58
|
+
version="%(prog)s 0.1.0",
|
|
59
|
+
)
|
|
60
|
+
return parser
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def validate_path(path: Path) -> Path:
|
|
64
|
+
"""Validate that the provided path exists and is a directory."""
|
|
65
|
+
if not path.exists():
|
|
66
|
+
raise SystemExit(f"Error: Path does not exist: {path}")
|
|
67
|
+
if not path.is_dir():
|
|
68
|
+
raise SystemExit(f"Error: Path is not a directory: {path}")
|
|
69
|
+
return path.resolve()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def main() -> int:
|
|
73
|
+
"""Main entry point for the PTDU application."""
|
|
74
|
+
parser = create_argument_parser()
|
|
75
|
+
args = parser.parse_args()
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
target_path = validate_path(args.path)
|
|
79
|
+
except SystemExit as e:
|
|
80
|
+
print(str(e), file=sys.stderr)
|
|
81
|
+
return 1
|
|
82
|
+
|
|
83
|
+
# Handle cache
|
|
84
|
+
cache: ScanCache | None = None
|
|
85
|
+
if not args.no_cache:
|
|
86
|
+
cache = ScanCache()
|
|
87
|
+
if args.clear_cache:
|
|
88
|
+
cache.clear()
|
|
89
|
+
print("Cache cleared.")
|
|
90
|
+
|
|
91
|
+
# Convert exclude list to tuple
|
|
92
|
+
exclude_patterns = tuple(args.exclude) if args.exclude else None
|
|
93
|
+
|
|
94
|
+
# Create scanner with all options
|
|
95
|
+
scanner = Scanner(
|
|
96
|
+
follow_symlinks=args.follow_symlinks,
|
|
97
|
+
exclude_patterns=exclude_patterns,
|
|
98
|
+
cache=cache,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Create main window and set scanner
|
|
102
|
+
window = MainWindow()
|
|
103
|
+
window.set_scanner(scanner)
|
|
104
|
+
|
|
105
|
+
# Set max depth if specified
|
|
106
|
+
if args.max_depth is not None:
|
|
107
|
+
window.set_max_depth(args.max_depth)
|
|
108
|
+
|
|
109
|
+
# Start background scan of initial directory
|
|
110
|
+
window.start_background_scan(target_path)
|
|
111
|
+
|
|
112
|
+
# Run the application
|
|
113
|
+
window.run()
|
|
114
|
+
return 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
if __name__ == "__main__":
|
|
118
|
+
sys.exit(main())
|
ptdu/models.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Data models for PTDU directory structure."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DirNode:
|
|
10
|
+
"""Represents a file or directory node in the tree structure."""
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
path: Path,
|
|
15
|
+
name: str,
|
|
16
|
+
is_dir: bool,
|
|
17
|
+
parent: Optional[DirNode] = None,
|
|
18
|
+
) -> None:
|
|
19
|
+
"""Initialize a directory node.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
path: Full filesystem path
|
|
23
|
+
name: Display name
|
|
24
|
+
is_dir: Whether this is a directory
|
|
25
|
+
parent: Reference to parent node (None for root)
|
|
26
|
+
"""
|
|
27
|
+
self.path: Path = path
|
|
28
|
+
self.name: str = name
|
|
29
|
+
self.size: int = 0
|
|
30
|
+
self.children: dict[str, DirNode] = {}
|
|
31
|
+
self.parent: Optional[DirNode] = parent
|
|
32
|
+
self.is_dir: bool = is_dir
|
|
33
|
+
self.is_expanded: bool = False
|
|
34
|
+
self._is_loaded: bool = False
|
|
35
|
+
|
|
36
|
+
def get_size(self) -> int:
|
|
37
|
+
"""Get the total size of this node.
|
|
38
|
+
|
|
39
|
+
For directories, returns the cached size.
|
|
40
|
+
For files, returns the file size.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Size in bytes
|
|
44
|
+
"""
|
|
45
|
+
return self.size
|
|
46
|
+
|
|
47
|
+
def add_child(self, node: DirNode) -> None:
|
|
48
|
+
"""Add a child node to this directory.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
node: The child node to add
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
ValueError: If this node is not a directory
|
|
55
|
+
"""
|
|
56
|
+
if not self.is_dir:
|
|
57
|
+
raise ValueError(f"Cannot add child to file: {self.name}")
|
|
58
|
+
|
|
59
|
+
self.children[node.name] = node
|
|
60
|
+
node.parent = self
|
|
61
|
+
|
|
62
|
+
def remove_child(self, name: str) -> Optional[DirNode]:
|
|
63
|
+
"""Remove a child node by name.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
name: Name of the child to remove
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
The removed node or None if not found
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
ValueError: If this node is not a directory
|
|
73
|
+
"""
|
|
74
|
+
if not self.is_dir:
|
|
75
|
+
raise ValueError(f"Cannot remove child from file: {self.name}")
|
|
76
|
+
|
|
77
|
+
node = self.children.pop(name, None)
|
|
78
|
+
if node is not None:
|
|
79
|
+
node.parent = None
|
|
80
|
+
return node
|
|
81
|
+
|
|
82
|
+
def get_child(self, name: str) -> Optional[DirNode]:
|
|
83
|
+
"""Get a child node by name.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
name: Name of the child to get
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
The child node or None if not found
|
|
90
|
+
"""
|
|
91
|
+
return self.children.get(name)
|
|
92
|
+
|
|
93
|
+
def set_size(self, size: int) -> None:
|
|
94
|
+
"""Set the size of this node and propagate to parents.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
size: New size in bytes
|
|
98
|
+
"""
|
|
99
|
+
old_size = self.size
|
|
100
|
+
self.size = size
|
|
101
|
+
|
|
102
|
+
# Propagate size change to parent
|
|
103
|
+
if self.parent is not None:
|
|
104
|
+
size_diff = size - old_size
|
|
105
|
+
self.parent.size += size_diff
|
|
106
|
+
|
|
107
|
+
def mark_loaded(self) -> None:
|
|
108
|
+
"""Mark this directory as having been scanned."""
|
|
109
|
+
self._is_loaded = True
|
|
110
|
+
|
|
111
|
+
def is_loaded(self) -> bool:
|
|
112
|
+
"""Check if this directory has been scanned.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
True if the directory has been loaded
|
|
116
|
+
"""
|
|
117
|
+
return self._is_loaded
|
|
118
|
+
|
|
119
|
+
def get_item_count(self) -> int:
|
|
120
|
+
"""Get the number of children.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
Number of children (0 for files)
|
|
124
|
+
"""
|
|
125
|
+
return len(self.children)
|
|
126
|
+
|
|
127
|
+
def __repr__(self) -> str:
|
|
128
|
+
"""Return string representation."""
|
|
129
|
+
node_type = "dir" if self.is_dir else "file"
|
|
130
|
+
return f"DirNode({node_type}: {self.name}, size={self.size})"
|