browse.pyΒΆ

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