root/browse.py

Revision 332:08f97445caca, 17.3 KB (checked in by Ian Ward <ian@…>, 22 hours ago)

update examples for unhandled_input change

  • Property exe set to *
Line 
1#!/usr/bin/python
2#
3# Urwid example lazy directory browser / tree view
4#    Copyright (C) 2004-2009  Ian Ward
5#
6#    This library is free software; you can redistribute it and/or
7#    modify it under the terms of the GNU Lesser General Public
8#    License as published by the Free Software Foundation; either
9#    version 2.1 of the License, or (at your option) any later version.
10#
11#    This library is distributed in the hope that it will be useful,
12#    but WITHOUT ANY WARRANTY; without even the implied warranty of
13#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
14#    Lesser General Public License for more details.
15#
16#    You should have received a copy of the GNU Lesser General Public
17#    License along with this library; if not, write to the Free Software
18#    Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
19#
20# Urwid web site: http://excess.org/urwid/
21
22"""
23Urwid example lazy directory browser / tree view
24
25Features:
26- custom selectable widgets for files and directories
27- custom message widgets to identify access errors and empty directories
28- custom list walker for displaying widgets in a tree fashion
29- outputs a quoted list of files and directories "selected" on exit
30"""
31
32import os
33
34import urwid
35
36
37class TreeWidget(urwid.WidgetWrap):
38    """A widget representing something in the file tree."""
39    def __init__(self, dir, name, index, display):
40        self.dir = dir
41        self.name = name
42        self.index = index
43
44        parent, _ign = os.path.split(dir)
45        # we're at the top if parent is same as dir
46        if dir == parent:
47            self.depth = 0
48        else:
49            self.depth = dir.count(dir_sep())
50
51        widget = urwid.Text(["  "*self.depth, display])
52        self.widget = widget
53        w = urwid.AttrWrap(widget, None)
54        self.__super.__init__(w)
55        self.selected = False
56        self.update_w()
57       
58   
59    def selectable(self):
60        return True
61   
62    def keypress(self, size, key):
63        """Toggle selected on space, ignore other keys."""
64
65        if key == " ":
66            self.selected = not self.selected
67            self.update_w()
68        else:
69            return key
70
71    def update_w(self):
72        """
73        Update the attributes of wrapped widget based on self.selected.
74        """
75        if self.selected:
76            self._w.attr = 'selected'
77            self._w.focus_attr = 'selected focus'
78        else:
79            self._w.attr = 'body'
80            self._w.focus_attr = 'focus'
81       
82    def first_child(self):
83        """Default to have no children."""
84        return None
85   
86    def last_child(self):
87        """Default to have no children."""
88        return None
89   
90    def next_inorder(self):
91        """Return the next TreeWidget depth first from this one."""
92       
93        child = self.first_child()
94        if child: 
95            return child
96        else:
97            dir = get_directory(self.dir)
98            return dir.next_inorder_from(self.index)
99   
100    def prev_inorder(self):
101        """Return the previous TreeWidget depth first from this one."""
102       
103        dir = get_directory(self.dir)
104        return dir.prev_inorder_from(self.index)
105
106
107class EmptyWidget(TreeWidget):
108    """A marker for expanded directories with no contents."""
109
110    def __init__(self, dir, name, index):
111        self.__super.__init__(dir, name, index, 
112            ('flag',"(empty directory)"))
113   
114    def selectable(self):
115        return False
116   
117
118class ErrorWidget(TreeWidget):
119    """A marker for errors reading directories."""
120
121    def __init__(self, dir, name, index):
122        self.__super.__init__(dir, name, index, 
123            ('error',"(error/permission denied)"))
124   
125    def selectable(self):
126        return False
127
128class FileWidget(TreeWidget):
129    """Widget for a simple file (or link, device node, etc)."""
130   
131    def __init__(self, dir, name, index):
132        self.__super.__init__(dir, name, index, name)
133
134
135class DirectoryWidget(TreeWidget):
136    """Widget for a directory."""
137   
138    def __init__(self, dir, name, index):
139        self.__super.__init__(dir, name, index, "")
140       
141        # check if this directory starts expanded
142        self.expanded = starts_expanded(os.path.join(dir,name))
143       
144        self.update_widget()
145   
146    def update_widget(self):
147        """Update display widget text."""
148       
149        if self.expanded:
150            mark = "+"
151        else:
152            mark = "-"
153        self.widget.set_text(["  "*(self.depth),
154            ('dirmark', mark), " ", self.name])
155
156    def keypress(self, size, key):
157        """Handle expand & collapse requests."""
158       
159        if key in ("+", "right"):
160            self.expanded = True
161            self.update_widget()
162        elif key == "-":
163            self.expanded = False
164            self.update_widget()
165        else:
166            return self.__super.keypress(size, key)
167   
168    def mouse_event(self, size, event, button, col, row, focus):
169        if event != 'mouse press' or button!=1:
170            return False
171
172        if row == 0 and col == 2*self.depth:
173            self.expanded = not self.expanded
174            self.update_widget()
175            return True
176       
177        return False
178   
179    def first_child(self):
180        """Return first child if expanded."""
181       
182        if not self.expanded: 
183            return None
184        full_dir = os.path.join(self.dir, self.name)
185        dir = get_directory(full_dir)
186        return dir.get_first()
187   
188    def last_child(self):
189        """Return last child if expanded."""
190       
191        if not self.expanded:
192            return None
193        full_dir = os.path.join(self.dir, self.name)
194        dir = get_directory(full_dir)
195        widget = dir.get_last()
196        sub = widget.last_child()
197        if sub is not None:
198            return sub
199        return widget
200       
201       
202
203class Directory:
204    """Store sorted directory contents and cache TreeWidget objects."""
205   
206    def __init__(self, path):
207        self.path = path
208        self.widgets = {}
209
210        dirs = []
211        files = []
212        try:
213            # separate dirs and files
214            for a in os.listdir(path):
215                if os.path.isdir(os.path.join(path,a)):
216                    dirs.append(a)
217                else:
218                    files.append(a)
219        except OSError, e:
220            self.widgets[None] = ErrorWidget(self.path, None, 0)
221
222        # sort dirs and files
223        dirs.sort(sensible_cmp)
224        files.sort(sensible_cmp)
225        # store where the first file starts
226        self.dir_count = len(dirs)
227        # collect dirs and files together again
228        self.items = dirs + files
229
230        # if no items, put a dummy None item in the list
231        if not self.items:
232            self.items = [None]
233
234    def get_widget(self, name):
235        """Return the widget for a given file.  Create if necessary."""
236       
237        if self.widgets.has_key(name):
238            return self.widgets[name]
239       
240        # determine the correct TreeWidget type (constructor)
241        index = self.items.index(name)
242        if name is None:
243            constructor = EmptyWidget
244        elif index < self.dir_count:
245            constructor = DirectoryWidget
246        else:
247            constructor = FileWidget
248
249        widget = constructor(self.path, name, index)
250       
251        self.widgets[name] = widget
252        return widget
253
254       
255    def next_inorder_from(self, index):
256        """Return the TreeWidget following index depth first."""
257   
258        index += 1
259        # try to get the next item at same level
260        if index < len(self.items):
261            return self.get_widget(self.items[index])
262           
263        # need to go up a level
264        parent, myname = os.path.split(self.path)
265        # give up if we can't go higher
266        if parent == self.path: return None
267
268        # find my location in parent, and return next inorder
269        pdir = get_directory(parent)
270        mywidget = pdir.get_widget(myname)
271        return pdir.next_inorder_from(mywidget.index)
272       
273    def prev_inorder_from(self, index):
274        """Return the TreeWidget preceeding index depth first."""
275       
276        index -= 1
277        if index >= 0:
278            widget = self.get_widget(self.items[index])
279            widget_child = widget.last_child()
280            if widget_child: 
281                return widget_child
282            else:
283                return widget
284
285        # need to go up a level
286        parent, myname = os.path.split(self.path)
287        # give up if we can't go higher
288        if parent == self.path: return None
289
290        # find myself in parent, and return
291        pdir = get_directory(parent)
292        return pdir.get_widget(myname)
293
294    def get_first(self):
295        """Return the first TreeWidget in the directory."""
296       
297        return self.get_widget(self.items[0])
298   
299    def get_last(self):
300        """Return the last TreeWIdget in the directory."""
301       
302        return self.get_widget(self.items[-1])
303       
304
305
306class DirectoryWalker(urwid.ListWalker):
307    """ListWalker-compatible class for browsing directories.
308   
309    positions used are directory,filename tuples."""
310   
311    def __init__(self, start_from, new_focus_callback):
312        parent = start_from
313        dir = get_directory(parent)
314        widget = dir.get_first()
315        self.focus = parent, widget.name
316        self._new_focus_callback = new_focus_callback
317        new_focus_callback(self.focus)
318
319    def get_focus(self):
320        parent, name = self.focus
321        dir = get_directory(parent)
322        widget = dir.get_widget(name)
323        return widget, self.focus
324       
325    def set_focus(self, focus):
326        parent, name = focus
327        self._new_focus_callback(focus)
328        self.focus = parent, name
329        self._modified()
330   
331    def get_next(self, start_from):
332        parent, name = start_from
333        dir = get_directory(parent)
334        widget = dir.get_widget(name)
335        target = widget.next_inorder()
336        if target is None:
337            return None, None
338        return target, (target.dir, target.name)
339
340    def get_prev(self, start_from):
341        parent, name = start_from
342        dir = get_directory(parent)
343        widget = dir.get_widget(name)
344        target = widget.prev_inorder()
345        if target is None:
346            return None, None
347        return target, (target.dir, target.name)
348               
349
350       
351class DirectoryBrowser:
352    palette = [
353        ('body', 'black', 'light gray'),
354        ('selected', 'black', 'dark green', ('bold','underline')),
355        ('focus', 'light gray', 'dark blue', 'standout'),
356        ('selected focus', 'yellow', 'dark cyan', 
357                ('bold','standout','underline')),
358        ('head', 'yellow', 'black', 'standout'),
359        ('foot', 'light gray', 'black'),
360        ('key', 'light cyan', 'black','underline'),
361        ('title', 'white', 'black', 'bold'),
362        ('dirmark', 'black', 'dark cyan', 'bold'),
363        ('flag', 'dark gray', 'light gray'),
364        ('error', 'dark red', 'light gray'),
365        ]
366   
367    footer_text = [
368        ('title', "Directory Browser"), "    ",
369        ('key', "UP"), ",", ('key', "DOWN"), ",",
370        ('key', "PAGE UP"), ",", ('key', "PAGE DOWN"),
371        "  ",
372        ('key', "SPACE"), "  ",
373        ('key', "+"), ",",
374        ('key', "-"), "  ",
375        ('key', "LEFT"), "  ",
376        ('key', "HOME"), "  ", 
377        ('key', "END"), "  ",
378        ('key', "Q"),
379        ]
380   
381   
382    def __init__(self):
383        cwd = os.getcwd()
384        store_initial_cwd(cwd)
385        self.header = urwid.Text("")
386        self.listbox = urwid.ListBox(DirectoryWalker(cwd, self.show_focus))
387        self.listbox.offset_rows = 1
388        self.footer = urwid.AttrWrap(urwid.Text(self.footer_text),
389            'foot')
390        self.view = urwid.Frame(
391            urwid.AttrWrap(self.listbox, 'body'), 
392            header=urwid.AttrWrap(self.header, 'head'), 
393            footer=self.footer)
394
395    def show_focus(self, focus):
396        parent, ignore = focus
397        self.header.set_text(parent)
398
399    def main(self):
400        """Run the program."""
401       
402        self.loop = urwid.MainLoop(self.view, self.palette,
403            unhandled_input=self.unhandled_input)
404        self.loop.run()
405   
406        # on exit, write the selected filenames to the console
407        names = [escape_filename_sh(x) for x in get_selected_names()]
408        print " ".join(names)
409
410    def unhandled_input(self, k):
411            # update display of focus directory
412            if k in ('q','Q'):
413                raise urwid.ExitMainLoop()
414            elif k == 'left':
415                self.move_focus_to_parent()
416            elif k == '-':
417                self.collapse_focus_parent()
418            elif k == 'home':
419                self.focus_home()
420            elif k == 'end':
421                self.focus_end()
422            else:
423                return
424            return True
425                   
426    def collapse_focus_parent(self):
427        """Collapse parent directory."""
428       
429        widget, pos = self.listbox.body.get_focus()
430        self.move_focus_to_parent()
431       
432        pwidget, ppos = self.listbox.body.get_focus()
433        if widget.dir != pwidget.dir:
434            self.loop.process_input(["-"])
435
436    def move_focus_to_parent(self):
437        """Move focus to parent of widget in focus."""
438        focus_widget, position = self.listbox.get_focus()
439        parent, name = os.path.split(focus_widget.dir)
440       
441        if parent == focus_widget.dir:
442            # no root dir, choose first element instead
443            self.focus_home()
444            return
445       
446        self.listbox.set_focus((parent, name), 'below')
447        return 
448       
449    def focus_home(self):
450        """Move focus to very top."""
451       
452        dir = get_directory("/")
453        widget = dir.get_first()
454        parent, name = widget.dir, widget.name
455        self.listbox.set_focus((parent, name), 'below')
456
457    def focus_end(self):
458        """Move focus to far bottom."""
459       
460        dir = get_directory("/")
461        widget = dir.get_last()
462        parent, name = widget.dir, widget.name
463        self.listbox.set_focus((parent, name), 'above')
464
465
466
467
468
469
470def main():
471    DirectoryBrowser().main()
472
473
474
475
476#######
477# global cache of directory information
478_dir_cache = {}
479
480def get_directory(name):
481    """Return the Directory object for a given path.  Create if necessary."""
482   
483    if not _dir_cache.has_key(name):
484        _dir_cache[name] = Directory(name)
485    return _dir_cache[name]
486
487def directory_cached(name):
488    """Return whether the directory is in the cache."""
489   
490    return _dir_cache.has_key(name)
491
492def get_selected_names():
493    """Return a list of all filenames marked as selected."""
494   
495    l = []
496    for d in _dir_cache.values():
497        for w in d.widgets.values():
498            if w.selected:
499                l.append(os.path.join(w.dir, w.name))
500    return l
501           
502
503
504######
505# store path components of initial current working directory
506_initial_cwd = []
507
508def store_initial_cwd(name):
509    """Store the initial current working directory path components."""
510   
511    global _initial_cwd
512    _initial_cwd = name.split(dir_sep())
513
514def starts_expanded(name):
515    """Return True if directory is a parent of initial cwd."""
516   
517    l = name.split(dir_sep())
518    if len(l) > len(_initial_cwd):
519        return False
520   
521    if l != _initial_cwd[:len(l)]:
522        return False
523   
524    return True
525
526
527def escape_filename_sh(name):
528    """Return a hopefully safe shell-escaped version of a filename."""
529
530    # check whether we have unprintable characters
531    for ch in name: 
532        if ord(ch) < 32: 
533            # found one so use the ansi-c escaping
534            return escape_filename_sh_ansic(name)
535           
536    # all printable characters, so return a double-quoted version
537    name.replace('\\','\\\\')
538    name.replace('"','\\"')
539    name.replace('`','\\`')
540    name.replace('$','\\$')
541    return '"'+name+'"'
542
543
544def escape_filename_sh_ansic(name):
545    """Return an ansi-c shell-escaped version of a filename."""
546   
547    out =[]
548    # gather the escaped characters into a list
549    for ch in name:
550        if ord(ch) < 32:
551            out.append("\\x%02x"% ord(ch))
552        elif ch == '\\':
553            out.append('\\\\')
554        else:
555            out.append(ch)
556           
557    # slap them back together in an ansi-c quote  $'...'
558    return "$'" + "".join(out) + "'"
559
560
561def sensible_cmp(name_a, name_b):
562    """Case insensitive compare with sensible numeric ordering.
563   
564    "blah7" < "BLAH08" < "blah9" < "blah10" """
565   
566    # ai, bi are indexes into name_a, name_b
567    ai = bi = 0
568   
569    def next_atom(name, i):
570        """Return the next 'atom' and the next index.
571       
572        An 'atom' is either a nonnegative integer or an uppercased
573        character used for defining sort order."""
574       
575        a = name[i].upper()
576        i += 1
577        if a.isdigit():
578            while i < len(name) and name[i].isdigit():
579                a += name[i]
580                i += 1
581            a = long(a)
582        return a, i
583       
584    # compare one atom at a time
585    while ai < len(name_a) and bi < len(name_b):
586        a, ai = next_atom(name_a, ai)
587        b, bi = next_atom(name_b, bi)
588        if a < b: return -1
589        if a > b: return 1
590   
591    # if all out of atoms to compare, do a regular cmp
592    if ai == len(name_a) and bi == len(name_b): 
593        return cmp(name_a,name_b)
594   
595    # the shorter one comes first
596    if ai == len(name_a): return -1
597    return 1
598
599
600def dir_sep():
601    """Return the separator used in this os."""
602    return getattr(os.path,'sep','/')
603
604
605if __name__=="__main__": 
606    main()
607       
Note: See TracBrowser for help on using the browser.