# Captain Nemo is a Nautilus extension which converts Nautilus into # an orthodox file manager. # # This extension requires at least version 1.0-0ubuntu2 of the # python-nautilus package. # # To install copy captain-nemo.py to ~/.local/share/nautilus-python/extensions/ # # The following keyboard shorcuts are (re)defined to their orthodox meanings. # # ------ ----------------------------------- --------------- # Operation # Key Orthodox Nautilus Alternative Key # ------ ------------- -------------------- --------------- # F3 View Show/Hide Extra Pane # F4 Edit Not Used # F5 Copy Reload Ctrl+R # F6 RenMov Switch Between Panes Tab # F7 Mkdir Not Used # F8 Delete Not Used # Ctrl+O Open Terminal Open File Enter # # As can be seen from the above table for most redefined operations there # exist commonly used alternatives. # # In addition this extension defined the following keyboard shortcut: # Ctrl+G - open a git client in the current directory # Also the Compare... item is added to the context menu when two items are # selected. import contextlib import logging import os import subprocess import sys import traceback import urllib from gi.repository import Nautilus, GObject, Gtk, GConf DIFF = 'meld' GIT_CLIENT = 'gitg' TERMINAL_KEY = '/desktop/gnome/applications/terminal/exec' EDITOR = 'gedit' ACCEL_FILE_NAME = os.path.join(os.path.dirname(__file__), "captain_nemo.accel") DEBUG = False SHOW_EXTRA_PANE = False # This class allows depth-first traversal of a widget tree using an iterator. class walk: def __init__(self, top, visit_submenu=True): self._generator = self._walk(top) self._visit_submenu = visit_submenu self._skip_children = False self._depth = 0 def __iter__(self): return self._generator.__iter__() def depth(self): return self._depth # Skip children of the current widget. def skip_children(self): self._skip_children = True def _walk(self, widget): if widget == None: return yield widget if self._skip_children: self._skip_children = False return self._depth += 1 if isinstance(widget, Gtk.Container): for child in widget.get_children(): for w in self._walk(child): yield w if self._visit_submenu and isinstance(widget, Gtk.MenuItem): for w in self._walk(widget.get_submenu()): yield w self._depth -= 1 class AccelInfo: def __init__(self, current, default): self.current = current self.default = default # Map from accel path to info. ACCELS = {} # Changes the accelerator associated with a path recording # the new value in ACCELS. def change_accel(accel_path, accel_name): key, mods = Gtk.accelerator_parse(accel_name) info = ACCELS.get(accel_path) if info == None: known, entry = Gtk.AccelMap.lookup_entry(accel_path) name = Gtk.accelerator_name(entry.accel_key, entry.accel_mods) info = AccelInfo(name, name) ACCELS[accel_path] = info if Gtk.AccelMap.change_entry(accel_path, key, mods, True): info.current = accel_name return True return False # Sets the default accelerators. def set_default_accels(): for path, info in ACCELS.items(): key, mods = Gtk.accelerator_parse(info.default) Gtk.AccelMap.change_entry(path, key, mods, True) ACCELS.clear() # Loads accelerators from a file. def load_accels(filename): set_default_accels() with open(filename) as f: for line in f: path, current, default = line.rstrip("\n").split(" ") path = urllib.unquote(path) key, mods = Gtk.accelerator_parse(current) Gtk.AccelMap.change_entry(path, key, mods, True) ACCELS[path] = AccelInfo(current, default) # Saves accelerators to a file. def save_accels(filename): with open(filename, "w") as f: for path, info in ACCELS.items(): f.write("%s %s %s\n" % (urllib.quote(path), info.current, info.default)) if DEBUG: logging.basicConfig( filename=os.path.join(os.path.dirname(__file__), 'captain_nemo.log'), level=logging.DEBUG) def get_filename(file_info): return urllib.unquote(file_info.get_uri()[7:]) def has_file_scheme(f): return f.get_uri_scheme() == 'file' # Catches and logs all exceptions. @contextlib.contextmanager def catch_all(): try: yield except: logging.error(sys.exc_info()[1]) def set_orthodox_accels(): # Change the accelerator for the Open action from Ctrl+O to F3. change_accel("/ShellActions/Show Hide Extra Pane", "") change_accel("/DirViewActions/Open", "F3") # Remove the accelerator from the 'SplitViewNextPane' action (F6). change_accel("/ShellActions/SplitViewNextPane", "") # Change the accelerator for the New Folder action from Ctrl+Shift+N to F7. change_accel("/DirViewActions/New Folder", "F7") class KeyboardShortcutsDialog(Gtk.Dialog): def __init__(self, parent): Gtk.Dialog.__init__(self, "Keyboard Shortcuts", parent, Gtk.DialogFlags.DESTROY_WITH_PARENT, border_width=5) self.add_button("Close", Gtk.ResponseType.CLOSE) self.set_default_size(800, 500) content = self.get_content_area() content.set_spacing(2) hbox = Gtk.Box() content.pack_start(hbox, True, True, 0) window = Gtk.ScrolledWindow( border_width=5, shadow_type=Gtk.ShadowType.IN) self.create_shortcut_list() window.add(self.view) hbox.pack_start(window, True, True, 0) vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, border_width=5, spacing=10) hbox.pack_start(vbox, False, False, 0) button = Gtk.Button(label="Use Default") button.connect("clicked", self.use_default) vbox.pack_start(button, False, False, 0) button = Gtk.Button(label="Use Orthodox") button.connect("clicked", self.use_orthodox) vbox.pack_start(button, False, False, 0) def create_shortcut_list(self): self.accel_store = Gtk.TreeStore(str, str, bool) self.accel_store.set_sort_column_id(0, Gtk.SortType.ASCENDING) iters = {} def add_accel(data, accel_path, key, mods, changed): label = Gtk.accelerator_get_label(key, mods) split_path = accel_path.split("/") parent = None for i in range(len(split_path) - 1): subpath = "/".join(split_path[:i + 1]) iter = iters.get(subpath) if iter == None: iter = self.accel_store.append( parent, [split_path[i], "", False]) iters[subpath] = iter parent = iter self.accel_store.append(parent, [split_path[-1], label, True]) Gtk.AccelMap.foreach(None, add_accel) self.view = Gtk.TreeView(self.accel_store) self.view.set_rules_hint(True) self.view.expand_all() column = Gtk.TreeViewColumn("Action", Gtk.CellRendererText(), text=0) column.set_sort_column_id(0) self.view.append_column(column) # Unselecting all when the view loses focus solves the following problem: # when the user clicks on an accelerator in a selected row the keyboard # input is not captured by the view and the accelerator cannot be changed. self.view.connect("focus-out-event", lambda *args: self.view.get_selection().unselect_all()) renderer = Gtk.CellRendererAccel() renderer.set_property("editable", True) renderer.connect("accel-edited", self.accel_edited) column = Gtk.TreeViewColumn("Key", renderer, text=1, editable=2) column.set_sort_column_id(1) self.view.append_column(column) # Converts tree iterator to accelerator path. def convert_tree_iter_to_accel_path(self, i): items = [] while i != None: items.insert(0, self.accel_store.get_value(i, 0)) i = self.accel_store.iter_parent(i) return "/".join(items) # Converts tree path to accelerator path. def convert_tree_path_to_accel_path(self, path): return self.convert_tree_iter_to_accel_path( self.accel_store.get_iter(path)) def do_update_accel_store(self, iter): while iter != None: if self.accel_store.iter_has_child(iter): self.do_update_accel_store(self.accel_store.iter_children(iter)) else: known, key = Gtk.AccelMap.lookup_entry( self.convert_tree_iter_to_accel_path(iter)) if known: self.accel_store[iter][1] = Gtk.accelerator_get_label( key.accel_key, key.accel_mods) iter = self.accel_store.iter_next(iter) def update_accel_store(self): self.do_update_accel_store(self.accel_store.get_iter_first()) save_accels(ACCEL_FILE_NAME) def accel_edited(self, accel, path, key, mods, keycode): with catch_all(): accel_path = self.convert_tree_path_to_accel_path(path) if change_accel(accel_path, Gtk.accelerator_name(key, mods)): self.accel_store[path][1] = Gtk.accelerator_get_label(key, mods) save_accels(ACCEL_FILE_NAME) def use_default(self, widget): with catch_all(): set_default_accels() self.update_accel_store() def use_orthodox(self, widget): with catch_all(): # Set default accelerators first to discard any changes, # then apply orthodox changes on top. set_default_accels() set_orthodox_accels() self.update_accel_store() # Keyboard shortcuts dialog is global because shortcuts apply for a # whole application, not to a single window. shortcuts_dialog = None # Redefines keyboard shortcuts and adds extra widgets. class WindowAgent: def __init__(self, window): self.window = window self.loc_entry1 = self.loc_entry2 = None self.menubar = None self.menu_items = {} # Find the main paned widget and the menubar. self.main_paned = menubar = None walker = walk(window, False) for w in walker: name = w.get_name() if name == 'NautilusToolbar': p = w.get_parent() while not isinstance(p, Gtk.Paned): p = p.get_parent() self.main_paned = p walker.skip_children() if name == 'MenuBar': self.menubar = w walker.skip_children() if self.main_paned != None: # Find location entries. self.loc_entry1 = self.find_loc_entry(self.main_paned.get_child1()) self.loc_entry2 = self.find_loc_entry(self.main_paned.get_child2()) else: logging.error("main paned not found") accel_group = Gtk.accel_groups_from_object(window)[0] def connect(accel, func): key, mods = Gtk.accelerator_parse(accel) accel_group.connect(key, mods, Gtk.AccelFlags.VISIBLE, func) connect('F4', self.on_edit) connect('F5', self.on_copy) connect('F6', self.on_move) connect('F8', self.on_delete) if self.loc_entry1 != None: # TODO: look how nautilus-open-terminal work connect('O', self.on_terminal) connect('G', self.on_git) else: logging.error("location entry not found") if self.menubar != None: for w in walk(self.menubar): name = w.get_name() if SHOW_EXTRA_PANE and name == 'Show Hide Extra Pane': w.activate() elif name == 'Edit': item = Gtk.MenuItem( "_Keyboard Shortcuts...", use_underline=True) w.add(item) item.show() item.connect('activate', self.show_keyboard_shortcuts_dialog) else: logging.error("menu bar not found") if DEBUG: # Add the widget inspector. from nautilus_debug import WidgetInspector child = window.get_child() inspector = WidgetInspector(window) window.remove(child) paned = Gtk.VPaned() paned.pack1(child, True, True) paned.pack2(inspector, False, False) paned.show() window.add(paned) def get_menu_item(self, name): item = self.menu_items.get(name) if item == None and self.menubar != None: for w in walk(self.menubar): if w.get_name() == name: item = w self.menu_items[name] = item break if item != None and item.get_sensitive(): return item return None def find_loc_entry(self, widget): for w in walk(widget): if w.get_name() == 'NautilusLocationEntry': return w def get_selection(self): focus = self.window.get_focus() if not isinstance(focus, Gtk.TreeView) and \ focus.get_parent().get_name() == 'NautilusListView': return [] def collect_uris(treemodel, path, iter, uris): uris.append(treemodel[iter][0].get_uri()) uris = [] focus.get_selection().selected_foreach(collect_uris, uris) return uris def show_dialog(self, title, message): md = Gtk.MessageDialog(parent=self.window, title=title) md.set_property('message-type', Gtk.MessageType.QUESTION) md.set_markup(message) md.add_button(Gtk.STOCK_OK, Gtk.ResponseType.OK) md.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL) result = md.run() md.destroy() return result == Gtk.ResponseType.OK def on_copy(self, accel_group, acceleratable, keyval, modifier): with catch_all(): item = self.get_menu_item('Copy to next pane') if item != None and self.show_dialog('Copy', 'Do you want to copy selected files/directories?'): item.activate() return True def on_move(self, accel_group, acceleratable, keyval, modifier): with catch_all(): item = self.get_menu_item('Move to next pane') if item != None and self.show_dialog('Move', 'Do you want to move selected files/directories?'): item.activate() return True def on_delete(self, accel_group, acceleratable, keyval, modifier): with catch_all(): item = self.get_menu_item('Trash') if item != None and self.show_dialog('Delete', 'Do you want to move selected files/directories to trash?'): item.activate() return True def on_edit(self, accel_group, acceleratable, keyval, modifier): with catch_all(): selection = self.get_selection() logging.debug("on_edit: %s", selection) subprocess.Popen([EDITOR] + selection) return True def get_location(self): w = self.window.get_focus() while w != None: if w == self.main_paned.get_child1(): entry = self.loc_entry1 break if w == self.main_paned.get_child2(): entry = self.loc_entry2 break w = w.get_parent() return entry.get_text() def on_terminal(self, accel_group, acceleratable, keyval, modifier): with catch_all(): location = self.get_location() logging.debug('on_terminal: location=%s', location) terminal = GConf.Client.get_default().get_string(TERMINAL_KEY) subprocess.Popen([terminal], cwd=location) return True def on_git(self, accel_group, acceleratable, keyval, modifier): with catch_all(): location = self.get_location() logging.debug('on_git: location=%s', location) subprocess.Popen([GIT_CLIENT], cwd=location) return True def show_keyboard_shortcuts_dialog(self, widget): global shortcuts_dialog if shortcuts_dialog: shortcuts_dialog.present() return with catch_all(): shortcuts_dialog = KeyboardShortcutsDialog(self.window) shortcuts_dialog.show_all() shortcuts_dialog.run() shortcuts_dialog.destroy() shortcuts_dialog = None class WidgetProvider(GObject.GObject, Nautilus.LocationWidgetProvider): def __init__(self): with catch_all(): self._loaded_accels = False self._window_agents = {} if DEBUG: # The nautilus_debug package is only imported in DEBUG mode to # avoid dependency on twisted for normal use. from nautilus_debug import SSHThread SSHThread(self._window_agents).start() def get_widget(self, uri, window): with catch_all(): if not self._loaded_accels: self._loaded_accels = True load_accels(ACCEL_FILE_NAME) if uri == "x-nautilus-desktop:///": return None agent = self._window_agents.get(window) if agent != None: return None window.connect("destroy", lambda w: self._window_agents.pop(w)) agent = WindowAgent(window) self._window_agents[window] = agent return None class CompareMenuProvider(GObject.GObject, Nautilus.MenuProvider): def on_compare(self, menu, files): subprocess.Popen([DIFF, get_filename(files[0]), get_filename(files[1])]) def get_file_items(self, window, files): if len(files) != 2: return if not has_file_scheme(files[0]) or not has_file_scheme(files[1]): return item = Nautilus.MenuItem( name='SimpleMenuExtension::Compare_Files', label='Compare...', tip='Compare...') item.connect('activate', self.on_compare, files) return [item]