Sort by type, then by lower-cased name.
[rox-shell.git] / rox / shell / shell.py
blobac52e37ad3cda398a093e20347a6c2738e2df3c3
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
15 import directory
17 class Warning(Exception):
18 pass
20 RETURN_KEYS = (keysyms.Return, keysyms.KP_Enter, keysyms.ISO_Enter)
22 class DirUpdated(tasks.Blocker):
23 changes = []
25 FILER_PAGE = 0
26 TERMINAL_PAGE = 1
28 class BaseArgument:
29 def __init__(self, view):
30 self.view = view
32 def get_entry_text(self):
33 return ''
35 def get_button_label(self):
36 return '?'
38 def entry_changed(self, entry):
39 return
41 def finish_edit(self):
42 pass
44 def validate(self):
45 pass
47 def tab(self, entry):
48 pass
50 class Argument(BaseArgument):
51 """Represents a word entered by the user for a command."""
52 # This is a bit complicated. An argument can be any of these:
53 # - A glob pattern matching multiple filenames (*.html)
54 # - A single filename (index.html)
55 # - A set of files selected manually (a.html, b.html)
56 # - A quoted string ('*.html')
57 # - An option (--index)
58 def __init__(self, view):
59 BaseArgument.__init__(self, view)
60 self.value = ''
61 self.type = 'empty'
63 def type_from_value(self, value):
64 if not value:
65 return 'empty'
66 elif value.startswith("!"):
67 return 'newfile'
68 elif value.startswith("'") or value.startswith('"'):
69 return 'quoted'
70 elif value.startswith("-"):
71 return 'option'
72 for x in value:
73 if x in '*?[':
74 return 'glob'
75 return 'filename'
77 def iter_matches(self, match, case_insensitive):
78 """Return all rows with a name matching match"""
79 for i, row in self.view.iter_contents():
80 name = row[0]
81 if case_insensitive:
82 name = name.lower()
83 if name.startswith(match):
84 yield i, row
86 def entry_changed(self, entry):
87 self.value = entry.get_text()
88 self.type = self.type_from_value(self.value)
90 if self.type not in ('filename', 'glob'):
91 return
93 # Check which directory the view should be displaying...
94 viewed = self.view.view_dir.file
95 path, leaf = os.path.split(self.value)
96 if path:
97 abs_path = self.view.cwd.file.resolve_relative_path(path)
98 else:
99 abs_path = self.view.cwd.file
101 # Switch if necessary...
102 if abs_path.get_uri() != viewed.get_uri():
103 self.view.set_view_dir(abs_path)
105 iv = self.view.iv
106 model = iv.get_model()
108 cursor_path = (self.view.iv.get_cursor() or (None, None))[0]
109 if cursor_path:
110 cursor_filename = model[model.get_iter(cursor_path)][0]
111 else:
112 cursor_filename = None
114 # If the user only entered lower-case letters do a case insensitive match
115 if self.type == 'filename':
116 # Rules are:
117 # - Select any exact match
118 # - Else, select any exact case-insensitive match
119 # - Else, select the cursor item if the prefix matches
120 # - Else, select the first prefix match
121 # - Else, select nothing
123 case_insensitive = (leaf == leaf.lower())
124 exact_case_match = None
125 exact_match = None
126 prefix_match = None
127 for i, row in self.iter_matches(leaf, case_insensitive):
128 name = row[0]
129 if name == leaf:
130 exact_case_match = model.get_path(i)
131 break
132 if case_insensitive:
133 name = name.lower()
134 if name == leaf:
135 exact_match = model.get_path(i)
136 if not prefix_match:
137 prefix_match = model.get_path(i)
138 if case_insensitive and cursor_filename:
139 cursor_filename = cursor_filename.lower()
140 if exact_case_match:
141 to_select = [exact_case_match]
142 elif exact_match:
143 to_select = [exact_match]
144 elif cursor_filename and cursor_filename.startswith(leaf):
145 to_select = [cursor_path]
146 elif prefix_match:
147 to_select = [prefix_match]
148 else:
149 to_select = []
150 elif self.type == 'glob':
151 to_select = []
152 pattern = leaf
153 def match(m, path, iter):
154 name = m[iter][0]
155 if fnmatch.fnmatch(name, pattern):
156 to_select.append(path)
157 model.foreach(match)
159 iv.unselect_all()
160 if to_select:
161 for path in to_select:
162 iv.select_path(path)
163 if cursor_path not in to_select:
164 iv.set_cursor(to_select[0])
166 def tab(self, entry):
167 if self.type == 'filename':
168 value = self.value
169 elif self.type == 'newfile':
170 value = self.value[1:]
171 else:
172 return
174 path, leaf = os.path.split(self.value)
175 case_insensitive = (leaf == leaf.lower())
176 prefix_match = None
177 single_match_is_dir = False
178 for i, row in self.iter_matches(leaf, case_insensitive):
179 name = row[directory.DirModel.NAME]
180 if prefix_match is not None:
181 single_match_is_dir = False # Multiple matches
182 if not name.startswith(prefix_match):
183 # Have to shorten the match then
184 same = []
185 for a, b in zip(prefix_match, name):
186 if a == b:
187 same.append(a)
188 else:
189 break
190 prefix_match = ''.join(same)
191 else:
192 prefix_match = name
193 if row[directory.DirModel.INFO].get_file_type() == gio.FILE_TYPE_DIRECTORY:
194 single_match_is_dir = True
195 if single_match_is_dir:
196 prefix_match += '/'
197 if prefix_match and prefix_match != leaf:
198 new = os.path.join(path, prefix_match)
199 entry.set_text(new)
200 entry.set_position(len(new))
202 def finish_edit(self):
203 iv = self.view.iv
204 model = iv.get_model()
205 cursor_path = (iv.get_cursor() or (None, None))[0]
206 selected = iv.get_selected_items()
208 if cursor_path and selected and cursor_path not in selected:
209 raise Warning("Cursor not in selection!")
210 if cursor_path and not selected:
211 selected = [cursor_path]
213 if self.type == 'empty':
214 if not selected:
215 raise Warning("No selection and no cursor item!")
216 if len(selected) > 1:
217 raise Warning("Multiple selection!")
218 path = selected[0]
220 self.type = 'filename'
221 self.selected_item = model[model.get_iter(path)][0]
222 if self.type == 'filename':
223 self.selected_filename = None
225 path, leaf = os.path.split(self.value)
227 if len(selected) != 1:
228 raise Warning("Must be one selected item!")
229 # The cursor must be on the single selected item
230 selected_name = model[model.get_iter(selected[0])][0]
231 # Selected item must match text
232 if selected_name.lower().startswith(leaf.lower()):
233 abs_path = self.view.view_dir.file.resolve_relative_path(selected_name)
234 # NB: selected item may be above cwd
235 self.selected_filename = self.view.cwd.file.get_relative_path(abs_path) or abs_path.get_path()
236 else:
237 raise Warning("Selected item does not match entered text!")
239 def get_entry_text(self):
240 return self.value
242 def get_button_label(self):
243 if self.type == 'filename':
244 return self.selected_filename or '(none)'
245 return self.value
247 def expand_to_argv(self):
248 if self.type == 'empty':
249 raise Warning("Empty argument")
250 if self.type == 'filename':
251 if self.selected_filename is None:
252 raise Warning("No filename selected")
253 return [self.selected_filename]
254 elif self.type == 'glob':
255 pattern = self.value
256 matches = [row[0] for i, row in self.view.iter_contents() if fnmatch.fnmatch(row[0], pattern)]
257 if not matches:
258 raise Warning("Nothing matches '%s'!" % pattern)
259 return matches
260 elif self.type == 'newfile':
261 value = self.value[1:]
262 path, leaf = os.path.split(value)
263 if not leaf:
264 raise Warning("No name given for new file!")
265 if path:
266 final_dir = self.view.cwd.file.resolve_relative_path(path)
267 unix_path = final_dir.get_path()
268 if not os.path.exists(unix_path):
269 os.makedirs(unix_path)
270 return [value]
271 elif self.type == 'quoted':
272 first = self.value[0]
273 if self.value.endswith(first):
274 return [self.value[1:-1]]
275 else:
276 return [self.value[1:]]
277 elif self.type == 'option':
278 return [self.value]
279 else:
280 assert False, "Unknown type " + self.type
282 class CommandArgument(BaseArgument):
283 command = ''
285 def entry_changed(self, entry):
286 self.command = entry.get_text() or None
288 def get_button_label(self):
289 return self.command if self.command else "Open"
291 def expand_to_argv(self):
292 return [self.command or 'rox:open']
294 class ArgvView:
295 def __init__(self, hbox):
296 self.hbox = hbox
297 self.args = []
298 self.widgets = []
300 def set_args(self, args):
301 self.args = args
302 self.edit_arg = self.args[-1]
303 self.build()
305 def build(self):
306 for w in self.widgets:
307 w.destroy()
308 self.widgets = []
309 self.active_entry = None
311 for x in self.args:
312 if x is self.edit_arg:
313 arg = gtk.Entry()
314 arg.set_text(x.get_entry_text())
315 arg.connect('changed', x.entry_changed)
316 self.active_entry = arg
317 else:
318 arg = gtk.Button(x.get_button_label())
319 arg.set_relief(gtk.RELIEF_NONE)
320 arg.connect('clicked', lambda b, x = x: self.activate(x))
321 arg.show()
322 self.hbox.pack_start(arg, False, True, 0)
323 self.widgets.append(arg)
325 def activate(self, x):
326 """Start editing argument 'x'"""
327 if x is self.edit_arg:
328 return
329 try:
330 self.edit_arg.finish_edit()
331 except Warning, ex:
332 self.edit_arg.view.warning(str(ex))
333 self.edit_arg = x
334 self.build()
335 i = self.args.index(x)
336 self.widgets[i].grab_focus()
338 def space(self):
339 if not self.active_entry.flags() & gtk.HAS_FOCUS:
340 return False # Not focussed
341 if self.active_entry.get_position() != len(self.active_entry.get_text()):
342 return False # Not at end
343 i = self.args.index(self.edit_arg)
344 try:
345 self.edit_arg.finish_edit()
346 except Warning, ex:
347 self.edit_arg.view.warning(str(ex))
348 self.edit_arg = Argument(self.edit_arg.view)
349 self.args.insert(i + 1, self.edit_arg)
350 self.build()
351 self.widgets[i + 1].grab_focus()
352 return True
354 def tab(self):
355 if self.active_entry.get_position() == len(self.active_entry.get_text()):
356 self.edit_arg.tab(self.active_entry)
357 return True
359 def key_press_event(self, kev):
360 if not self.active_entry:
361 return False
362 old_text = self.active_entry.get_text()
363 self.active_entry.grab_focus() # Otherwise it selects the added text
364 self.active_entry.event(kev)
365 return self.active_entry.get_text() != old_text
367 def finish_edit(self):
368 self.edit_arg.finish_edit()
370 class ShellView:
371 terminal = None
372 user_seen_terminal_contents = False
373 warning_timeout = None
374 cwd = None
375 view_dir = None
377 def __init__(self, cwd_file):
378 builder = gtk.Builder()
379 builder.add_from_file(os.path.join(os.path.dirname(__file__), "ui.xml"))
380 self.window = builder.get_object('directory')
381 self.notebook = builder.get_object('notebook')
383 cd_parent = builder.get_object('cd-parent')
384 cd_parent.connect('activate', lambda a: self.cd_parent())
386 # Must show window before adding icons, or we randomly get:
387 # The error was 'BadAlloc (insufficient resources for operation)'
388 self.window.show()
390 self.window_destroyed = tasks.Blocker('Window destroyed')
391 self.window.connect('destroy', lambda w: self.window_destroyed.trigger())
393 self.window.connect('key-press-event', self.key_press_event)
395 self.iv = builder.get_object('iconview')
396 self.iv.set_text_column(0)
397 self.iv.set_pixbuf_column(1)
398 self.iv.set_selection_mode(gtk.SELECTION_MULTIPLE)
400 self.iv.connect('item-activated', self.item_activated)
402 command_area = builder.get_object('command')
403 self.command_argv = ArgvView(command_area)
405 self.status_msg = builder.get_object('status_msg')
407 self.set_cwd(cwd_file)
408 self.reset()
410 self.window.show_all()
412 def iter_contents(self):
413 m = self.iv.get_model()
414 i = m.get_iter_root()
415 while i:
416 yield i, m[i]
417 i = m.iter_next(i)
419 def warning(self, msg):
420 def hide_warning():
421 self.status_msg.set_text('')
422 return False
423 if self.warning_timeout is not None:
424 gobject.source_remove(self.warning_timeout)
425 self.status_msg.set_text(msg)
426 self.warning_timeout = gobject.timeout_add(2000, hide_warning)
428 def show_terminal(self):
429 # Actually, don't show it until we get some output...
430 if not self.terminal:
431 def terminal_contents_changed(vte):
432 if self.notebook.get_current_page() == FILER_PAGE:
433 self.notebook.set_current_page(TERMINAL_PAGE)
434 self.user_seen_terminal_contents = False
436 def terminal_child_exited():
437 if self.user_seen_terminal_contents:
438 self.notebook.set_current_page(FILER_PAGE)
439 else:
440 self.terminal.feed('\r\nProcess complete. Press Return to return to filer view.\r\n')
441 self.waiting_for_return = True
442 return False
444 self.terminal = vte.Terminal()
445 self.terminal.connect('contents-changed', terminal_contents_changed)
446 self.terminal.connect('child-exited', lambda vte: gobject.timeout_add(100, terminal_child_exited))
447 self.terminal.show()
449 self.notebook.add(self.terminal)
450 self.waiting_for_return = False
452 def reset(self):
453 self.view_cwd()
454 self.command_argv.set_args([CommandArgument(self), Argument(self)])
455 if self.notebook.get_current_page() == FILER_PAGE:
456 self.iv.grab_focus()
457 self.iv.unselect_all()
459 def key_press_event(self, window, kev):
460 if self.terminal and self.terminal.flags() & gtk.HAS_FOCUS:
461 if kev.keyval in RETURN_KEYS and self.waiting_for_return:
462 self.notebook.set_current_page(FILER_PAGE)
463 return True
464 self.user_seen_terminal_contents = True
465 return False
467 if kev.keyval == keysyms.space:
468 if self.command_argv.space():
469 return True
471 if kev.keyval == keysyms.Tab:
472 if self.command_argv.tab():
473 return True
475 if kev.keyval == keysyms.Escape:
476 self.reset()
477 return True
478 elif kev.keyval in RETURN_KEYS:
479 try:
480 self.command_argv.finish_edit()
481 self.run_command(self.command_argv.args)
482 except Warning, ex:
483 self.warning(str(ex))
484 else:
485 self.reset()
486 return True
488 # Are we ready for special characters?
489 if self.command_argv.active_entry and self.command_argv.active_entry.flags() & gtk.HAS_FOCUS:
490 accept_special = True # TODO: check cursor is at end
491 else:
492 accept_special = True
494 if accept_special:
495 if kev.keyval == keysyms.comma:
496 self.command_argv.activate(self.command_argv.args[0])
497 return True
498 elif kev.keyval == keysyms.semicolon and len(self.command_argv.args) == 2:
499 self.command_argv.set_args([CommandArgument(self)])
500 self.command_argv.widgets[0].grab_focus()
501 return True
503 if self.iv.flags() & gtk.HAS_FOCUS:
504 if self.iv.event(kev):
505 # Handled by IconView (e.g. cursor motion)
506 return True
507 elif kev.keyval == keysyms.BackSpace:
508 self.cd_parent()
509 else:
510 if not self.command_argv.key_press_event(kev):
511 self.iv.grab_focus() # Restore focus to IconView
512 return True
514 def run_in_terminal(self, argv):
515 self.show_terminal()
516 self.user_seen_terminal_contents = True
517 self.terminal.fork_command(argv[0], argv, None, self.cwd.file.get_path(), False, False, False)
519 def view_cwd(self):
520 """Make the IconView show the cwd."""
521 self.set_view_dir(self.cwd.file)
523 def set_view_dir(self, dir_file):
524 if self.view_dir:
525 self.view_dir.del_ref(self)
526 self.view_dir = None
527 self.view_dir = directory.get_dir_model(dir_file)
528 self.view_dir.add_ref(self)
530 tree_model = gtk.TreeModelSort(self.view_dir.model)
531 tree_model.set_sort_column_id(directory.DirModel.SORT, gtk.SORT_ASCENDING)
532 self.iv.set_model(tree_model)
533 if tree_model.get_iter_root():
534 self.iv.set_cursor((0,))
536 if self.view_dir.error:
537 self.warning(str(self.view_dir.error))
539 def set_cwd(self, cwd_file):
540 if self.cwd:
541 self.cwd.del_ref(self)
542 self.cwd = None
543 self.cwd = directory.get_dir_model(cwd_file)
544 self.cwd.add_ref(self)
545 self.view_cwd()
546 self.window.set_title(self.cwd.file.get_uri())
547 self.reset()
549 @tasks.async
550 def run(self):
551 self.iv.grab_focus()
552 while True:
553 blockers = [self.window_destroyed]
554 yield blockers
555 tasks.check(blockers)
556 if self.window_destroyed.happened:
557 break
559 def get_iter(self, name):
560 for i, row in self.iter_contents():
561 if row[directory.DirModel.NAME] == name:
562 return i
563 raise Exception("File '%s' not found!" % name)
565 def item_activated(self, iv, path):
566 # Open a single item
567 self.reset()
569 tm = iv.get_model()
570 row = tm[tm.get_iter(path)]
571 name = row[directory.DirModel.NAME]
572 item_info = row[directory.DirModel.INFO]
574 child = self.cwd.file.get_child(name)
575 self.open_item(child)
577 def open_item(self, item_file):
578 item_info = item_file.query_info('standard::*', 0)
579 if item_info.get_file_type() == gio.FILE_TYPE_DIRECTORY:
580 self.set_cwd(item_file)
581 else:
582 self.run_in_terminal(['gvim', item_file.get_path()])
584 def cd_parent(self):
585 parent = self.cwd.file.get_parent()
586 if parent:
587 self.set_cwd(parent)
589 def run_command(self, args):
590 argv = []
591 for a in args:
592 argv += a.expand_to_argv()
594 builtin = commands.builtin_commands.get(argv[0], None)
595 if builtin:
596 builtin(self, argv[1:])
597 else:
598 self.run_in_terminal(argv)