redwood-view 0.1.0__tar.gz

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.
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
@@ -0,0 +1,124 @@
1
+ Metadata-Version: 2.4
2
+ Name: redwood-view
3
+ Version: 0.1.0
4
+ Summary: Console disk usage viewer: a treemap with an ncdu-style folder list
5
+ Keywords: disk usage,treemap,ncdu,diskonaut,sequoiaview,curses,tui
6
+ Author: Mykhailo Odintsov
7
+ License-Expression: Apache-2.0
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Console :: Curses
11
+ Classifier: Intended Audience :: System Administrators
12
+ Classifier: Intended Audience :: End Users/Desktop
13
+ Classifier: Operating System :: MacOS
14
+ Classifier: Operating System :: POSIX :: Linux
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: System :: Filesystems
23
+ Classifier: Topic :: Utilities
24
+ Requires-Python: >=3.10
25
+ Project-URL: Homepage, https://github.com/Templarrr/redwood_view
26
+ Project-URL: Source, https://github.com/Templarrr/redwood_view
27
+ Project-URL: Issues, https://github.com/Templarrr/redwood_view/issues
28
+ Description-Content-Type: text/markdown
29
+
30
+ # Redwood View
31
+
32
+ A console disk usage viewer in the spirit of SequoiaView: a treemap fills most of the
33
+ window, and an ncdu-style folder list on the right is used for navigation. The selected
34
+ entry is highlighted on the treemap, and blocks are coloured by file type so you can see
35
+ not only which folder is big but what is filling it.
36
+
37
+ Pure Python standard library (`curses`), so it works on any server you can `ssh` into.
38
+
39
+ ![Home folder overview with the file type legend open](https://raw.githubusercontent.com/Templarrr/redwood_view/main/docs/screenshots/overview.png)
40
+
41
+ Inside a folder: nested blocks of the same type are told apart by a dotted texture. Files too
42
+ small to get a cell of their own are merged into one block labelled "+N small items",
43
+ which keeps folders with tens of thousands of files quick to draw.
44
+
45
+ ![Videos folder](https://raw.githubusercontent.com/Templarrr/redwood_view/main/docs/screenshots/videos.png)
46
+
47
+ Without colour (`--no-color`, or a terminal that has none) each file type gets its own
48
+ character instead.
49
+
50
+ ![Monochrome mode](https://raw.githubusercontent.com/Templarrr/redwood_view/main/docs/screenshots/nocolor.png)
51
+
52
+ ## Install
53
+
54
+ ```sh
55
+ uv tool install redwood-view
56
+ ```
57
+
58
+ or `pipx install redwood-view`. Either puts the `redwood` command on your `PATH`.
59
+
60
+ To get the latest unreleased code instead, install from the repo:
61
+ `uv tool install git+https://github.com/Templarrr/redwood_view`, or from a checkout,
62
+ `uv tool install .`
63
+
64
+ ## Update
65
+
66
+ ```sh
67
+ uv tool upgrade redwood-view # or: pipx upgrade redwood-view
68
+ ```
69
+
70
+ If you installed from the repo, the same command fetches the latest commit, even when the
71
+ version number hasn't changed. A pinned commit or tag (`...redwood_view@<ref>`) stays
72
+ pinned, so install again without it instead:
73
+
74
+ ```sh
75
+ uv tool install --reinstall git+https://github.com/Templarrr/redwood_view
76
+ ```
77
+
78
+ From a checkout, `git pull` and then `uv tool install --reinstall .`
79
+
80
+ ## Use
81
+
82
+ ```sh
83
+ redwood / # scan everything
84
+ redwood ~/Videos # scan one folder
85
+ redwood -x / # stay on the root filesystem (skip /proc, mounted drives, ...)
86
+ ```
87
+
88
+ Folders that can't be read are skipped and marked with `!` in the list.
89
+
90
+ | Key | Action |
91
+ |------------------------|---------------------------|
92
+ | Up / Down, `j` / `k` | move selection |
93
+ | PgUp / PgDn, Home / End| jump |
94
+ | Right / Enter | open folder |
95
+ | Left / Backspace | go to parent |
96
+ | `l` | toggle file type legend |
97
+ | `q` | quit |
98
+
99
+ Letter keys work by position on Russian, Ukrainian, Belarusian and Greek layouts too,
100
+ so `й` acts as `q` without switching to English.
101
+
102
+ Options: `--apparent-size` uses file sizes instead of disk usage, `--no-color` draws
103
+ file types with characters instead of colours.
104
+
105
+ ## Development
106
+
107
+ ```sh
108
+ uv sync
109
+ uv run pytest
110
+ uv run ruff check . && uv run ruff format --check .
111
+ ```
112
+
113
+ ### Releasing
114
+
115
+ Bump the version with `uv version --bump minor` (or `patch`, `major`), commit, then push a
116
+ matching tag:
117
+
118
+ ```sh
119
+ git tag v0.2.0
120
+ git push origin v0.2.0
121
+ ```
122
+
123
+ The release workflow checks that the tag matches the version in `pyproject.toml`, runs
124
+ the tests, builds the package and publishes it to PyPI.
@@ -0,0 +1,95 @@
1
+ # Redwood View
2
+
3
+ A console disk usage viewer in the spirit of SequoiaView: a treemap fills most of the
4
+ window, and an ncdu-style folder list on the right is used for navigation. The selected
5
+ entry is highlighted on the treemap, and blocks are coloured by file type so you can see
6
+ not only which folder is big but what is filling it.
7
+
8
+ Pure Python standard library (`curses`), so it works on any server you can `ssh` into.
9
+
10
+ ![Home folder overview with the file type legend open](https://raw.githubusercontent.com/Templarrr/redwood_view/main/docs/screenshots/overview.png)
11
+
12
+ Inside a folder: nested blocks of the same type are told apart by a dotted texture. Files too
13
+ small to get a cell of their own are merged into one block labelled "+N small items",
14
+ which keeps folders with tens of thousands of files quick to draw.
15
+
16
+ ![Videos folder](https://raw.githubusercontent.com/Templarrr/redwood_view/main/docs/screenshots/videos.png)
17
+
18
+ Without colour (`--no-color`, or a terminal that has none) each file type gets its own
19
+ character instead.
20
+
21
+ ![Monochrome mode](https://raw.githubusercontent.com/Templarrr/redwood_view/main/docs/screenshots/nocolor.png)
22
+
23
+ ## Install
24
+
25
+ ```sh
26
+ uv tool install redwood-view
27
+ ```
28
+
29
+ or `pipx install redwood-view`. Either puts the `redwood` command on your `PATH`.
30
+
31
+ To get the latest unreleased code instead, install from the repo:
32
+ `uv tool install git+https://github.com/Templarrr/redwood_view`, or from a checkout,
33
+ `uv tool install .`
34
+
35
+ ## Update
36
+
37
+ ```sh
38
+ uv tool upgrade redwood-view # or: pipx upgrade redwood-view
39
+ ```
40
+
41
+ If you installed from the repo, the same command fetches the latest commit, even when the
42
+ version number hasn't changed. A pinned commit or tag (`...redwood_view@<ref>`) stays
43
+ pinned, so install again without it instead:
44
+
45
+ ```sh
46
+ uv tool install --reinstall git+https://github.com/Templarrr/redwood_view
47
+ ```
48
+
49
+ From a checkout, `git pull` and then `uv tool install --reinstall .`
50
+
51
+ ## Use
52
+
53
+ ```sh
54
+ redwood / # scan everything
55
+ redwood ~/Videos # scan one folder
56
+ redwood -x / # stay on the root filesystem (skip /proc, mounted drives, ...)
57
+ ```
58
+
59
+ Folders that can't be read are skipped and marked with `!` in the list.
60
+
61
+ | Key | Action |
62
+ |------------------------|---------------------------|
63
+ | Up / Down, `j` / `k` | move selection |
64
+ | PgUp / PgDn, Home / End| jump |
65
+ | Right / Enter | open folder |
66
+ | Left / Backspace | go to parent |
67
+ | `l` | toggle file type legend |
68
+ | `q` | quit |
69
+
70
+ Letter keys work by position on Russian, Ukrainian, Belarusian and Greek layouts too,
71
+ so `й` acts as `q` without switching to English.
72
+
73
+ Options: `--apparent-size` uses file sizes instead of disk usage, `--no-color` draws
74
+ file types with characters instead of colours.
75
+
76
+ ## Development
77
+
78
+ ```sh
79
+ uv sync
80
+ uv run pytest
81
+ uv run ruff check . && uv run ruff format --check .
82
+ ```
83
+
84
+ ### Releasing
85
+
86
+ Bump the version with `uv version --bump minor` (or `patch`, `major`), commit, then push a
87
+ matching tag:
88
+
89
+ ```sh
90
+ git tag v0.2.0
91
+ git push origin v0.2.0
92
+ ```
93
+
94
+ The release workflow checks that the tag matches the version in `pyproject.toml`, runs
95
+ the tests, builds the package and publishes it to PyPI.
@@ -0,0 +1,76 @@
1
+ [project]
2
+ name = "redwood-view"
3
+ version = "0.1.0"
4
+ description = "Console disk usage viewer: a treemap with an ncdu-style folder list"
5
+ readme = "README.md"
6
+ license = "Apache-2.0"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.10"
9
+ dependencies = []
10
+ keywords = [
11
+ "disk usage",
12
+ "treemap",
13
+ "ncdu",
14
+ "diskonaut",
15
+ "sequoiaview",
16
+ "curses",
17
+ "tui",
18
+ ]
19
+ classifiers = [
20
+ "Development Status :: 4 - Beta",
21
+ "Environment :: Console :: Curses",
22
+ "Intended Audience :: System Administrators",
23
+ "Intended Audience :: End Users/Desktop",
24
+ "Operating System :: MacOS",
25
+ "Operating System :: POSIX :: Linux",
26
+ "Programming Language :: Python :: 3",
27
+ "Programming Language :: Python :: 3 :: Only",
28
+ "Programming Language :: Python :: 3.10",
29
+ "Programming Language :: Python :: 3.11",
30
+ "Programming Language :: Python :: 3.12",
31
+ "Programming Language :: Python :: 3.13",
32
+ "Programming Language :: Python :: 3.14",
33
+ "Topic :: System :: Filesystems",
34
+ "Topic :: Utilities",
35
+ ]
36
+
37
+ [[project.authors]]
38
+ name = "Mykhailo Odintsov"
39
+
40
+ [project.urls]
41
+ Homepage = "https://github.com/Templarrr/redwood_view"
42
+ Source = "https://github.com/Templarrr/redwood_view"
43
+ Issues = "https://github.com/Templarrr/redwood_view/issues"
44
+
45
+ [project.scripts]
46
+ redwood = "redwood.cli:main"
47
+
48
+ [dependency-groups]
49
+ dev = [
50
+ "pytest>=8",
51
+ "ruff>=0.6",
52
+ ]
53
+
54
+ [build-system]
55
+ requires = ["uv_build>=0.8,<0.12"]
56
+ build-backend = "uv_build"
57
+
58
+ [tool.uv.build-backend]
59
+ module-name = "redwood"
60
+
61
+ [tool.ruff]
62
+ line-length = 100
63
+ target-version = "py310"
64
+
65
+ [tool.ruff.lint]
66
+ select = [
67
+ "E",
68
+ "F",
69
+ "W",
70
+ "I",
71
+ "B",
72
+ "UP",
73
+ ]
74
+
75
+ [tool.pytest.ini_options]
76
+ testpaths = ["tests"]
@@ -0,0 +1,56 @@
1
+ [project]
2
+ name = "redwood-view"
3
+ version = "0.1.0"
4
+ description = "Console disk usage viewer: a treemap with an ncdu-style folder list"
5
+ readme = "README.md"
6
+ license = "Apache-2.0"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "Mykhailo Odintsov" }]
9
+ requires-python = ">=3.10"
10
+ dependencies = []
11
+ keywords = ["disk usage", "treemap", "ncdu", "diskonaut", "sequoiaview", "curses", "tui"]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Environment :: Console :: Curses",
15
+ "Intended Audience :: System Administrators",
16
+ "Intended Audience :: End Users/Desktop",
17
+ "Operating System :: MacOS",
18
+ "Operating System :: POSIX :: Linux",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Programming Language :: Python :: 3.14",
26
+ "Topic :: System :: Filesystems",
27
+ "Topic :: Utilities",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/Templarrr/redwood_view"
32
+ Source = "https://github.com/Templarrr/redwood_view"
33
+ Issues = "https://github.com/Templarrr/redwood_view/issues"
34
+
35
+ [project.scripts]
36
+ redwood = "redwood.cli:main"
37
+
38
+ [dependency-groups]
39
+ dev = ["pytest>=8", "ruff>=0.6"]
40
+
41
+ [build-system]
42
+ requires = ["uv_build>=0.8,<0.12"]
43
+ build-backend = "uv_build"
44
+
45
+ [tool.uv.build-backend]
46
+ module-name = "redwood"
47
+
48
+ [tool.ruff]
49
+ line-length = 100
50
+ target-version = "py310"
51
+
52
+ [tool.ruff.lint]
53
+ select = ["E", "F", "W", "I", "B", "UP"]
54
+
55
+ [tool.pytest.ini_options]
56
+ testpaths = ["tests"]
File without changes
@@ -0,0 +1,3 @@
1
+ from redwood.cli import main
2
+
3
+ main()
@@ -0,0 +1,36 @@
1
+ from redwood.scanner import Node
2
+
3
+
4
+ class Browser:
5
+ def __init__(self, root: Node):
6
+ self.root = root
7
+ self.current = root
8
+ self.index = 0
9
+
10
+ @property
11
+ def entries(self) -> list[Node]:
12
+ return self.current.children
13
+
14
+ @property
15
+ def selected(self) -> Node | None:
16
+ return self.entries[self.index] if self.entries else None
17
+
18
+ def move(self, delta: int) -> None:
19
+ if self.entries:
20
+ self.index = max(0, min(len(self.entries) - 1, self.index + delta))
21
+
22
+ def move_to(self, index: int) -> None:
23
+ self.move(index - self.index)
24
+
25
+ def enter(self) -> None:
26
+ node = self.selected
27
+ if node is not None and node.is_dir:
28
+ self.current = node
29
+ self.index = 0
30
+
31
+ def leave(self) -> None:
32
+ if self.current.parent is None:
33
+ return
34
+ came_from = self.current
35
+ self.current = came_from.parent
36
+ self.index = self.current.children.index(came_from)
@@ -0,0 +1,60 @@
1
+ import os
2
+ import stat
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class Category:
8
+ name: str
9
+ color: int # ANSI colour index, same numbering as curses.COLOR_*
10
+ char: str # fill used when the terminal has no colour
11
+
12
+
13
+ _EXTENSIONS = {
14
+ "video": "mp4 mkv avi mov wmv flv webm m4v mpg mpeg vob 3gp m2ts mts",
15
+ "audio": "mp3 flac wav ogg oga m4a aac wma opus aiff ape mid midi",
16
+ "image": "jpg jpeg png gif bmp tif tiff webp svg heic heif raw cr2 nef arw dng psd xcf ico",
17
+ "archive": (
18
+ "zip tar gz tgz bz2 xz zst 7z rar lz4 lzma iso img dmg deb rpm jar whl apk "
19
+ "snap squashfs qcow2 vmdk vdi vhd vhdx"
20
+ ),
21
+ "document": (
22
+ "pdf doc docx xls xlsx ppt pptx odt ods odp rtf txt md rst epub djvu csv tex "
23
+ "html htm xml json yaml yml toml log"
24
+ ),
25
+ "code": (
26
+ "py pyi c h cc cpp hpp cxx js mjs ts tsx jsx java kt scala go rs rb php pl sh bash "
27
+ "zsh lua cs swift sql css scss vue ipynb el hs ml"
28
+ ),
29
+ "binary": "so dll exe bin o a lib dylib pyc class ko wasm dat db sqlite pack idx",
30
+ }
31
+
32
+ CATEGORIES = [
33
+ Category("video", 1, "#"),
34
+ Category("audio", 5, "~"),
35
+ Category("image", 3, "%"),
36
+ Category("archive", 4, "="),
37
+ Category("document", 6, "+"),
38
+ Category("code", 2, "*"),
39
+ Category("binary", 7, "@"),
40
+ Category("other", 0, "."),
41
+ ]
42
+ OTHER = len(CATEGORIES) - 1
43
+ BINARY = next(i for i, c in enumerate(CATEGORIES) if c.name == "binary")
44
+
45
+ _BY_EXT = {
46
+ ext: next(i for i, c in enumerate(CATEGORIES) if c.name == name)
47
+ for name, exts in _EXTENSIONS.items()
48
+ for ext in exts.split()
49
+ }
50
+
51
+
52
+ def categorize(name: str, mode: int = 0) -> int:
53
+ lower = name.lower()
54
+ ext = os.path.splitext(lower)[1][1:]
55
+ if ext in _BY_EXT:
56
+ return _BY_EXT[ext]
57
+ # Versioned shared libraries (libfoo.so.6) and executables like python3.13.
58
+ if ".so." in lower or (stat.S_ISREG(mode) and mode & 0o111):
59
+ return BINARY
60
+ return OTHER
@@ -0,0 +1,64 @@
1
+ import argparse
2
+ import os
3
+ import sys
4
+ import time
5
+
6
+ from redwood.humanize import human_size
7
+ from redwood.scanner import scan
8
+
9
+
10
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
11
+ parser = argparse.ArgumentParser(
12
+ prog="redwood", description="Show disk usage as a treemap with a navigable folder list."
13
+ )
14
+ parser.add_argument("path", nargs="?", default=".", help="folder to scan (default: .)")
15
+ parser.add_argument(
16
+ "-x", "--one-file-system", action="store_true", help="don't cross filesystem boundaries"
17
+ )
18
+ parser.add_argument(
19
+ "--apparent-size", action="store_true", help="use file sizes instead of disk usage"
20
+ )
21
+ parser.add_argument("--no-color", action="store_true", help="draw with characters only")
22
+ return parser.parse_args(argv)
23
+
24
+
25
+ class _Progress:
26
+ def __init__(self):
27
+ self.last = 0.0
28
+
29
+ def __call__(self, count: int, path: str) -> None:
30
+ now = time.monotonic()
31
+ if now - self.last < 0.1:
32
+ return
33
+ self.last = now
34
+ width = os.get_terminal_size(sys.stderr.fileno()).columns if sys.stderr.isatty() else 80
35
+ line = f"Scanning: {count} items {path}"
36
+ sys.stderr.write("\r" + line[: width - 1].ljust(width - 1))
37
+ sys.stderr.flush()
38
+
39
+
40
+ def main(argv: list[str] | None = None) -> None:
41
+ args = parse_args(argv)
42
+ if not os.path.isdir(args.path):
43
+ sys.exit(f"redwood: not a directory: {args.path}")
44
+
45
+ progress = _Progress() if sys.stderr.isatty() else None
46
+ try:
47
+ root = scan(args.path, args.one_file_system, args.apparent_size, progress)
48
+ except KeyboardInterrupt:
49
+ sys.exit(130)
50
+ except OSError as e:
51
+ sys.exit(f"redwood: {e}")
52
+ if progress:
53
+ sys.stderr.write("\r\033[K")
54
+
55
+ if not sys.stdout.isatty():
56
+ print(f"{human_size(root.size)}\t{root.path}")
57
+ return
58
+
59
+ from redwood.ui import run
60
+
61
+ try:
62
+ run(root, color=not args.no_color)
63
+ except KeyboardInterrupt:
64
+ pass
@@ -0,0 +1,12 @@
1
+ _UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]
2
+
3
+
4
+ def human_size(size: int) -> str:
5
+ value = float(size)
6
+ for unit in _UNITS:
7
+ if value < 1024 or unit == _UNITS[-1]:
8
+ break
9
+ value /= 1024
10
+ if unit == "B":
11
+ return f"{int(value)} B"
12
+ return f"{value:.1f} {unit}"
@@ -0,0 +1,38 @@
1
+ import curses
2
+
3
+ # Terminals report characters, not physical keys, so letters typed on a non-Latin
4
+ # layout are translated back to the US QWERTY key in the same position.
5
+ _QWERTY = "`qwertyuiop[]asdfghjkl;'zxcvbnm,."
6
+ _LAYOUTS = {
7
+ "russian": "ёйцукенгшщзхъфывапролджэячсмитьбю",
8
+ "ukrainian": "'йцукенгшщзхїфівапролджєячсмитьбю",
9
+ "belarusian": "ёйцукенгшўзх'фывапролджэячсмітьбю",
10
+ "greek": "`;ςερτυθιοπ[]ασδφγηξκλ΄'ζχψωβνμ,.",
11
+ }
12
+
13
+
14
+ def _build() -> dict[str, str]:
15
+ table: dict[str, str] = {}
16
+ for chars in _LAYOUTS.values():
17
+ for char, latin in zip(chars, _QWERTY, strict=True):
18
+ if char.isascii():
19
+ continue
20
+ table.setdefault(char, latin)
21
+ # Skips final sigma, whose upper case belongs to σ.
22
+ if char.upper().lower() == char:
23
+ table.setdefault(char.upper(), latin.upper())
24
+ return table
25
+
26
+
27
+ _TO_LATIN = _build()
28
+
29
+
30
+ def normalize(key: int | str) -> int:
31
+ """Turn a get_wch() result into a getch()-style code, mapping layouts to QWERTY."""
32
+ if isinstance(key, int):
33
+ return key
34
+ return ord(_TO_LATIN.get(key, key))
35
+
36
+
37
+ def read_key(screen: curses.window) -> int:
38
+ return normalize(screen.get_wch())
@@ -0,0 +1,111 @@
1
+ import os
2
+ import stat
3
+ from collections.abc import Callable
4
+
5
+ from redwood.categories import CATEGORIES, OTHER, categorize
6
+
7
+
8
+ class Node:
9
+ __slots__ = ("name", "parent", "size", "children", "category", "cat_sizes", "error")
10
+
11
+ def __init__(self, name: str, parent: "Node | None", size: int = 0, category: int = OTHER):
12
+ self.name = name
13
+ self.parent = parent
14
+ self.size = size
15
+ self.category = category
16
+ self.children: list[Node] | None = None
17
+ self.cat_sizes: list[int] | None = None
18
+ self.error = False
19
+
20
+ @property
21
+ def is_dir(self) -> bool:
22
+ return self.children is not None
23
+
24
+ @property
25
+ def path(self) -> str:
26
+ if self.parent is None:
27
+ return self.name
28
+ return os.path.join(self.parent.path, self.name)
29
+
30
+ def __repr__(self) -> str:
31
+ return f"Node({self.path!r}, {self.size})"
32
+
33
+
34
+ def _dir_node(name: str, parent: Node | None) -> Node:
35
+ node = Node(name, parent)
36
+ node.children = []
37
+ node.cat_sizes = [0] * len(CATEGORIES)
38
+ return node
39
+
40
+
41
+ def scan(
42
+ root: str,
43
+ one_file_system: bool = False,
44
+ apparent_size: bool = False,
45
+ progress: Callable[[int, str], None] | None = None,
46
+ ) -> Node:
47
+ root = os.path.abspath(root)
48
+ root_dev = os.stat(root).st_dev
49
+ root_node = _dir_node(root, None)
50
+ # Iterative walk so very deep trees can't hit the recursion limit.
51
+ stack = [(root_node, root)]
52
+ visited: list[Node] = []
53
+ seen_inodes: set[tuple[int, int]] = set()
54
+ count = 0
55
+
56
+ while stack:
57
+ node, path = stack.pop()
58
+ visited.append(node)
59
+ try:
60
+ with os.scandir(path) as it:
61
+ entries = list(it)
62
+ except OSError:
63
+ node.error = True
64
+ continue
65
+
66
+ for entry in entries:
67
+ try:
68
+ st = entry.stat(follow_symlinks=False)
69
+ except OSError:
70
+ continue
71
+ count += 1
72
+ if stat.S_ISDIR(st.st_mode):
73
+ if one_file_system and st.st_dev != root_dev:
74
+ continue
75
+ child = _dir_node(entry.name, node)
76
+ stack.append((child, entry.path))
77
+ else:
78
+ # Hard links share storage, so only count the first one we meet.
79
+ if st.st_nlink > 1:
80
+ key = (st.st_dev, st.st_ino)
81
+ if key in seen_inodes:
82
+ continue
83
+ seen_inodes.add(key)
84
+ child = Node(
85
+ entry.name, node, _size(st, apparent_size), categorize(entry.name, st.st_mode)
86
+ )
87
+ node.children.append(child)
88
+ if progress:
89
+ progress(count, path)
90
+
91
+ # Parents were visited before their children, so reversing gives a bottom-up order.
92
+ for node in reversed(visited):
93
+ node.children.sort(key=lambda n: n.size, reverse=True)
94
+ for child in node.children:
95
+ node.size += child.size
96
+ if child.is_dir:
97
+ for i, size in enumerate(child.cat_sizes):
98
+ node.cat_sizes[i] += size
99
+ else:
100
+ node.cat_sizes[child.category] += child.size
101
+ if node.size:
102
+ node.category = max(range(len(CATEGORIES)), key=node.cat_sizes.__getitem__)
103
+ return root_node
104
+
105
+
106
+ def _size(st: os.stat_result, apparent_size: bool) -> int:
107
+ blocks = getattr(st, "st_blocks", None)
108
+ if apparent_size or blocks is None:
109
+ return st.st_size
110
+ # Allocated size, like du/ncdu; also keeps sparse files such as /proc/kcore at their real cost.
111
+ return blocks * 512
@@ -0,0 +1,98 @@
1
+ from typing import NamedTuple
2
+
3
+ # Terminal cells are roughly twice as tall as they are wide.
4
+ CELL_ASPECT = 2.0
5
+
6
+
7
+ class Rect(NamedTuple):
8
+ x: int
9
+ y: int
10
+ w: int
11
+ h: int
12
+
13
+
14
+ def squarify(
15
+ sizes: list[float], x: float, y: float, w: float, h: float
16
+ ) -> list[tuple[float, float, float, float]]:
17
+ """Squarified treemap (Bruls et al.); `sizes` must be sorted in descending order."""
18
+ total = sum(sizes)
19
+ if total <= 0 or w <= 0 or h <= 0:
20
+ return [(x, y, 0.0, 0.0)] * len(sizes)
21
+ scale = w * h / total
22
+ areas = [s * scale for s in sizes]
23
+ rects = []
24
+ i = 0
25
+ while i < len(areas):
26
+ if areas[i] <= 0 or w <= 0 or h <= 0:
27
+ rects.extend([(x, y, 0.0, 0.0)] * (len(areas) - i))
28
+ break
29
+ short = min(w, h)
30
+ # Sizes are sorted, so the row's largest area is its first and the smallest its last;
31
+ # that keeps each aspect-ratio check O(1) instead of rescanning the row.
32
+ row = [areas[i]]
33
+ row_sum = areas[i]
34
+ i += 1
35
+ while i < len(areas) and areas[i] > 0:
36
+ grown = row_sum + areas[i]
37
+ if _worst(grown, row[0], areas[i], short) > _worst(row_sum, row[0], row[-1], short):
38
+ break
39
+ row.append(areas[i])
40
+ row_sum = grown
41
+ i += 1
42
+ if w >= h:
43
+ col_w = row_sum / h
44
+ cy = y
45
+ for area in row:
46
+ rects.append((x, cy, col_w, area / col_w))
47
+ cy += area / col_w
48
+ x += col_w
49
+ w -= col_w
50
+ else:
51
+ row_h = row_sum / w
52
+ cx = x
53
+ for area in row:
54
+ rects.append((cx, y, area / row_h, row_h))
55
+ cx += area / row_h
56
+ y += row_h
57
+ h -= row_h
58
+ return rects
59
+
60
+
61
+ def _worst(total: float, largest: float, smallest: float, short: float) -> float:
62
+ side2 = short * short
63
+ total2 = total * total
64
+ return max(side2 * largest / total2, total2 / (side2 * smallest))
65
+
66
+
67
+ def layout(sizes: list[float], area: Rect) -> tuple[list[Rect], Rect]:
68
+ """Lay sizes out on the character grid.
69
+
70
+ Items too small to get a cell of their own are merged into one block, so huge folders cost
71
+ no more than the screen can show. Returns rects for the leading, visible items (possibly
72
+ fewer than `sizes`) and the merged block's rect, which is empty when nothing was merged.
73
+ """
74
+ total = sum(sizes)
75
+ cells = area.w * area.h
76
+ if total <= 0 or cells <= 0:
77
+ return [Rect(area.x, area.y, 0, 0)] * len(sizes), Rect(area.x, area.y, 0, 0)
78
+ shown = 0
79
+ while shown < len(sizes) and sizes[shown] * cells >= total:
80
+ shown += 1
81
+ if shown == len(sizes):
82
+ return _grid(sizes, area), Rect(area.x, area.y, 0, 0)
83
+ rest = total - sum(sizes[:shown])
84
+ # Squarify wants descending sizes, so the merged block goes in at its sorted position.
85
+ pos = next((i for i in range(shown) if sizes[i] < rest), shown)
86
+ rects = _grid(sizes[:pos] + [rest] + sizes[pos:shown], area)
87
+ return rects, rects.pop(pos)
88
+
89
+
90
+ def _grid(sizes: list[float], area: Rect) -> list[Rect]:
91
+ rects = squarify(sizes, 0.0, 0.0, float(area.w), area.h * CELL_ASPECT)
92
+ result = []
93
+ # Rounding shared edges the same way keeps neighbours from overlapping or leaving gaps.
94
+ for fx, fy, fw, fh in rects:
95
+ x0, x1 = round(fx), round(fx + fw)
96
+ y0, y1 = round(fy / CELL_ASPECT), round((fy + fh) / CELL_ASPECT)
97
+ result.append(Rect(area.x + x0, area.y + y0, max(0, x1 - x0), max(0, y1 - y0)))
98
+ return result
@@ -0,0 +1,307 @@
1
+ import curses
2
+ import locale
3
+
4
+ from redwood.browser import Browser
5
+ from redwood.categories import CATEGORIES, OTHER
6
+ from redwood.humanize import human_size
7
+ from redwood.keys import read_key
8
+ from redwood.scanner import Node
9
+ from redwood.treemap import Rect, layout
10
+
11
+ HELP = "↑↓ move →/Enter open ←/Bksp up l legend q quit"
12
+ HELP_ASCII = "Up/Down move Right/Enter open Left/Bksp up l legend q quit"
13
+ BAR_WIDTH = 8
14
+
15
+ _HIGHLIGHT_PAIR = len(CATEGORIES) + 1
16
+
17
+
18
+ class Styles:
19
+ def __init__(self, color: bool):
20
+ self.color = color and curses.has_colors()
21
+ if self.color:
22
+ curses.start_color()
23
+ try:
24
+ curses.use_default_colors()
25
+ default_bg = -1
26
+ except curses.error:
27
+ default_bg = curses.COLOR_BLACK
28
+ for i, cat in enumerate(CATEGORIES):
29
+ if i == OTHER:
30
+ curses.init_pair(i + 1, curses.COLOR_WHITE, default_bg)
31
+ else:
32
+ curses.init_pair(i + 1, curses.COLOR_BLACK, cat.color)
33
+ curses.init_pair(_HIGHLIGHT_PAIR, curses.COLOR_YELLOW, default_bg)
34
+ self.highlight = curses.color_pair(_HIGHLIGHT_PAIR) | curses.A_BOLD | curses.A_REVERSE
35
+ else:
36
+ self.highlight = curses.A_BOLD | curses.A_REVERSE
37
+
38
+ def fill(self, category: int, alt: bool) -> tuple[str, int]:
39
+ # Alternating texture keeps neighbouring blocks of the same type apart.
40
+ if self.color:
41
+ if category == OTHER:
42
+ char = ":" if alt else "."
43
+ else:
44
+ char = "." if alt else " "
45
+ return char, curses.color_pair(category + 1)
46
+ return CATEGORIES[category].char, curses.A_BOLD if alt else curses.A_NORMAL
47
+
48
+ def swatch(self, category: int) -> tuple[str, int]:
49
+ char, attr = self.fill(category, False)
50
+ return char * 2, attr
51
+
52
+
53
+ class App:
54
+ def __init__(self, screen, root: Node, color: bool):
55
+ self.screen = screen
56
+ self.browser = Browser(root)
57
+ self.styles = Styles(color)
58
+ self.show_legend = False
59
+ self.list_top = 0
60
+ self.encoding = screen.encoding or locale.getpreferredencoding()
61
+ self.unicode = "utf" in self.encoding.lower()
62
+ self.unreadable = _count_errors(root)
63
+ self._tiles_key = None
64
+ self._tiles: list[tuple[Rect, int, bool]] = []
65
+ self._top_rects: list[Rect] = []
66
+ self._rest_rect = Rect(0, 0, 0, 0)
67
+
68
+ def run(self) -> None:
69
+ curses.curs_set(0)
70
+ while True:
71
+ self.draw()
72
+ key = read_key(self.screen)
73
+ if key in (ord("q"), ord("Q")):
74
+ return
75
+ self.handle(key)
76
+
77
+ def handle(self, key: int) -> None:
78
+ b = self.browser
79
+ page = max(1, self.screen.getmaxyx()[0] - 3)
80
+ actions = {
81
+ curses.KEY_UP: lambda: b.move(-1),
82
+ ord("k"): lambda: b.move(-1),
83
+ curses.KEY_DOWN: lambda: b.move(1),
84
+ ord("j"): lambda: b.move(1),
85
+ curses.KEY_PPAGE: lambda: b.move(-page),
86
+ curses.KEY_NPAGE: lambda: b.move(page),
87
+ curses.KEY_HOME: lambda: b.move_to(0),
88
+ curses.KEY_END: lambda: b.move_to(len(b.entries) - 1),
89
+ curses.KEY_RIGHT: b.enter,
90
+ curses.KEY_ENTER: b.enter,
91
+ 10: b.enter,
92
+ 13: b.enter,
93
+ curses.KEY_LEFT: b.leave,
94
+ curses.KEY_BACKSPACE: b.leave,
95
+ 127: b.leave,
96
+ 8: b.leave,
97
+ ord("l"): self.toggle_legend,
98
+ }
99
+ action = actions.get(key)
100
+ if action:
101
+ action()
102
+
103
+ def toggle_legend(self) -> None:
104
+ self.show_legend = not self.show_legend
105
+
106
+ def text(self, y: int, x: int, s: str, width: int, attr: int = 0) -> None:
107
+ if width <= 0:
108
+ return
109
+ s = s.encode(self.encoding, "replace").decode(self.encoding)
110
+ s = "".join(c if c.isprintable() else "?" for c in s)
111
+ try:
112
+ self.screen.addnstr(y, x, s, width, attr)
113
+ except curses.error:
114
+ pass # writing the bottom-right cell moves the cursor off-screen
115
+
116
+ def draw(self) -> None:
117
+ self.screen.erase()
118
+ height, width = self.screen.getmaxyx()
119
+ if width < 40 or height < 8:
120
+ self.text(0, 0, "Terminal too small", width)
121
+ self.screen.refresh()
122
+ return
123
+ list_w = min(max(width * 2 // 5, 36), 64)
124
+ map_area = Rect(0, 1, width - list_w - 1, height - 2)
125
+ self.draw_header(width)
126
+ self.draw_treemap(map_area)
127
+ self.draw_separator(width - list_w - 1, 1, height - 2)
128
+ self.draw_list(Rect(width - list_w, 1, list_w, height - 2))
129
+ if self.show_legend:
130
+ self.draw_legend(map_area)
131
+ self.text(
132
+ height - 1,
133
+ 0,
134
+ (HELP if self.unicode else HELP_ASCII).ljust(width),
135
+ width,
136
+ curses.A_REVERSE,
137
+ )
138
+ self.screen.refresh()
139
+
140
+ def draw_header(self, width: int) -> None:
141
+ cur = self.browser.current
142
+ right = f" {human_size(cur.size)} {len(cur.children)} items "
143
+ if self.unreadable:
144
+ right = f" {self.unreadable} unreadable |" + right
145
+ left = f" redwood {cur.path}"
146
+ line = left[: max(0, width - len(right))].ljust(width - len(right)) + right
147
+ self.text(0, 0, line, width, curses.A_REVERSE)
148
+
149
+ def draw_separator(self, x: int, y: int, h: int) -> None:
150
+ try:
151
+ self.screen.vline(y, x, curses.ACS_VLINE, h)
152
+ except curses.error:
153
+ pass
154
+
155
+ def draw_treemap(self, area: Rect) -> None:
156
+ cur = self.browser.current
157
+ key = (id(cur), area)
158
+ if key != self._tiles_key:
159
+ self._tiles = []
160
+ self._top_rects, self._rest_rect = layout([c.size for c in cur.children], area)
161
+ for i, (child, rect) in enumerate(zip(cur.children, self._top_rects, strict=False)):
162
+ self._collect(child, _frame_inner(child, rect), i % 2 == 1)
163
+ self._add_rest_tile(self._rest_rect, cur, len(self._top_rects))
164
+ self._tiles_key = key
165
+
166
+ for rect, category, alt in self._tiles:
167
+ char, attr = self.styles.fill(category, alt)
168
+ for row in range(rect.y, rect.y + rect.h):
169
+ self.text(row, rect.x, char * rect.w, rect.w, attr)
170
+
171
+ rest = self._rest_rect
172
+ if rest.w >= 6:
173
+ label = f"+{len(cur.children) - len(self._top_rects)} small items"
174
+ self.text(rest.y, rest.x, label, rest.w, curses.A_BOLD)
175
+
176
+ for i, (child, rect) in enumerate(zip(cur.children, self._top_rects, strict=False)):
177
+ if not rect.w or not rect.h:
178
+ continue
179
+ selected = i == self.browser.index
180
+ label = f" {child.name}{'/' if child.is_dir else ''} {human_size(child.size)} "
181
+ if _frame_inner(child, rect) != rect:
182
+ self.draw_frame(rect, self.styles.highlight if selected else curses.A_NORMAL)
183
+ self.text(
184
+ rect.y, rect.x + 1, label, rect.w - 2, self.styles.highlight if selected else 0
185
+ )
186
+ elif selected:
187
+ attr = self.styles.highlight
188
+ for row in range(rect.y, rect.y + rect.h):
189
+ self.text(row, rect.x, " " * rect.w, rect.w, attr)
190
+ self.text(rect.y, rect.x, label.strip(), rect.w, attr)
191
+ elif rect.w >= 6:
192
+ self.text(
193
+ rect.y,
194
+ rect.x,
195
+ label.strip(),
196
+ rect.w,
197
+ self.styles.fill(child.category, i % 2 == 1)[1],
198
+ )
199
+
200
+ def _collect(self, node: Node, rect: Rect, alt: bool) -> None:
201
+ if not rect.w or not rect.h:
202
+ return
203
+ if not node.is_dir or not node.size or rect.w * rect.h < 2:
204
+ self._tiles.append((rect, node.category, alt))
205
+ return
206
+ rects, rest = layout([c.size for c in node.children], rect)
207
+ for i, (child, child_rect) in enumerate(zip(node.children, rects, strict=False)):
208
+ self._collect(child, child_rect, alt ^ (i % 2 == 1))
209
+ self._add_rest_tile(rest, node, len(rects), alt)
210
+
211
+ def _add_rest_tile(self, rect: Rect, parent: Node, index: int, alt: bool = False) -> None:
212
+ # The parent's dominant type is close enough for a block of items too small to see.
213
+ if rect.w and rect.h:
214
+ self._tiles.append((rect, parent.category, alt ^ (index % 2 == 1)))
215
+
216
+ def draw_frame(self, r: Rect, attr: int) -> None:
217
+ s = self.screen
218
+ right, bottom = r.x + r.w - 1, r.y + r.h - 1
219
+ try:
220
+ s.hline(r.y, r.x + 1, curses.ACS_HLINE | attr, r.w - 2)
221
+ s.hline(bottom, r.x + 1, curses.ACS_HLINE | attr, r.w - 2)
222
+ s.vline(r.y + 1, r.x, curses.ACS_VLINE | attr, r.h - 2)
223
+ s.vline(r.y + 1, right, curses.ACS_VLINE | attr, r.h - 2)
224
+ s.addch(r.y, r.x, curses.ACS_ULCORNER, attr)
225
+ s.addch(r.y, right, curses.ACS_URCORNER, attr)
226
+ s.addch(bottom, r.x, curses.ACS_LLCORNER, attr)
227
+ s.addch(bottom, right, curses.ACS_LRCORNER, attr)
228
+ except curses.error:
229
+ pass
230
+
231
+ def draw_list(self, area: Rect) -> None:
232
+ b = self.browser
233
+ entries = b.entries
234
+ if not entries:
235
+ msg = "(unreadable)" if b.current.error else "(empty)"
236
+ self.text(area.y, area.x + 1, msg, area.w - 1)
237
+ return
238
+ if b.index < self.list_top:
239
+ self.list_top = b.index
240
+ elif b.index >= self.list_top + area.h:
241
+ self.list_top = b.index - area.h + 1
242
+ self.list_top = max(0, min(self.list_top, len(entries) - area.h))
243
+
244
+ biggest = entries[0].size or 1
245
+ total = b.current.size or 1
246
+ for row, i in enumerate(range(self.list_top, min(len(entries), self.list_top + area.h))):
247
+ node = entries[i]
248
+ y = area.y + row
249
+ swatch, swatch_attr = self.styles.swatch(node.category)
250
+ filled = round(BAR_WIDTH * node.size / biggest)
251
+ bar = "#" * filled + " " * (BAR_WIDTH - filled)
252
+ mark = "!" if node.error else " "
253
+ name = node.name + ("/" if node.is_dir else "")
254
+ line = f"{human_size(node.size):>9} {node.size * 100 / total:5.1f}% [{bar}]{mark}{name}"
255
+ attr = curses.A_REVERSE if i == b.index else 0
256
+ self.text(y, area.x + 1, swatch, 2, swatch_attr)
257
+ self.text(y, area.x + 3, line.ljust(area.w), area.w - 3, attr)
258
+
259
+ def draw_legend(self, area: Rect) -> None:
260
+ cur = self.browser.current
261
+ cats = sorted(
262
+ (i for i in range(len(CATEGORIES)) if cur.cat_sizes[i]),
263
+ key=lambda i: cur.cat_sizes[i],
264
+ reverse=True,
265
+ )
266
+ total = cur.size or 1
267
+ lines = []
268
+ for i in cats:
269
+ size = cur.cat_sizes[i]
270
+ lines.append(
271
+ (i, f"{CATEGORIES[i].name:<9}{human_size(size):>10} {size * 100 / total:5.1f}%")
272
+ )
273
+ w = 2 + 3 + max((len(t) for _, t in lines), default=16)
274
+ h = len(lines) + 2
275
+ box = Rect(area.x + 1, area.y + area.h - h - 1, w + 1, h)
276
+ if box.y < area.y or box.w > area.w:
277
+ return
278
+ for row in range(box.y, box.y + box.h):
279
+ self.text(row, box.x, " " * box.w, box.w)
280
+ self.draw_frame(box, curses.A_BOLD)
281
+ self.text(box.y, box.x + 2, " legend ", box.w - 3, curses.A_BOLD)
282
+ if not lines:
283
+ self.text(box.y + 1, box.x + 2, "(nothing here)", box.w - 3)
284
+ for row, (i, line) in enumerate(lines):
285
+ swatch, attr = self.styles.swatch(i)
286
+ self.text(box.y + 1 + row, box.x + 2, swatch, 2, attr)
287
+ self.text(box.y + 1 + row, box.x + 5, line, box.w - 6)
288
+
289
+
290
+ def _frame_inner(node: Node, rect: Rect) -> Rect:
291
+ if node.is_dir and rect.w >= 6 and rect.h >= 3:
292
+ return Rect(rect.x + 1, rect.y + 1, rect.w - 2, rect.h - 2)
293
+ return rect
294
+
295
+
296
+ def _count_errors(root: Node) -> int:
297
+ count, stack = 0, [root]
298
+ while stack:
299
+ node = stack.pop()
300
+ count += node.error
301
+ stack.extend(c for c in node.children if c.is_dir)
302
+ return count
303
+
304
+
305
+ def run(root: Node, color: bool = True) -> None:
306
+ locale.setlocale(locale.LC_ALL, "")
307
+ curses.wrapper(lambda screen: App(screen, root, color).run())