1#!/usr/bin/env python
2#
3# Urwid example lazy directory browser / tree view
4# Copyright (C) 2004-2011 Ian Ward
5# Copyright (C) 2010 Kirk McDonald
6# Copyright (C) 2010 Rob Lanphier
7#
8# This library is free software; you can redistribute it and/or
9# modify it under the terms of the GNU Lesser General Public
10# License as published by the Free Software Foundation; either
11# version 2.1 of the License, or (at your option) any later version.
12#
13# This library is distributed in the hope that it will be useful,
14# but WITHOUT ANY WARRANTY; without even the implied warranty of
15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
16# Lesser General Public License for more details.
17#
18# You should have received a copy of the GNU Lesser General Public
19# License along with this library; if not, write to the Free Software
20# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
21#
22# Urwid web site: https://urwid.org/
23
24"""
25Urwid example lazy directory browser / tree view.
26
27Features:
28- custom selectable widgets for files and directories
29- custom message widgets to identify access errors and empty directories
30- custom list walker for displaying widgets in a tree fashion
31- outputs a quoted list of files and directories "selected" on exit
32- copies the path in focus to the clipboard of the terminal (OSC 52)
33"""
34
35from __future__ import annotations
36
37import itertools
38import os
39import re
40import typing
41
42import urwid
43
44if typing.TYPE_CHECKING:
45 from collections.abc import Hashable
46
47_TreeNode = typing.TypeVar("_TreeNode", bound="urwid.TreeNode[typing.Any]")
48
49
50class FlagFileWidget(urwid.TreeWidget[_TreeNode]):
51 """Tree widget that can be flagged, with a highlighted appearance while flagged."""
52
53 # apply an attribute to the expand/unexpand icons
54 unexpanded_icon = urwid.AttrMap(urwid.TreeWidget.unexpanded_icon, "dirmark")
55 expanded_icon = urwid.AttrMap(urwid.TreeWidget.expanded_icon, "dirmark")
56
57 def __init__(self, node: _TreeNode) -> None:
58 """Initialize unflagged, wrapping the tree widget's display in an extra `AttrMap`."""
59 super().__init__(node)
60 # insert an extra AttrWrap for our own use; TreeWidget declares its wrapped widget as the Padding
61 # pyrefly: ignore[bad-argument-type]
62 self._w = urwid.AttrMap(self._w, None) # type: ignore[assignment]
63 self.flagged = False
64 self.update_w()
65
66 def selectable(self) -> bool:
67 """Return whether this widget can take focus."""
68 return True
69
70 def keypress(self, size: tuple[int] | tuple[()], key: str) -> str | None: # type: ignore[override]
71 """Handle a keypress, passing any unhandled key on to :meth:`unhandled_keys`.
72
73 :param size: render size passed by the parent widget
74 :param key: key that was pressed
75 :returns: the key if it was not handled, ``None`` otherwise
76 """
77 if (unhandled := super().keypress(size, key)) is not None:
78 unhandled = self.unhandled_keys(size, unhandled)
79 else:
80 return None
81 return unhandled
82
83 def unhandled_keys(self, size: tuple[int] | tuple[()], key: str) -> str | None:
84 """Override this method to intercept keystrokes in subclasses.
85
86 Default behavior: toggle flagged on space, ignore other keys.
87
88 :param size: render size passed by the parent widget
89 :param key: key that was not handled by the base widget
90 :returns: the key if it was not handled, ``None`` otherwise
91 """
92 if key == " ":
93 self.flagged = not self.flagged
94 self.update_w()
95 return None
96
97 return key
98
99 def update_w(self) -> None:
100 """Update the attributes of self.widget based on self.flagged."""
101 if self.flagged:
102 self._w.attr_map = {None: "flagged"} # type: ignore[attr-defined]
103 self._w.focus_map = {None: "flagged focus"} # type: ignore[attr-defined]
104 else:
105 self._w.attr_map = {None: "body"} # type: ignore[attr-defined]
106 self._w.focus_map = {None: "focus"} # type: ignore[attr-defined]
107
108
109class FileTreeWidget(FlagFileWidget["FileNode"]):
110 """Widget for individual files."""
111
112 def __init__(self, node: FileNode) -> None:
113 """Initialize and register this widget under its file's path."""
114 super().__init__(node)
115 path = node.get_value()
116 add_widget(path, self)
117
118 def get_display_text(self) -> str:
119 """Return the file name to display."""
120 return typing.cast("str", self.get_node().get_key())
121
122
123class EmptyWidget(urwid.TreeWidget["EmptyNode"]):
124 """A marker for expanded directories with no contents."""
125
126 def get_display_text(self) -> str | tuple[Hashable, str] | list[str | tuple[Hashable, str]]:
127 """Return the placeholder text shown for an empty directory."""
128 return ("flag", "(empty directory)")
129
130
131class ErrorWidget(urwid.TreeWidget["ErrorNode"]):
132 """A marker for errors reading directories."""
133
134 def get_display_text(self) -> str | tuple[Hashable, str] | list[str | tuple[Hashable, str]]:
135 """Return the placeholder text shown for a directory that could not be read."""
136 return ("error", "(error/permission denied)")
137
138
139class DirectoryWidget(FlagFileWidget["DirectoryNode"]):
140 """Widget for a directory."""
141
142 def __init__(self, node: DirectoryNode) -> None:
143 """Initialize, register this widget under its directory's path, and set its initial expanded state."""
144 super().__init__(node)
145 path = node.get_value()
146 add_widget(path, self)
147 self.expanded = starts_expanded(path)
148 self.update_expanded_icon()
149
150 def get_display_text(self) -> str:
151 """Return the directory name to display, or ``/`` for the root."""
152 node = self.get_node()
153 if node.get_depth() == 0:
154 return "/"
155
156 return typing.cast("str", node.get_key())
157
158
159class FileNode(urwid.TreeNode[str]):
160 """Metadata storage for individual files."""
161
162 def __init__(self, path: str, parent: DirectoryNode | None = None) -> None:
163 """Initialize with the key and depth derived from `path`."""
164 depth = path.count(dir_sep())
165 key = os.path.basename(path)
166 super().__init__(path, key=key, parent=parent, depth=depth)
167
168 def load_parent(self) -> DirectoryNode:
169 """Return the (freshly loaded) parent directory node."""
170 parentname, _myname = os.path.split(self.get_value())
171 parent = DirectoryNode(parentname)
172 parent.set_child_node(self.get_key(), self)
173 return parent
174
175 def load_widget(self) -> FileTreeWidget:
176 """Return a new widget for this file."""
177 return FileTreeWidget(self)
178
179
180class EmptyNode(urwid.TreeNode[None]):
181 """Placeholder child of a directory with no contents."""
182
183 def load_widget(self) -> EmptyWidget:
184 """Return a new widget for this placeholder."""
185 return EmptyWidget(self)
186
187
188class ErrorNode(urwid.TreeNode[None]):
189 """Placeholder child of a directory that could not be read."""
190
191 def load_widget(self) -> ErrorWidget:
192 """Return a new widget for this placeholder."""
193 return ErrorWidget(self)
194
195
196class DirectoryNode(urwid.ParentNode[str]):
197 """Metadata storage for directories."""
198
199 def __init__(self, path: str, parent: DirectoryNode | None = None) -> None:
200 """Initialize with the key and depth derived from `path`, with no children read yet."""
201 if path == dir_sep():
202 depth = 0
203 key = None
204 else:
205 depth = path.count(dir_sep())
206 key = os.path.basename(path)
207 super().__init__(path, key=key, parent=parent, depth=depth)
208 self.dir_count = 0
209 self.read_error = False
210
211 def load_parent(self) -> DirectoryNode:
212 """Return the (freshly loaded) parent directory node."""
213 parentname, _myname = os.path.split(self.get_value())
214 parent = DirectoryNode(parentname)
215 parent.set_child_node(self.get_key(), self)
216 return parent
217
218 def load_child_keys(self) -> list[str] | list[None]:
219 """Return the sorted directory-entry keys, or ``[None]`` for an empty or unreadable directory."""
220 dirs: list[str] = []
221 files: list[str] = []
222 try:
223 path = self.get_value()
224 # separate dirs and files
225 for a in os.listdir(path):
226 if os.path.isdir(os.path.join(path, a)):
227 dirs.append(a)
228 else:
229 files.append(a)
230 except OSError:
231 # the placeholder child itself is built by load_child_node
232 self.read_error = True
233 self.dir_count = 0
234 return [None]
235
236 # sort dirs and files
237 dirs.sort(key=alphabetize)
238 files.sort(key=alphabetize)
239 # store where the first file starts
240 self.read_error = False
241 self.dir_count = len(dirs)
242 # collect dirs and files together again
243 keys: list[str] | list[None] = dirs + files
244 if len(keys) == 0:
245 keys = [None]
246 return keys
247
248 def load_child_node(
249 self,
250 key: str | None, # type: ignore[override] # We have explicit type
251 ) -> EmptyNode | ErrorNode | DirectoryNode | FileNode:
252 """Return the child node for a directory entry.
253
254 :param key: entry name, or ``None`` for the placeholder child of an empty or unreadable directory
255 :returns: a placeholder node, a :class:`FileNode`, or a :class:`DirectoryNode`
256 """
257 if key is None:
258 depth = self.get_depth() + 1
259 if self.read_error:
260 return ErrorNode(None, parent=self, key=None, depth=depth)
261
262 return EmptyNode(None, parent=self, key=None, depth=depth)
263
264 path = os.path.join(self.get_value(), key)
265 if self.get_child_index(key) < self.dir_count:
266 return DirectoryNode(path, parent=self)
267
268 return FileNode(path, parent=self)
269
270 def load_widget(self) -> DirectoryWidget:
271 """Return a new widget for this directory."""
272 return DirectoryWidget(self)
273
274
275class DirectoryBrowser:
276 """Interactive lazy directory tree browser."""
277
278 palette: typing.ClassVar[list[tuple[str, str, str] | tuple[str, str, str, str]]] = [
279 ("body", "black", "light gray"),
280 ("flagged", "black", "dark green", "bold,underline"),
281 ("focus", "light gray", "dark blue", "standout"),
282 ("flagged focus", "yellow", "dark cyan", "bold,standout,underline"),
283 ("head", "yellow", "black", "standout"),
284 ("foot", "light gray", "black"),
285 ("key", "light cyan", "black", "underline"),
286 ("title", "white", "black", "bold"),
287 ("dirmark", "black", "dark cyan", "bold"),
288 ("flag", "dark gray", "light gray"),
289 ("error", "dark red", "light gray"),
290 ]
291
292 footer_text: typing.ClassVar[list[tuple[str, str] | str]] = [
293 ("title", "Directory Browser"),
294 " ",
295 ("key", "UP"),
296 ",",
297 ("key", "DOWN"),
298 ",",
299 ("key", "PAGE UP"),
300 ",",
301 ("key", "PAGE DOWN"),
302 " ",
303 ("key", "SPACE"),
304 " ",
305 ("key", "+"),
306 ",",
307 ("key", "-"),
308 " ",
309 ("key", "LEFT"),
310 " ",
311 ("key", "HOME"),
312 " ",
313 ("key", "END"),
314 " ",
315 ("key", "C"),
316 " ",
317 ("key", "Q"),
318 ]
319
320 def __init__(self) -> None:
321 """Initialize the tree view rooted at the current working directory."""
322 cwd = os.getcwd()
323 store_initial_cwd(cwd)
324 self.header = urwid.Text("")
325 self.listbox = urwid.TreeListBox(urwid.TreeWalker(DirectoryNode(cwd)))
326 self.listbox.offset_rows = 1
327 self.footer = urwid.AttrMap(urwid.Text(self.footer_text), "foot")
328 self.screen = urwid.display.raw.Screen()
329 self.view = urwid.Frame(
330 urwid.AttrMap(
331 urwid.ScrollBar(
332 self.listbox,
333 thumb_char=urwid.ScrollBar.Symbols.FULL_BLOCK,
334 trough_char=urwid.ScrollBar.Symbols.LITE_SHADE,
335 ),
336 "body",
337 ),
338 header=urwid.AttrMap(self.header, "head"),
339 footer=self.footer,
340 )
341
342 def main(self) -> None:
343 """Run the program."""
344 self.loop = urwid.MainLoop(
345 self.view,
346 self.palette,
347 screen=self.screen,
348 unhandled_input=self.unhandled_input,
349 )
350 self.loop.run()
351
352 # on exit, write the flagged filenames to the console
353 names = [escape_filename_sh(x) for x in get_flagged_names()]
354 print(" ".join(names))
355
356 def unhandled_input(self, k: str | tuple[str, int, int, int]) -> None:
357 """Handle keys not consumed by the widgets.
358
359 :kbd:`c` copies the path in focus to the clipboard, and :kbd:`q` exits.
360
361 :param k: unhandled key or mouse event
362 :raises urwid.ExitMainLoop: when the user presses :kbd:`q`
363 """
364 # update display of focus directory
365 if k in {"q", "Q"}:
366 raise urwid.ExitMainLoop
367 if k in {"c", "C"} and isinstance(focus := self.listbox.focus, FlagFileWidget):
368 self.screen.copy_to_clipboard(focus.get_node().get_value())
369
370
371def main() -> None:
372 """Run the directory browser example program."""
373 DirectoryBrowser().main()
374
375
376#######
377# global cache of widgets
378_widget_cache: dict[str, DirectoryWidget | FileTreeWidget] = {}
379
380
381def add_widget(path: str, widget: DirectoryWidget | FileTreeWidget) -> None:
382 """Add the widget for a given path."""
383 _widget_cache[path] = widget
384
385
386def get_flagged_names() -> list[str]:
387 """Return a list of all filenames marked as flagged."""
388 names = [w.get_node().get_value() for w in _widget_cache.values() if w.flagged]
389 return names
390
391
392######
393# store path components of initial current working directory
394_initial_cwd: list[str] = []
395
396
397def store_initial_cwd(name: str) -> None:
398 """Store the initial current working directory path components."""
399 _initial_cwd.clear()
400 _initial_cwd.extend(name.split(dir_sep()))
401
402
403def starts_expanded(name: str) -> bool:
404 """Return True if directory is a parent of initial cwd."""
405 if name == "/":
406 return True
407
408 path_elements = name.split(dir_sep())
409 if len(path_elements) > len(_initial_cwd):
410 return False
411
412 return path_elements == _initial_cwd[: len(path_elements)]
413
414
415def escape_filename_sh(name: str) -> str:
416 """Return a hopefully safe shell-escaped version of a filename."""
417 # check whether we have unprintable characters
418 for ch in name:
419 if ord(ch) < 32:
420 # found one so use the ansi-c escaping
421 return escape_filename_sh_ansic(name)
422
423 # all printable characters, so return a double-quoted version
424 name = name.replace("\\", "\\\\").replace('"', '\\"').replace("`", "\\`").replace("$", "\\$")
425 return f'"{name}"'
426
427
428def escape_filename_sh_ansic(name: str) -> str:
429 """Return an ansi-c shell-escaped version of a filename."""
430 out = []
431 # gather the escaped characters into a list
432 for ch in name:
433 if ord(ch) < 32:
434 out.append(f"\\x{ord(ch):02x}")
435 elif ch == "\\":
436 out.append("\\\\")
437 else:
438 out.append(ch)
439
440 # slap them back together in an ansi-c quote $'...'
441 return f"$'{''.join(out)}'"
442
443
444SPLIT_RE = re.compile(r"[a-zA-Z]+|\d+")
445
446
447def alphabetize(s: str) -> list[tuple[str, int]]:
448 """Return a sort key that orders embedded numbers numerically rather than lexically.
449
450 :param s: string to build a sort key for
451 :returns: list of (lower-cased text, number) pairs usable as a sort key
452 """
453 listing: list[tuple[str, int]] = []
454 for isdigit, group in itertools.groupby(SPLIT_RE.findall(s), key=str.isdigit):
455 if isdigit:
456 listing.extend(("", int(n)) for n in group)
457 else:
458 listing.append(("".join(group).lower(), 0))
459 return listing
460
461
462def dir_sep() -> str:
463 """Return the separator used in this os."""
464 return getattr(os.path, "sep", "/")
465
466
467if __name__ == "__main__":
468 main()