Cope with no matches.
[rox-shell.git] / roxshell / line.py
blob59b3a0447ba12d809ab1c4a6fe0678c8d8cb06ea
1 """
2 @copyright: (C) 2008, Thomas Leonard
3 @see: U{http://roscidus.com}
4 """
5 import os, sys, fnmatch
7 import _gio as gio
8 import gtk
10 import support
11 from directory import DirModel
13 class CantHandle(Exception):
14 pass
16 class Value:
17 def __init__(self, arg, value):
18 self.arg = arg
19 self.value = value
20 self.parsed = self.parse_value(value)
22 def get_entry_text(self):
23 return self.value
25 def get_button_label(self):
26 return self.get_entry_text() or '(nothing)'
28 def finish_edit(self):
29 iv = self.arg.view.iv
30 model = iv.get_model()
31 if iv.flags() & gtk.HAS_FOCUS:
32 cursor_path = (iv.get_cursor() or (None, None))[0]
33 else:
34 cursor_path = None
35 selected = iv.get_selected_items()
37 if cursor_path and selected and cursor_path not in selected:
38 raise Warning("Cursor not in selection!")
39 if cursor_path and not selected:
40 selected = [cursor_path]
42 self.finish_edit_with_selection(selected)
44 def finish_edit_with_selection(self, selected):
45 pass
47 def get_default_command(self):
48 return None
50 class Option(Value):
51 def parse_value(self, value):
52 if value.startswith('-'):
53 return value
54 else:
55 raise CantHandle
57 def expand_to_argv(self):
58 return [self.value]
60 class Quote(Value):
61 def parse_value(self, value):
62 first = value[:1]
63 if first and first in '"\'':
64 if value[-1] == first:
65 return value[1:-1]
66 else:
67 return value[1:]
68 else:
69 raise CantHandle
71 def expand_to_argv(self):
72 return self.parsed
74 class SelectedFiles(Value):
75 abs_path = None # GFile for the directory containing the files
76 to_select = [] # Paths in the IconView's model to be selected
78 def __init__(self, arg, value):
79 Value.__init__(self, arg, value)
80 path, leaf = support.split_expanded_path(value)
81 if path:
82 self.abs_path = arg.view.cwd.file.resolve_relative_path(path)
83 else:
84 self.abs_path = arg.view.cwd.file
86 self.to_select = self.get_selected_files(path, leaf)
88 def tab(self, entry):
89 value = self.parsed
91 path, leaf = os.path.split(value)
92 case_insensitive = (leaf == leaf.lower())
93 prefix_match = None
94 single_match_is_dir = False
95 for i, row in self.arg.iter_matches(leaf, case_insensitive):
96 name = row[DirModel.NAME]
97 if prefix_match is not None:
98 single_match_is_dir = False # Multiple matches
99 if not name.startswith(prefix_match):
100 # Have to shorten the match then
101 same = []
102 for a, b in zip(prefix_match, name):
103 if a == b:
104 same.append(a)
105 else:
106 break
107 prefix_match = ''.join(same)
108 else:
109 prefix_match = name
110 if row[DirModel.INFO].get_file_type() == gio.FILE_TYPE_DIRECTORY:
111 single_match_is_dir = True
112 if single_match_is_dir:
113 prefix_match += '/'
114 if prefix_match and prefix_match != leaf:
115 self.parsed = os.path.join(path, prefix_match)
116 new = self.get_entry_text()
117 entry.set_text(new)
118 entry.set_position(len(new))
120 class Filename(SelectedFiles):
121 selected_filename = None
122 selected_type = None
124 def parse_value(self, value):
125 return value
127 def get_selected_files(self, path, leaf):
128 iv = self.arg.view.iv
129 model = iv.get_model()
131 self.selected_filename = None
132 self.selected_type = None
134 if not self.value:
135 return [] # Empty entry
137 if not leaf:
138 self.selected_filename = ''
139 self.selected_type = gio.FILE_TYPE_DIRECTORY
140 return []
141 elif leaf in ('.', '..'):
142 self.selected_filename = leaf
143 self.selected_type = gio.FILE_TYPE_DIRECTORY
144 return []
146 cursor_path = (self.arg.view.iv.get_cursor() or (None, None))[0]
147 if cursor_path:
148 cursor_filename = model[model.get_iter(cursor_path)][0]
149 else:
150 cursor_filename = None
152 # Rules are:
153 # - Select any exact match
154 # - Else, select any exact case-insensitive match
155 # - Else, select the cursor item if the prefix matches
156 # - Else, select the first prefix match
157 # - Else, select nothing
159 # If the user only entered lower-case letters do a case insensitive match
160 model = self.arg.view.iv.get_model()
162 case_insensitive = (leaf == leaf.lower())
163 exact_case_match = None
164 exact_match = None
165 prefix_match = None
166 for i, row in self.arg.iter_matches(leaf, case_insensitive):
167 name = row[0]
168 if name == leaf:
169 exact_case_match = model.get_path(i)
170 break
171 if case_insensitive:
172 name = name.lower()
173 if name == leaf:
174 exact_match = model.get_path(i)
175 if not prefix_match:
176 prefix_match = model.get_path(i)
177 if case_insensitive and cursor_filename:
178 cursor_filename = cursor_filename.lower()
179 if exact_case_match:
180 to_select = exact_case_match
181 elif exact_match:
182 to_select = exact_match
183 elif cursor_filename and cursor_filename.startswith(leaf):
184 to_select = cursor_path
185 elif prefix_match:
186 to_select = prefix_match
187 else:
188 to_select = None
190 if to_select is None:
191 return []
193 row = model[model.get_iter(to_select)]
194 self.selected_filename = row[DirModel.NAME]
195 self.selected_type = row[DirModel.INFO].get_file_type()
197 return [to_select]
199 def get_entry_text(self):
200 return self.parsed
202 def get_button_label(self):
203 return self.selected_filename or '(none)'
205 def expand_to_argv(self):
206 if not self.value:
207 raise Warning("Empty argument")
208 if self.selected_filename is None:
209 raise Warning("No filename selected")
210 abs_path = self.abs_path.resolve_relative_path(self.selected_filename)
211 return [self.arg.view.cwd.file.get_relative_path(abs_path) or abs_path.get_path()]
213 def finish_edit_with_selection(self, selected):
214 if not self.value:
215 model = self.arg.view.iv.get_model()
216 if not selected:
217 raise Warning("No selection and no cursor item!")
218 if len(selected) > 1:
219 raise Warning("Multiple selection!")
220 path = selected[0]
222 row = model[model.get_iter(path)]
223 self.selected_filename = row[DirModel.NAME]
224 self.selected_type = row[DirModel.INFO].get_file_type()
225 self.value = self.parsed = self.selected_filename
227 def get_default_command(self):
228 if self.selected_filename is None:
229 return None
230 if self.selected_type == gio.FILE_TYPE_DIRECTORY:
231 return 'cd'
232 else:
233 return 'gvim'
235 class Newfile(SelectedFiles):
236 def parse_value(self, value):
237 if value.startswith('!'):
238 return value[1:]
239 raise CantHandle
241 def get_selected_files(self, path, leaf):
242 # No completion for new files
243 # TODO: highlight if it exists
244 return []
246 def expand_to_argv(self):
247 value = self.parsed
248 path, leaf = os.path.split(value)
249 if not leaf:
250 raise Warning("No name given for new file!")
251 if path:
252 final_dir = self.arg.view.cwd.file.resolve_relative_path(path)
253 unix_path = final_dir.get_path()
254 if not os.path.exists(unix_path):
255 os.makedirs(unix_path)
256 return [value]
258 def get_default_command(self):
259 return 'mkdir'
261 class Glob(SelectedFiles):
262 def parse_value(self, value):
263 for x in value:
264 if x in '*?[':
265 return value
266 raise CantHandle
268 def get_selected_files(self, path, leaf):
269 to_select = []
270 pattern = leaf
271 def match(m, path, iter):
272 name = m[iter][0]
273 if fnmatch.fnmatch(name, pattern):
274 to_select.append(path)
275 self.arg.view.iv.get_model().foreach(match)
276 return to_select
278 def expand_to_argv(self):
279 pattern = self.parsed
280 matches = [row[0] for i, row in self.arg.view.iter_contents() if fnmatch.fnmatch(row[0], pattern)]
281 if not matches:
282 raise Warning("Nothing matches '%s'!" % pattern)
283 return matches
285 def tab(self, entry):
286 return
288 class BaseArgument:
289 def __init__(self, view):
290 self.view = view
292 def get_entry_text(self):
293 return ''
295 def get_button_label(self):
296 return '?'
298 def entry_changed(self, entry):
299 return
301 def finish_edit(self):
302 pass
304 def validate(self):
305 pass
307 def tab(self, entry):
308 pass
310 def updown(self, entry, delta):
311 pass
313 class Argument(BaseArgument):
314 """Represents a word entered by the user for a command."""
315 # This is a bit complicated. An argument can be any of these:
316 # - A glob pattern matching multiple filenames (*.html)
317 # - A single filename (index.html)
318 # - A set of files selected manually (a.html, b.html)
319 # - A quoted string ('*.html')
320 # - An option (--index)
321 def __init__(self, view):
322 BaseArgument.__init__(self, view)
323 self.result = self.parse_value('')
325 def parse_value(self, value):
326 for t in [Newfile, Quote, Option, Glob, Filename]:
327 try:
328 return t(self, value)
329 except CantHandle:
330 continue
331 assert False
333 def iter_matches(self, match, case_insensitive):
334 """Return all rows with a name matching match"""
335 for i, row in self.view.iter_contents():
336 name = row[0]
337 if case_insensitive:
338 name = name.lower()
339 if name.startswith(match):
340 yield i, row
342 def entry_changed(self, entry):
343 self.result = self.parse_value(entry.get_text())
345 if not isinstance(self.result, SelectedFiles):
346 self.view.iv.unselect_all()
347 return
349 cursor_path = (self.view.iv.get_cursor() or (None, None))[0]
351 # Check which directory the view should be displaying...
352 viewed = self.view.view_dir.file
354 # Switch if necessary...
355 if self.result.abs_path.get_uri() != viewed.get_uri():
356 self.view.set_view_dir(self.result.abs_path)
358 iv = self.view.iv
359 iv.unselect_all()
360 if self.result.to_select:
361 for path in self.result.to_select:
362 iv.select_path(path)
363 if cursor_path not in self.result.to_select:
364 iv.set_cursor(self.result.to_select[0])
366 def tab(self, entry):
367 self.result.tab(entry)
369 def get_default_command(self):
370 return self.result.get_default_command()
372 def updown(self, entry, delta):
373 if self.type == 'newfile':
374 leaf = self.value[1:]
375 elif self.type == 'filename':
376 leaf = self.value
377 else:
378 return
380 leaf = os.path.basename(leaf)
382 model = self.view.iv.get_model()
383 cursor_path = self.view.get_cursor_path()
384 if delta > 0:
385 def apply_delta(i):
386 return model.iter_next(i) or model.get_iter_first()
387 else:
388 def apply_delta(i):
389 n, = model.get_path(i)
390 if n == 0:
391 n = model.iter_n_children(None)
392 return model.get_iter((n - 1,))
393 if cursor_path:
394 start = model.get_iter(cursor_path)
395 i = apply_delta(start)
396 else:
397 start = i = model.get_iter_root()
398 if not start:
399 return # Empty directory
400 start = model[start][DirModel.NAME]
402 case_insensitive = (leaf == leaf.lower())
404 while i:
405 name = model[i][DirModel.NAME]
406 if name == start:
407 return
408 if case_insensitive:
409 name = name.lower()
410 if name.startswith(leaf):
411 path = model.get_path(i)
412 self.view.iv.unselect_all()
413 self.view.iv.set_cursor(path)
414 self.view.iv.select_path(path)
415 return
416 i = apply_delta(i)
418 def finish_edit(self):
419 self.result.finish_edit()
421 def get_entry_text(self):
422 return self.result.get_entry_text()
424 def get_button_label(self):
425 return self.result.get_button_label()
427 def expand_to_argv(self):
428 return self.result.expand_to_argv()
430 class CommandArgument(BaseArgument):
431 command = ''
432 default_command = None
434 def entry_changed(self, entry):
435 self.command = entry.get_text() or None
437 def get_button_label(self):
438 return self.command if self.command else \
439 self.default_command if self.default_command else \
440 "Open"
442 def expand_to_argv(self):
443 return [self.command or self.default_command]
445 def set_default_command_from_args(self, args):
446 self.default_command = None
448 if len(args) != 1:
449 return
451 self.default_command = args[0].get_default_command()
453 class ArgvView:
454 def __init__(self, hbox):
455 self.hbox = hbox
456 self.args = []
457 self.widgets = []
459 def set_args(self, args):
460 self.args = args
461 self.edit_arg = self.args[-1]
462 self.build()
464 def build(self):
465 for w in self.widgets:
466 w.destroy()
467 self.widgets = []
468 self.active_entry = None
470 cmd_arg = self.args[0]
472 for x in self.args:
473 if x is self.edit_arg:
474 arg = gtk.Entry()
475 arg.set_text(x.get_entry_text())
476 def entry_changed(entry, x = x):
477 x.entry_changed(entry)
478 if x is not cmd_arg and not cmd_arg.command:
479 cmd_arg.set_default_command_from_args(self.args[1:])
480 self.widgets[0].set_label(cmd_arg.get_button_label())
481 arg.connect('changed', entry_changed)
482 self.active_entry = arg
483 else:
484 arg = gtk.Button(x.get_button_label())
485 arg.set_relief(gtk.RELIEF_NONE)
486 arg.connect('clicked', lambda b, x = x: self.activate(x))
487 arg.show()
488 self.hbox.pack_start(arg, False, True, 0)
489 self.widgets.append(arg)
491 def activate(self, x):
492 """Start editing argument 'x'"""
493 if x is self.edit_arg:
494 return
495 try:
496 self.edit_arg.finish_edit()
497 except Warning, ex:
498 self.edit_arg.view.warning(str(ex))
499 self.edit_arg = x
500 self.build()
501 i = self.args.index(x)
502 self.widgets[i].grab_focus()
504 def space(self):
505 if not self.active_entry.flags() & gtk.HAS_FOCUS:
506 return False # Not focussed
507 if self.active_entry.get_position() != len(self.active_entry.get_text()):
508 return False # Not at end
509 i = self.args.index(self.edit_arg)
510 try:
511 self.edit_arg.finish_edit()
512 except Warning, ex:
513 self.edit_arg.view.warning(str(ex))
514 self.edit_arg = Argument(self.edit_arg.view)
515 self.args.insert(i + 1, self.edit_arg)
516 self.build()
517 self.widgets[i + 1].grab_focus()
518 return True
520 def tab(self):
521 if self.active_entry.get_position() == len(self.active_entry.get_text()):
522 self.edit_arg.tab(self.active_entry)
523 return True
525 def updown(self, delta):
526 if self.active_entry and self.active_entry.flags() & gtk.HAS_FOCUS:
527 self.edit_arg.updown(self.active_entry, delta)
528 return True
529 else:
530 return False
532 def key_press_event(self, kev):
533 if not self.active_entry:
534 return False
535 old_text = self.active_entry.get_text()
536 self.active_entry.grab_focus() # Otherwise it selects the added text
537 self.active_entry.event(kev)
538 return self.active_entry.get_text() != old_text
540 def finish_edit(self):
541 self.edit_arg.finish_edit()