Changed matching implementation to work with more types of argument.
[rox-shell.git] / rox / shell / shell.py
blob2f9a5c37665ebd0566841f364f078678cff9d83f
1 """
2 @copyright: (C) 2008, Thomas Leonard
3 @see: U{http://roscidus.com}
4 """
5 import os, sys, fnmatch
6 from zeroinstall.support import tasks # tmp
8 import _gio as gio
9 import gobject
10 import gtk
11 from gtk import keysyms
12 import vte
14 import commands
16 icon_size = 48
18 gtk_theme = gtk.icon_theme_get_default()
20 class Warning(Exception):
21 pass
23 def get_themed_icon(name, icon_size):
24 try:
25 return gtk_theme.load_icon('text-x-generic', icon_size, 0)
26 except gobject.GError:
27 return None
29 icon_text_plain = get_themed_icon('text-x-generic', icon_size)
30 icon_dir = get_themed_icon('folder', icon_size)
32 RETURN_KEYS = (keysyms.Return, keysyms.KP_Enter, keysyms.ISO_Enter)
34 class DirUpdated(tasks.Blocker):
35 changes = []
37 class ShellController:
38 def __init__(self, file):
39 self.file = file
40 self.updated = None
41 self.build_contents()
43 def build_contents(self):
44 contents = {}
45 e = self.file.enumerate_children('standard::*', 0)
46 if e:
47 while True:
48 info = e.next_file()
49 if not info:
50 break
51 contents[info.get_name()] = info
52 self.contents = contents
54 if self.updated:
55 self.updated.trigger()
56 self.updated = DirUpdated("Updates for " + self.file.get_uri())
58 def item_activated(self, name):
59 item_info = self.contents[name]
60 child = self.file.get_child(name)
62 if item_info.get_file_type() == gio.FILE_TYPE_DIRECTORY:
63 self.file = child
64 self.build_contents()
65 else:
66 self.view.run_in_terminal(['cat', child.get_path()], self.file.get_path())
68 def cd_parent(self):
69 parent = self.file.get_parent()
70 if parent:
71 self.file = parent
72 self.build_contents()
74 def run_command(self, args):
75 argv = []
76 for a in args:
77 argv += a.expand_to_argv()
79 builtin = commands.builtin_commands.get(argv[0], None)
80 if builtin:
81 builtin(self, argv[1:])
82 else:
83 self.view.run_in_terminal(argv, self.file.get_path())
85 def gtk_tree_model(contents):
86 tm = gtk.ListStore(str, gtk.gdk.Pixbuf)
87 tm.set_sort_column_id(0, gtk.SORT_ASCENDING)
88 for name, info in contents.iteritems():
89 if not name.startswith('.'):
90 new = tm.append(None)
91 pixbuf = None
92 icon = info.get_icon()
93 if icon:
94 gtkicon_info = gtk_theme.choose_icon(icon.get_names(), icon_size, 0)
95 if gtkicon_info:
96 gtkicon_info.get_filename()
97 pixbuf = gtkicon_info.load_icon()
99 tm[new] = [name, pixbuf]
100 return tm
102 FILER_PAGE = 0
103 TERMINAL_PAGE = 1
105 class BaseArgument:
106 def __init__(self, view):
107 self.view = view
109 def get_entry_text(self):
110 return ''
112 def get_button_label(self):
113 return '?'
115 def entry_changed(self, entry):
116 return
118 def finish_edit(self):
119 pass
121 def validate(self):
122 pass
124 class Argument(BaseArgument):
125 """Represents a word entered by the user for a command."""
126 # This is a bit complicated. An argument can be any of these:
127 # - A glob pattern matching multiple filenames (*.html)
128 # - A single filename (index.html)
129 # - A set of files selected manually (a.html, b.html)
130 # - A quoted string ('*.html')
131 # - An option (--index)
132 def __init__(self, view):
133 BaseArgument.__init__(self, view)
134 self.value = ''
135 self.type = 'empty'
137 def type_from_value(self, value):
138 if not value:
139 return 'empty'
140 elif value.startswith("'"):
141 return 'quoted'
142 elif value.startswith("-"):
143 return 'option'
144 for x in value:
145 if x in '*?[':
146 return 'glob'
147 return 'filename'
149 def entry_changed(self, entry):
150 self.value = entry.get_text()
151 self.type = self.type_from_value(self.value)
153 if self.type not in ('filename', 'glob'):
154 return
156 iv = self.view.iv
157 model = iv.get_model()
159 cursor_path = (self.view.iv.get_cursor() or (None, None))[0]
160 if cursor_path:
161 cursor_filename = model[model.get_iter(cursor_path)][0]
162 else:
163 cursor_filename = None
165 # If the user only entered lower-case letters do a case insensitive match
166 if self.type == 'filename':
167 # Rules are:
168 # - Select any exact match
169 # - Else, select any exact case-insensitive match
170 # - Else, select the cursor item if the prefix matches
171 # - Else, select the first prefix match
172 # - Else, select nothing
174 if self.value in self.view.shell.contents:
175 case_insensitive = False # Exact match available
176 else:
177 case_insensitive = (self.value == self.value.lower())
178 exact_match = None
179 prefix_match = None
180 for i, row in self.view.iter_contents():
181 name = row[0]
182 if case_insensitive:
183 name = name.lower()
184 if name == self.value:
185 exact_match = model.get_path(i)
186 break
187 if name.startswith(self.value) and not prefix_match:
188 prefix_match = model.get_path(i)
189 if case_insensitive:
190 cursor_filename = cursor_filename.lower()
191 if exact_match:
192 to_select = [exact_match]
193 elif cursor_filename.startswith(self.value):
194 to_select = [cursor_path]
195 elif prefix_match:
196 to_select = [prefix_match]
197 else:
198 to_select = []
199 elif self.type == 'glob':
200 to_select = []
201 pattern = self.value
202 def match(m, path, iter):
203 name = m[iter][0]
204 if fnmatch.fnmatch(name, pattern):
205 to_select.append(path)
206 model.foreach(match)
208 iv.unselect_all()
209 if to_select:
210 for path in to_select:
211 iv.select_path(path)
212 if cursor_path not in to_select:
213 iv.set_cursor(to_select[0])
215 def finish_edit(self):
216 iv = self.view.iv
217 model = iv.get_model()
218 cursor_path = (iv.get_cursor() or (None, None))[0]
219 selected = iv.get_selected_items()
221 if cursor_path and selected and cursor_path not in selected:
222 raise Warning("Cursor not in selection!")
223 if cursor_path and not selected:
224 selected = [cursor_path]
226 if self.type == 'empty':
227 if not selected:
228 raise Warning("No selection and no cursor item!")
229 if len(selected) > 1:
230 raise Warning("Multiple selection!")
231 path = selected[0]
233 self.type = 'filename'
234 self.selected_item = model[model.get_iter(path)][0]
235 if self.type == 'filename':
236 self.selected_filename = None
238 if len(selected) != 1:
239 raise Warning("Must be one selected item!")
240 # The cursor must be on the single selected item
241 selected_name = model[model.get_iter(selected[0])][0]
242 # Selected item must match text
243 if selected_name.lower().startswith(self.value):
244 self.selected_filename = selected_name
245 else:
246 raise Warning("Selected item does not match entered text!")
248 def get_entry_text(self):
249 return self.value
251 def get_button_label(self):
252 if self.type == 'filename':
253 return self.selected_filename or '(none)'
254 return self.value
256 def expand_to_argv(self):
257 if self.type == 'empty':
258 raise Warning("Empty argument")
259 if self.type == 'filename':
260 if self.selected_filename is None:
261 raise Warning("No filename selected")
262 return [self.selected_filename]
263 elif self.type == 'glob':
264 pattern = self.value
265 matches = [row[0] for i, row in self.view.iter_contents() if fnmatch.fnmatch(row[0], pattern)]
266 if not matches:
267 raise Warning("Nothing matches '%s'!" % pattern)
268 return matches
269 else:
270 # TODO: expand
271 return [self.value]
273 class CommandArgument(BaseArgument):
274 command = ''
276 def entry_changed(self, entry):
277 self.command = entry.get_text() or None
279 def get_button_label(self):
280 return self.command if self.command else "Open"
282 def expand_to_argv(self):
283 return [self.command or 'rox:open']
285 class ArgvView:
286 def __init__(self, hbox):
287 self.hbox = hbox
288 self.args = []
289 self.widgets = []
291 def set_args(self, args):
292 self.args = args
293 self.edit_arg = self.args[-1]
294 self.build()
296 def build(self):
297 for w in self.widgets:
298 w.destroy()
299 self.widgets = []
300 self.active_entry = None
302 for x in self.args:
303 if x is self.edit_arg:
304 arg = gtk.Entry()
305 arg.set_text(x.get_entry_text())
306 arg.connect('changed', x.entry_changed)
307 self.active_entry = arg
308 else:
309 arg = gtk.Button(x.get_button_label())
310 arg.set_relief(gtk.RELIEF_NONE)
311 arg.connect('clicked', lambda b, x = x: self.activate(x))
312 arg.show()
313 self.hbox.pack_start(arg, False, True, 0)
314 self.widgets.append(arg)
316 def activate(self, x):
317 """Start editing argument 'x'"""
318 if x is self.edit_arg:
319 return
320 try:
321 self.edit_arg.finish_edit()
322 except Warning, ex:
323 self.edit_arg.view.warning(str(ex))
324 self.edit_arg = x
325 self.build()
326 i = self.args.index(x)
327 self.widgets[i].grab_focus()
329 def key_press_event(self, kev):
330 if not self.active_entry:
331 return False
332 old_text = self.active_entry.get_text()
333 self.active_entry.grab_focus() # Otherwise it selects the added text
334 self.active_entry.event(kev)
335 return self.active_entry.get_text() != old_text
337 def finish_edit(self):
338 self.edit_arg.finish_edit()
340 class ShellView:
341 terminal = None
342 user_seen_terminal_contents = False
343 warning_timeout = None
345 def __init__(self, shell):
346 self.shell = shell
347 shell.view = self
349 builder = gtk.Builder()
350 builder.add_from_file(os.path.join(os.path.dirname(__file__), "ui.xml"))
351 self.window = builder.get_object('directory')
353 cd_parent = builder.get_object('cd-parent')
354 cd_parent.connect('activate', lambda a: self.shell.cd_parent())
356 # Must show window before adding icons, or we randomly get:
357 # The error was 'BadAlloc (insufficient resources for operation)'
358 self.window.show()
360 self.window_destroyed = tasks.Blocker('Window destroyed')
361 self.window.connect('destroy', lambda w: self.window_destroyed.trigger())
363 self.window.connect('key-press-event', self.key_press_event)
365 self.iv = builder.get_object('iconview')
366 self.iv.set_text_column(0)
367 self.iv.set_pixbuf_column(1)
368 self.iv.set_selection_mode(gtk.SELECTION_MULTIPLE)
370 self.iv.connect('item-activated', self.item_activated)
372 command_area = builder.get_object('command')
373 self.command_argv = ArgvView(command_area)
374 self.reset()
376 self.notebook = builder.get_object('notebook')
377 self.status_msg = builder.get_object('status_msg')
379 self.window.show_all()
381 def iter_contents(self):
382 m = self.iv.get_model()
383 i = m.get_iter_root()
384 while i:
385 yield i, m[i]
386 i = m.iter_next(i)
388 def warning(self, msg):
389 def hide_warning():
390 self.status_msg.set_text('')
391 return False
392 if self.warning_timeout is not None:
393 gobject.source_remove(self.warning_timeout)
394 self.status_msg.set_text(msg)
395 self.warning_timeout = gobject.timeout_add(2000, hide_warning)
397 def show_terminal(self):
398 if not self.terminal:
399 def terminal_contents_changed(vte):
400 self.user_seen_terminal_contents = False
402 def terminal_child_exited():
403 if self.user_seen_terminal_contents:
404 self.notebook.set_current_page(FILER_PAGE)
405 else:
406 self.terminal.feed('\r\nProcess complete. Press Return to return to filer view.\r\n')
407 self.waiting_for_return = True
408 return False
410 self.terminal = vte.Terminal()
411 self.terminal.connect('contents-changed', terminal_contents_changed)
412 self.terminal.connect('child-exited', lambda vte: gobject.timeout_add(100, terminal_child_exited))
413 self.terminal.show()
415 self.notebook.add(self.terminal)
416 self.notebook.set_current_page(TERMINAL_PAGE)
417 self.waiting_for_return = False
419 def reset(self):
420 self.command_argv.set_args([CommandArgument(self), Argument(self)])
421 if self.terminal is None or not (self.terminal.flags() & gtk.IS_VISIBLE):
422 self.iv.grab_focus()
423 self.iv.unselect_all()
425 def key_press_event(self, window, kev):
426 if self.terminal and self.terminal.flags() & gtk.HAS_FOCUS:
427 if kev.keyval in RETURN_KEYS and self.waiting_for_return:
428 self.notebook.set_current_page(FILER_PAGE)
429 return True
430 self.user_seen_terminal_contents = True
431 return False
433 if kev.keyval == keysyms.Escape:
434 self.reset()
435 return True
436 elif kev.keyval in RETURN_KEYS:
437 try:
438 self.command_argv.finish_edit()
439 self.shell.run_command(self.command_argv.args)
440 except Warning, ex:
441 self.warning(str(ex))
442 else:
443 self.reset()
444 return True
446 # Are we ready for special characters?
447 if self.command_argv.active_entry and self.command_argv.active_entry.flags() & gtk.HAS_FOCUS:
448 accept_special = True # TODO: check cursor is at end
449 else:
450 accept_special = True
452 if accept_special:
453 if kev.keyval == keysyms.comma:
454 self.command_argv.activate(self.command_argv.args[0])
455 return True
456 elif kev.keyval == keysyms.semicolon and len(self.command_argv.args) == 2:
457 self.command_argv.set_args([CommandArgument(self)])
458 self.command_argv.widgets[0].grab_focus()
459 return True
461 if self.iv.flags() & gtk.HAS_FOCUS:
462 if self.iv.event(kev):
463 # Handled by IconView (e.g. cursor motion)
464 return True
465 elif kev.keyval == keysyms.BackSpace:
466 self.shell.cd_parent()
467 else:
468 if not self.command_argv.key_press_event(kev):
469 self.iv.grab_focus() # Restore focus to IconView
470 return True
472 def run_in_terminal(self, argv, cwd):
473 self.show_terminal()
474 self.user_seen_terminal_contents = True
475 self.terminal.fork_command(argv[0], argv, None, cwd, False, False, False)
477 @tasks.async
478 def run(self):
479 self.iv.grab_focus()
480 while True:
481 tree_model = gtk_tree_model(self.shell.contents)
482 self.iv.set_model(tree_model)
483 if self.shell.contents:
484 self.iv.set_cursor((0,))
485 self.window.set_title(self.shell.file.get_uri())
486 blockers = [self.shell.updated, self.window_destroyed]
487 yield blockers
488 tasks.check(blockers)
489 if self.window_destroyed.happened:
490 break
492 def item_activated(self, iv, path):
493 # Open a single item
494 self.reset()
496 tm = iv.get_model()
497 name = tm[tm.get_iter(path)][0]
498 self.shell.item_activated(name)