3
# Urwid example lazy directory browser / tree view
4
# Copyright (C) 2004-2009 Ian Ward
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.
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.
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
20
# Urwid web site: http://excess.org/urwid/
23
Urwid example lazy directory browser / tree view
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
37
class TreeWidget(urwid.WidgetWrap):
38
"""A widget representing something in the file tree."""
39
def __init__(self, dir, name, index, display):
44
parent, _ign = os.path.split(dir)
45
# we're at the top if parent is same as dir
49
self.depth = dir.count(dir_sep())
51
widget = urwid.Text([" "*self.depth, display])
53
w = urwid.AttrWrap(widget, None)
54
self.__super.__init__(w)
62
def keypress(self, size, key):
63
"""Toggle selected on space, ignore other keys."""
66
self.selected = not self.selected
73
Update the attributes of wrapped widget based on self.selected.
76
self._w.attr = 'selected'
77
self._w.focus_attr = 'selected focus'
80
self._w.focus_attr = 'focus'
82
def first_child(self):
83
"""Default to have no children."""
87
"""Default to have no children."""
90
def next_inorder(self):
91
"""Return the next TreeWidget depth first from this one."""
93
# If I am a directory, the next element is always my first child
94
# IF EXPANDED. Otherwise self.first_child() would return None.
96
child = self.first_child()
100
dir = get_directory(self.dir)
101
return dir.next_inorder_from(self.index)
103
def prev_inorder(self):
104
"""Return the previous TreeWidget depth first from this one."""
106
dir = get_directory(self.dir)
107
return dir.prev_inorder_from(self.index)
110
class EmptyWidget(TreeWidget):
111
"""A marker for expanded directories with no contents."""
113
def __init__(self, dir, name, index):
114
self.__super.__init__(dir, name, index,
115
('flag',"(empty directory)"))
117
def selectable(self):
121
class ErrorWidget(TreeWidget):
122
"""A marker for errors reading directories."""
124
def __init__(self, dir, name, index):
125
self.__super.__init__(dir, name, index,
126
('error',"(error/permission denied)"))
128
def selectable(self):
131
class FileWidget(TreeWidget):
132
"""Widget for a simple file (or link, device node, etc)."""
134
def __init__(self, dir, name, index):
135
self.__super.__init__(dir, name, index, name)
138
class DirectoryWidget(TreeWidget):
139
"""Widget for a directory."""
141
def __init__(self, dir, name, index):
142
self.__super.__init__(dir, name, index, "")
144
# check if this directory starts expanded
145
self.expanded = starts_expanded(os.path.join(dir,name))
149
def update_widget(self):
150
"""Update display widget text."""
156
self.widget.set_text([" "*(self.depth),
157
('dirmark', mark), " ", self.name])
159
def keypress(self, size, key):
160
"""Handle expand & collapse requests."""
162
if key in ("+", "right"):
166
self.expanded = False
169
return self.__super.keypress(size, key)
171
def mouse_event(self, size, event, button, col, row, focus):
172
if event != 'mouse press' or button!=1:
175
if row == 0 and col == 2*self.depth:
176
self.expanded = not self.expanded
182
def first_child(self):
183
"""Return first child if expanded."""
185
if not self.expanded:
187
full_dir = os.path.join(self.dir, self.name)
188
dir = get_directory(full_dir)
189
return dir.get_first()
191
def last_child(self):
192
"""Return last child if expanded."""
194
if not self.expanded:
196
full_dir = os.path.join(self.dir, self.name)
197
dir = get_directory(full_dir)
198
widget = dir.get_last()
199
sub = widget.last_child()
207
"""Store sorted directory contents and cache TreeWidget objects."""
208
def __init__(self, path):
215
# separate dirs and files
216
for a in os.listdir(path):
217
if os.path.isdir(os.path.join(path,a)):
222
self.widgets[None] = ErrorWidget(self.path, None, 0)
224
# sort dirs and files
225
dirs.sort(sensible_cmp)
226
files.sort(sensible_cmp)
227
# store where the first file starts
228
self.dir_count = len(dirs)
229
# collect dirs and files together again
230
self.items = dirs + files
232
# if no items, put a dummy None item in the list
236
def get_widget(self, name):
237
"""Return the widget for a given file. Create if necessary."""
239
if self.widgets.has_key(name):
240
return self.widgets[name]
242
# determine the correct TreeWidget type (constructor)
243
index = self.items.index(name)
245
constructor = EmptyWidget
246
elif index < self.dir_count:
247
constructor = DirectoryWidget
249
constructor = FileWidget
251
widget = constructor(self.path, name, index)
253
self.widgets[name] = widget
257
def next_inorder_from(self, index):
258
"""Return the TreeWidget following index depth first."""
261
# try to get the next item at same level
262
if index < len(self.items):
263
return self.get_widget(self.items[index])
265
# need to go up a level
266
parent, myname = os.path.split(self.path)
267
# give up if we can't go higher
268
if parent == self.path: return None
270
# find my location in parent, and return next inorder
271
pdir = get_directory(parent)
272
mywidget = pdir.get_widget(myname)
273
return pdir.next_inorder_from(mywidget.index)
275
def prev_inorder_from(self, index):
276
"""Return the TreeWidget preceeding index depth first."""
280
widget = self.get_widget(self.items[index])
281
widget_child = widget.last_child()
287
# need to go up a level
288
parent, myname = os.path.split(self.path)
289
# give up if we can't go higher
290
if parent == self.path: return None
292
# find myself in parent, and return
293
pdir = get_directory(parent)
294
return pdir.get_widget(myname)
297
"""Return the first TreeWidget in the directory."""
299
return self.get_widget(self.items[0])
302
"""Return the last TreeWIdget in the directory."""
304
return self.get_widget(self.items[-1])
308
class DirectoryWalker(urwid.ListWalker):
309
"""ListWalker-compatible class for browsing directories.
311
positions used are directory,filename tuples."""
313
def __init__(self, start_from, new_focus_callback):
316
# uses _dir_cache global variable. It stores instances of Directory
317
dir = get_directory(parent)
318
# this simple method will start a chain reaction creating the whole
319
# directory structure as a WidgetTree. Jump to Directory.get_first() for more.
320
widget = dir.get_first()
321
self.focus = parent, widget.name
323
# this callback will be called each time focus changes
324
# (in set_focus) so that DirectoryBrowser updates its
325
# header via show_focus.
326
self._new_focus_callback = new_focus_callback
327
new_focus_callback(self.focus)
330
parent, name = self.focus
331
dir = get_directory(parent)
332
widget = dir.get_widget(name)
333
return widget, self.focus
335
def set_focus(self, focus):
337
self._new_focus_callback(focus)
338
self.focus = parent, name
341
def get_next(self, start_from):
342
parent, name = start_from
343
dir = get_directory(parent)
344
widget = dir.get_widget(name)
345
target = widget.next_inorder()
348
return target, (target.dir, target.name)
350
def get_prev(self, start_from):
351
parent, name = start_from
352
dir = get_directory(parent)
353
widget = dir.get_widget(name)
354
target = widget.prev_inorder()
357
return target, (target.dir, target.name)
361
class DirectoryBrowser:
363
('body', 'black', 'light gray'),
364
('selected', 'black', 'dark green', ('bold','underline')),
365
('focus', 'light gray', 'dark blue', 'standout'),
366
('selected focus', 'yellow', 'dark cyan',
367
('bold','standout','underline')),
368
('head', 'yellow', 'black', 'standout'),
369
('foot', 'light gray', 'black'),
370
('key', 'light cyan', 'black','underline'),
371
('title', 'white', 'black', 'bold'),
372
('dirmark', 'black', 'dark cyan', 'bold'),
373
('flag', 'dark gray', 'light gray'),
374
('error', 'dark red', 'light gray'),
378
('title', "Directory Browser"), " ",
379
('key', "UP"), ",", ('key', "DOWN"), ",",
380
('key', "PAGE UP"), ",", ('key', "PAGE DOWN"),
382
('key', "SPACE"), " ",
385
('key', "LEFT"), " ",
386
('key', "HOME"), " ",
395
# initializes _init_cwd, which is used by starts_expanded.
396
# starts_expanded is used by the DirectoryWidget constructor
397
# a DirectoryWidget is created by the Directory.get_widget method
398
# Directory.get_widget uses an internal cache and stores widgets
399
# for every file or directory it is asked for
400
store_initial_cwd(cwd)
402
self.header = urwid.Text("")
403
# DirectoryWalker is responsable for filling up our ListBox
404
self.listbox = urwid.ListBox(DirectoryWalker(cwd, self.show_focus))
405
self.listbox.offset_rows = 1
406
self.footer = urwid.AttrWrap(urwid.Text(self.footer_text),
408
self.view = urwid.Frame(
409
urwid.AttrWrap(self.listbox, 'body'),
410
header=urwid.AttrWrap(self.header, 'head'),
413
def show_focus(self, focus):
414
parent, ignore = focus
415
self.header.set_text(parent)
418
"""Run the program."""
420
self.loop = urwid.MainLoop(self.view, self.palette,
421
unhandled_input=self.unhandled_input)
424
# on exit, write the selected filenames to the console
425
# (yassine) this works with global variable _dir_cache.
426
# it looks for each widget stored inside _dir_cache
427
# that has selected flag raised.
429
names = [escape_filename_sh(x) for x in get_selected_names()]
430
print " ".join(names)
432
def unhandled_input(self, k):
433
# update display of focus directory
435
raise urwid.ExitMainLoop()
437
self.move_focus_to_parent()
439
self.collapse_focus_parent()
445
def collapse_focus_parent(self):
446
"""Collapse parent directory."""
448
widget, pos = self.listbox.body.get_focus()
449
self.move_focus_to_parent()
451
pwidget, ppos = self.listbox.body.get_focus()
452
if widget.dir != pwidget.dir:
453
self.loop.process_input(["-"])
455
def move_focus_to_parent(self):
456
"""Move focus to parent of widget in focus."""
457
focus_widget, position = self.listbox.get_focus()
458
parent, name = os.path.split(focus_widget.dir)
460
if parent == focus_widget.dir:
461
# no root dir, choose first element instead
465
self.listbox.set_focus((parent, name), 'below')
468
def focus_home(self):
469
"""Move focus to very top."""
471
dir = get_directory("/")
472
widget = dir.get_first()
473
parent, name = widget.dir, widget.name
474
self.listbox.set_focus((parent, name), 'below')
477
"""Move focus to far bottom."""
479
dir = get_directory("/")
480
widget = dir.get_last()
481
parent, name = widget.dir, widget.name
482
self.listbox.set_focus((parent, name), 'above')
486
DirectoryBrowser().main()
492
# global cache of directory information
495
def get_directory(name):
496
"""Return the Directory object for a given path. Create if necessary."""
498
if not _dir_cache.has_key(name):
500
# the creation of a Directory does nothing in particular
501
# it only stores names. The widgets are created just in time.
502
# For example, first widgets are created when calling dir.get_first()
503
# inside DirectoryWalker.__init__. The mere creation of Dictionary
504
# doesn't create the widgets. Methods like Directory.get_first do
505
# (via get_widget). This is why it is considered to be a 'lazy' browser.
506
# get_widget creates the widget if necessary (uses internal cache).
507
_dir_cache[name] = Directory(name)
508
return _dir_cache[name]
510
def directory_cached(name):
511
"""Return whether the directory is in the cache."""
513
return _dir_cache.has_key(name)
515
def get_selected_names():
516
"""Return a list of all filenames marked as selected."""
519
for d in _dir_cache.values():
520
for w in d.widgets.values():
522
l.append(os.path.join(w.dir, w.name))
528
# store path components of initial current working directory
531
def store_initial_cwd(name):
532
"""Store the initial current working directory path components."""
535
_initial_cwd = name.split(dir_sep())
537
def starts_expanded(name):
538
"""Return True if directory is a parent of initial cwd."""
540
l = name.split(dir_sep())
541
if len(l) > len(_initial_cwd):
544
if l != _initial_cwd[:len(l)]:
550
def escape_filename_sh(name):
551
"""Return a hopefully safe shell-escaped version of a filename."""
553
# check whether we have unprintable characters
556
# found one so use the ansi-c escaping
557
return escape_filename_sh_ansic(name)
559
# all printable characters, so return a double-quoted version
560
name.replace('\\','\\\\')
561
name.replace('"','\\"')
562
name.replace('`','\\`')
563
name.replace('$','\\$')
567
def escape_filename_sh_ansic(name):
568
"""Return an ansi-c shell-escaped version of a filename."""
571
# gather the escaped characters into a list
574
out.append("\\x%02x"% ord(ch))
580
# slap them back together in an ansi-c quote $'...'
581
return "$'" + "".join(out) + "'"
584
def sensible_cmp(name_a, name_b):
585
"""Case insensitive compare with sensible numeric ordering.
587
"blah7" < "BLAH08" < "blah9" < "blah10" """
589
# ai, bi are indexes into name_a, name_b
592
def next_atom(name, i):
593
"""Return the next 'atom' and the next index.
595
An 'atom' is either a nonnegative integer or an uppercased
596
character used for defining sort order."""
601
while i < len(name) and name[i].isdigit():
607
# compare one atom at a time
608
while ai < len(name_a) and bi < len(name_b):
609
a, ai = next_atom(name_a, ai)
610
b, bi = next_atom(name_b, bi)
614
# if all out of atoms to compare, do a regular cmp
615
if ai == len(name_a) and bi == len(name_b):
616
return cmp(name_a,name_b)
618
# the shorter one comes first
619
if ai == len(name_a): return -1
624
"""Return the separator used in this os."""
625
return getattr(os.path,'sep','/')
628
if __name__=="__main__":