# -*- coding: utf-8 -*- # Copyright 2008 Jaap Karssenberg '''This module contains the Gtk user interface for zim. The main widgets and dialogs are seperated out in sub-modules. Included here are the main class for the zim GUI, which contains most action handlers and the main window class. The GUI uses a few mechanisms for other classes to dynamically add elements. One is the use of the gtk.UIManager class to populate the menubar and toolbar. This allows other parts of the application to define additional actions. See the methods add_actions() and add_ui() for wrappers around this functionality. A second mechanism is that for simple options other classes can register a preference to be shown in the PreferencesDialog. See the register_prererences() mmethod. NOTE: the plugin base class has it's own wrappers for these things. Plugin writers should look there first. To define dialogs in the GUI please use one of the Dialog, FileDialog, QuestionDialog or ErrorDialog classes as base class. Especially the Dialog class contains many convenience methods to quickly setup a simple form. ''' import os import logging import gobject import gtk import zim from zim import NotebookInterface, NotebookLookupError from zim.fs import * from zim.fs import normalize_win32_share from zim.errors import Error from zim.notebook import Path, Page, PageNameError, \ resolve_default_notebook, get_notebook, get_notebook_list from zim.stores import encode_filename from zim.index import LINK_DIR_BACKWARD from zim.config import data_file, config_file, data_dirs, ListDict from zim.parsing import url_encode, URL_ENCODE_DATA, is_win32_share_re from zim.history import History, HistoryRecord from zim.gui.pathbar import NamespacePathBar, RecentPathBar, HistoryPathBar from zim.gui.pageindex import PageIndex from zim.gui.pageview import PageView from zim.gui.widgets import ui_environment, \ Button, MenuButton, \ Window, Dialog, \ ErrorDialog, QuestionDialog, FileDialog, ProgressBarDialog from zim.gui.clipboard import Clipboard from zim.gui.applications import get_application, get_default_application, CustomToolManager logger = logging.getLogger('zim.gui') ui_actions = ( ('file_menu', None, _('_File')), # T: Menu title ('edit_menu', None, _('_Edit')), # T: Menu title ('view_menu', None, _('_View')), # T: Menu title ('insert_menu', None, _('_Insert')), # T: Menu title ('search_menu', None, _('_Search')), # T: Menu title ('format_menu', None, _('For_mat')), # T: Menu title ('tools_menu', None, _('_Tools')), # T: Menu title ('go_menu', None, _('_Go')), # T: Menu title ('help_menu', None, _('_Help')), # T: Menu title ('pathbar_menu', None, _('P_athbar')), # T: Menu title ('toolbar_menu', None, _('_Toolbar')), # T: Menu title # name, stock id, label, accelerator, tooltip, readonly ('new_page', 'gtk-new', _('_New Page...'), 'N', '', False), # T: Menu item ('new_sub_page', 'gtk-new', _('New S_ub Page...'), '', '', False), # T: Menu item ('open_notebook', 'gtk-open', _('_Open Another Notebook...'), 'O', '', True), # T: Menu item ('open_new_window', None, _('Open in New _Window'), '', '', True), # T: Menu item ('import_page', None, _('_Import Page...'), '', '', False), # T: Menu item ('save_page', 'gtk-save', _('_Save'), 'S', '', False), # T: Menu item ('save_copy', None, _('Save A _Copy...'), '', '', True), # T: Menu item ('show_export', None, _('E_xport...'), '', '', True), # T: Menu item ('email_page', None, _('_Send To...'), '', '', True), # T: Menu item ('move_page', None, _('_Move Page...'), '', '', False), # T: Menu item ('rename_page', None, _('_Rename Page...'), 'F2', '', False), # T: Menu item ('delete_page', None, _('_Delete Page'), '', '', False), # T: Menu item ('show_properties', 'gtk-properties', _('Proper_ties'), '', '', True), # T: Menu item ('close', 'gtk-close', _('_Close'), 'W', '', True), # T: Menu item ('quit', 'gtk-quit', _('_Quit'), 'Q', '', True), # T: Menu item ('show_search', 'gtk-find', _('_Search...'), 'F', '', True), # T: Menu item ('show_search_backlinks', None, _('Search _Backlinks...'), '', '', True), # T: Menu item ('copy_location', None, _('Copy Location'), 'L', '', True), # T: Menu item ('show_preferences', 'gtk-preferences', _('Pr_eferences'), '', '', True), # T: Menu item ('reload_page', 'gtk-refresh', _('_Reload'), 'R', '', True), # T: Menu item ('open_attachments_folder', 'gtk-open', _('Open Attachments _Folder'), '', '', True), # T: Menu item ('open_notebook_folder', 'gtk-open', _('Open _Notebook Folder'), '', '', True), # T: Menu item ('open_document_root', 'gtk-open', _('Open _Document Root'), '', '', True), # T: Menu item ('open_document_folder', 'gtk-open', _('Open _Document Folder'), '', '', True), # T: Menu item ('attach_file', 'zim-attachment', _('Attach _File'), '', _('Attach external file'), False), # T: Menu item ('edit_page_source', 'gtk-edit', _('Edit _Source'), '', '', False), # T: Menu item ('show_server_gui', None, _('Start _Web Server'), '', '', True), # T: Menu item ('reload_index', None, _('Re-build Index'), '', '', False), # T: Menu item ('manage_custom_tools', 'gtk-preferences', _('Custom _Tools'), '', '', True), # T: Menu item ('open_page_back', 'gtk-go-back', _('_Back'), 'Left', _('Go page back'), True), # T: Menu item ('open_page_forward', 'gtk-go-forward', _('_Forward'), 'Right', _('Go page forward'), True), # T: Menu item ('open_page_parent', 'gtk-go-up', _('_Parent'), 'Up', _('Go to parent page'), True), # T: Menu item ('open_page_child', 'gtk-go-down', _('_Child'), 'Down', _('Go to child page'), True), # T: Menu item ('open_page_previous', None, _('_Previous in index'), 'Page_Up', _('Go to previous page'), True), # T: Menu item ('open_page_next', None, _('_Next in index'), 'Page_Down', _('Go to next page'), True), # T: Menu item ('open_page_home', 'gtk-home', _('_Home'), 'Home', _('Go home'), True), # T: Menu item ('open_page', 'gtk-jump-to', _('_Jump To...'), 'J', '', True), # T: Menu item ('show_help', 'gtk-help', _('_Contents'), 'F1', '', True), # T: Menu item ('show_help_faq', None, _('_FAQ'), '', '', True), # T: Menu item ('show_help_keys', None, _('_Keybindings'), '', '', True), # T: Menu item ('show_help_bugs', None, _('_Bugs'), '', '', True), # T: Menu item ('show_about', 'gtk-about', _('_About'), '', '', True), # T: Menu item ) ui_toggle_actions = ( # name, stock id, label, accelerator, tooltip, initial state, readonly ('toggle_toolbar', None, _('_Toolbar'), 'M', '', True, True), # T: Menu item ('toggle_statusbar', None, _('_Statusbar'), None, '', True, True), # T: Menu item ('toggle_sidepane', 'gtk-index', _('_Index'), 'F9', _('Show index'), True, True), # T: Menu item ('toggle_fullscreen', 'gtk-fullscreen', _('_Fullscreen'), 'F11', '', False, True), # T: Menu item ('toggle_readonly', 'gtk-edit', _('Notebook _Editable'), '', _('Toggle notebook editable'), True, True), # T: menu item ) ui_pathbar_radio_actions = ( # name, stock id, label, accelerator, tooltip ('set_pathbar_none', None, _('_None'), None, None, 0), # T: Menu item ('set_pathbar_recent', None, _('_Recent pages'), None, None, 1), # T: Menu item ('set_pathbar_history', None, _('_History'), None, None, 2), # T: Menu item ('set_pathbar_path', None, _('N_amespace'), None, None, 3), # T: Menu item ) PATHBAR_NONE = 'none' PATHBAR_RECENT = 'recent' PATHBAR_HISTORY = 'history' PATHBAR_PATH = 'path' ui_toolbar_style_radio_actions = ( # name, stock id, label, accelerator, tooltip ('set_toolbar_icons_and_text', None, _('Icons _And Text'), None, None, 0), # T: Menu item ('set_toolbar_icons_only', None, _('_Icons Only'), None, None, 1), # T: Menu item ('set_toolbar_text_only', None, _('_Text Only'), None, None, 2), # T: Menu item ) ui_toolbar_size_radio_actions = ( # name, stock id, label, accelerator, tooltip ('set_toolbar_icons_large', None, _('_Large Icons'), None, None, 0), # T: Menu item ('set_toolbar_icons_small', None, _('_Small Icons'), None, None, 1), # T: Menu item ('set_toolbar_icons_tiny', None, _('_Tiny Icons'), None, None, 2), # T: Menu item ) TOOLBAR_ICONS_AND_TEXT = 'icons_and_text' TOOLBAR_ICONS_ONLY = 'icons_only' TOOLBAR_TEXT_ONLY = 'text_only' TOOLBAR_ICONS_LARGE = 'large' TOOLBAR_ICONS_SMALL = 'small' TOOLBAR_ICONS_TINY = 'tiny' ui_preferences = ( # key, type, category, label, default ('tearoff_menus', 'bool', 'Interface', _('Add \'tearoff\' strips to the menus'), False), # T: Option in the preferences dialog ('toggle_on_ctrlspace', 'bool', 'Interface', _('Use to switch to the side pane\n(If disabled you can still use )'), False), # T: Option in the preferences dialog # default value is False because this is mapped to switch between # char sets in certain international key mappings ) if ui_environment['platform'].startswith('maemo'): # Maemo specific settngs ui_preferences = ( # key, type, category, label, default ('tearoff_menus', 'bool', None, None, False), # Maemo can't have tearoff_menus ('toggle_on_ctrlspace', 'bool', None, None, True), # There is no ALT key on maemo devices ) # Load custom application icons as stock def load_zim_stock_icons(): factory = gtk.IconFactory() factory.add_default() for dir in data_dirs(('pixmaps')): for file in dir.list(): if not file.endswith('.png'): continue # no all installs have svg support.. name = 'zim-'+file[:-4] # e.g. checked-box.png -> zim-checked-box try: pixbuf = gtk.gdk.pixbuf_new_from_file(str(dir+file)) set = gtk.IconSet(pixbuf=pixbuf) factory.add(name, set) except Exception: logger.exception('Got exception while loading application icons') load_zim_stock_icons() KEYVAL_ESC = gtk.gdk.keyval_from_name('Escape') def schedule_on_idle(function, args=()): '''Helper function to schedule stuff that can be done later''' def callback(): function(*args) return False # delete signal gobject.idle_add(callback) class NoSuchFileError(Error): description = _('The file or folder you specified does not exist.\nPlease check if you the path is correct.') # T: Error description for "no such file or folder" def __init__(self, path): self.msg = _('No such file or folder: %s') % path.path # T: Error message, %s will be the file path class RLock(object): '''Kind of re-entrant lock that keeps a stack count''' __slots__ = ('count',) def __init__(self): self.count = 0 def __nonzero__(self): return self.count > 0 def increment(self): self.count += 1 def decrement(self): if self.count == 0: raise AssertionError, 'BUG: RLock count can not go below zero' self.count -= 1 class GtkInterface(NotebookInterface): '''Main class for the zim Gtk interface. This object wraps a single notebook and provides actions to manipulate and access this notebook. Signals: * open-page (page, path) Called when opening another page, see open_page() for details * close-page (page) Called when closing a page, typically just before a new page is opened and before closing the application * preferences-changed Emitted after the user changed the preferences (typically triggered by the preferences dialog) * read-only-changed Emitted when the ui changed from read-write to read-only or back * quit Emitted when the application is about to quit Also see signals in zim.NotebookInterface ''' # define signals we want to use - (closure type, return type and arg types) __gsignals__ = { 'open-page': (gobject.SIGNAL_RUN_LAST, None, (object, object)), 'close-page': (gobject.SIGNAL_RUN_LAST, None, (object,)), 'preferences-changed': (gobject.SIGNAL_RUN_LAST, None, ()), 'readonly-changed': (gobject.SIGNAL_RUN_LAST, None, ()), 'quit': (gobject.SIGNAL_RUN_LAST, None, ()), } ui_type = 'gtk' def __init__(self, notebook=None, page=None, fullscreen=False, geometry=None, usedaemon=False): assert not (page and notebook is None), 'BUG: can not give page while notebook is None' NotebookInterface.__init__(self) self._finalize_ui = False self.preferences_register = ListDict() self.page = None self.history = None self._autosave_lock = RLock() # used to prevent autosave triggering while we are # doing a (async) save, or when we have an error during # saving. self.readonly = False self.usedaemon = usedaemon self.hideonclose = False if ui_environment['platform'].startswith('maemo'): #Maemo gtk UI bugfix: expander-size is set to 0 gtk.rc_parse_string('''style "toolkit" { GtkTreeView::expander-size = 12 } class "GtkTreeView" style "toolkit" ''') logger.debug('Gtk version is %s' % str(gtk.gtk_version)) logger.debug('Pygtk version is %s' % str(gtk.pygtk_version)) icon = data_file('zim.png').path gtk.window_set_default_icon(gtk.gdk.pixbuf_new_from_file(icon)) self.uimanager = gtk.UIManager() self.uimanager.add_ui_from_string(''' ''') self.register_preferences('GtkInterface', ui_preferences) # Hidden setting to force the gtk bell off. Otherwise it # can bell every time you reach the begin or end of the text # buffer. Especially specific gtk version on windows. # See bug lp:546920 self.preferences['GtkInterface'].setdefault('gtk_bell', False) if not self.preferences['GtkInterface']['gtk_bell']: gtk.rc_parse_string('gtk-error-bell = 0') # Set default applications apps = { 'email_client': ['xdg-email', 'startfile'], 'file_browser': ['xdg-open', 'startfile'], 'web_browser': ['xdg-open', 'startfile'] } if ui_environment['platform'].startswith('maemo'): apps = { 'email_client': ['modest'], 'file_browser': ['hildon-mime-summon'], 'web_browser': ['webbrowser'] } for type in apps.keys(): prefs = self.preferences['GtkInterface'] if type in prefs and prefs[type] \ and isinstance(prefs[type], basestring): pass # preference is set, no need to set default else: from zim.gui.applications import get_helper_applications key = None helpers = get_helper_applications(type) keys = [entry.key for entry in helpers] for k in apps[type]: # prefered keys if k in keys: key = k break if key is None: if helpers: key = helpers[0].key else: key = 'none' prefs.setdefault(type, key) self.mainwindow = MainWindow(self, fullscreen, geometry) self.add_actions(ui_actions, self) self.add_toggle_actions(ui_toggle_actions, self.mainwindow) self.add_radio_actions(ui_pathbar_radio_actions, self.mainwindow, 'do_set_pathbar') self.add_radio_actions(ui_toolbar_style_radio_actions, self.mainwindow, 'do_set_toolbar_style') self.add_radio_actions(ui_toolbar_size_radio_actions, self.mainwindow, 'do_set_toolbar_size') # Allow menubar/toolbar to customized for different platforms if ui_environment['platform']: fname = 'menubar-'+ui_environment['platform']+'.xml' else: fname = 'menubar.xml' self.add_ui(data_file(fname).read(), self) if ui_environment['platform'].startswith('maemo'): # Hardware fullscreen key is F6 in N8xx devices #group = gtk.AccelGroup() #group.connect_group( # gtk.gdk.keyval_from_name('F6'), 0, gtk.ACCEL_VISIBLE, # lambda o: self.mainwindow.toggle_fullscreen() ) #self.mainwindow.add_accel_group(group) # AccelGroup doesn't seem to work, so do it with keypress events self.mainwindow.connect('key-press-event', lambda o, event: event.keyval == gtk.keysyms.F6 and self.mainwindow.toggle_fullscreen()) self.load_plugins() self._custom_tool_ui_id = None self._custom_tool_actiongroup = None self._custom_tool_iconfactory = None self.load_custom_tools() self.uimanager.ensure_update() # prevent flashing when the toolbar is after showing the window # and do this before connecting signal below for accelmap # this has to run before moving the menu in maemo enviroments # so the menuitems are linked to the menubar if ui_environment['platform'].startswith('maemo'): # Move the menu to the hildon menu menu = gtk.Menu() for child in self.mainwindow.menubar.get_children(): child.reparent(menu) self.mainwindow.set_menu(menu) # This works when plugins load submenu items, # e.g. when they are enabled from the preferences menu, # as long as they do not change the main menubar self.mainwindow.menubar.hide() # Localize the fullscreen button in the toolbar for i in range(self.mainwindow.toolbar.get_n_items()): self.fsbutton = None toolitem = self.mainwindow.toolbar.get_nth_item(i) if isinstance(toolitem, gtk.ToolButton): if toolitem.get_stock_id() == 'gtk-fullscreen': self.fsbutton = toolitem self.fsbutton.tap_and_hold_setup(menu) # attach app menu to fullscreen button for N900 break accelmap = config_file('accelmap').file logger.debug('Accelmap: %s', accelmap.path) if accelmap.exists(): gtk.accel_map_load(accelmap.path) def on_accel_map_changed(o, path, key, mod): logger.info('Accelerator changed for %s', path) gtk.accel_map_save(accelmap.path) gtk.accel_map_get().connect('changed', on_accel_map_changed) self.do_preferences_changed() # Deal with commandline arguments for notebook and page if notebook: self.open_notebook(notebook) if self.notebook is None: # Exit the program before reaching main() raise Exception, 'Could not open notebook: %s' % notebook if page: if isinstance(page, basestring): page = self.notebook.resolve_path(page) if not page is None: self.open_page(page) else: assert isinstance(page, Path) self.open_page(page) else: pass # Will check default in main() def load_plugin(self, name): plugin = NotebookInterface.load_plugin(self, name) if plugin and self._finalize_ui: plugin.finalize_ui(self) def spawn(self, *args): if not self.usedaemon: args = args + ('--no-daemon',) NotebookInterface.spawn(self, *args) def main(self): '''Wrapper for gtk.main(); does not return until program has ended.''' if self.notebook is None: import zim.gui.notebookdialog notebook = zim.gui.notebookdialog.prompt_notebook() if notebook: self.open_notebook(notebook) else: # User cancelled notebook dialog return if self.notebook.dir: os.chdir(self.notebook.dir.path) os.environ['PWD'] = self.notebook.dir.path if self.page is None: path = self.history.get_current() if path: self.open_page(path) else: self.open_page_home() # We schedule the autosave on idle to try to make it impact # the performance of the applciation less. Of course using the # async interface also helps, but we need to account for cases # where asynchronous actions are not supported. def autosave(): page = self.mainwindow.pageview.get_page() if page.modified and not self._autosave_lock: self.save_page_async(page) def schedule_autosave(): schedule_on_idle(autosave) return True # keep ticking # older gobject version doesn't know about seconds self._autosave_timer = gobject.timeout_add(5000, schedule_autosave) self._finalize_ui = True for plugin in self.plugins: plugin.finalize_ui(self) self.check_notebook_needs_upgrade() self.mainwindow.show_all() self.mainwindow.pageview.grab_focus() gtk.main() def present(self, page=None, fullscreen=None, geometry=None): '''Present a specific page the main window and/or set window mode''' self.mainwindow.present() if page: if isinstance(page, basestring): page = Path(page) self.open_page(page) if geometry: self.mainwindow.parse_geometry(geometry) elif fullscreen: self.mainwindow.toggle_fullscreen(show=True) def toggle_present(self): '''Present main window if it is not on top, but hide if it is. Used by the TrayIcon to toggle visibility of the window. ''' if self.mainwindow.is_active(): self.mainwindow.hide() else: self.mainwindow.present() def hide(self): '''Hide the main window (this is not the same as minimize)''' self.mainwindow.hide() def close(self): if self.hideonclose: self.hide() else: self.quit() def quit(self): # TODO: logic to hide the window if not self.close_page(self.page): # Do not quit if page not saved return self.emit('quit') if self.uistate.modified: self.uistate.write() self.mainwindow.destroy() gtk.main_quit() def add_actions(self, actions, handler, methodname=None): '''Wrapper for gtk.ActionGroup.add_actions(actions), "handler" is the object that has the methods for these actions. Each action is mapped to a like named method of the handler object. If the object not yet has an actiongroup this is created first, attached to the uimanager and put in the "actiongroup" attribute. ''' assert isinstance(actions[0], tuple), 'BUG: actions should be list of tupels' group = self.init_actiongroup(handler) group.add_actions([a[0:5] for a in actions]) self._connect_actions(actions, group, handler) def add_toggle_actions(self, actions, handler): '''Wrapper for gtk.ActionGroup.add_toggle_actions(actions), "handler" is the object that has the methods for these actions. Differs for add-actions() in that in the mapping from action name to method name is prefixed with "do_". The reason for this is that in order to keep the state of toolbar and menubar widgets stays in sync with the internal state. Therefore the method of the same name as the action should just call activate() on the action, while the actual logic is implamented in the handler which is prefixed with "do_". ''' assert isinstance(actions[0], tuple), 'BUG: actions should be list of tupels' group = self.init_actiongroup(handler) group.add_toggle_actions([a[0:5]+(None,)+(a[5],) for a in actions]) # insert 'None' for callback self._connect_actions(actions, group, handler, is_toggle=True) def init_actiongroup(self, handler): '''Initializes the actiongroup for 'handler' if it does not already exist and returns the actiongroup. ''' if not hasattr(handler, 'actiongroup') or handler.actiongroup is None: name = handler.__class__.__name__ handler.actiongroup = gtk.ActionGroup(name) self.uimanager.insert_action_group(handler.actiongroup, 0) return handler.actiongroup def remove_actiongroup(self, handler): '''Remove the actiongroup for 'handler' and thereby all actions''' if hasattr(handler, 'actiongroup') and handler.actiongroup: self.uimanager.remove_action_group(handler.actiongroup) handler.actiongroup = None @staticmethod def _log_action(action, *a): logger.debug('Action: %s', action.get_name()) def _connect_actions(self, actions, group, handler, is_toggle=False): for name, readonly in [(a[0], a[-1]) for a in actions if not a[0].endswith('_menu')]: action = group.get_action(name) action.zim_readonly = readonly if is_toggle: name = 'do_' + name assert hasattr(handler, name), 'No method defined for action %s' % name method = getattr(handler.__class__, name) action.connect('activate', self._log_action) action.connect_object('activate', method, handler) if self.readonly and not action.zim_readonly: action.set_sensitive(False) def add_radio_actions(self, actions, handler, methodname): '''Wrapper for gtk.ActionGroup.add_radio_actions(actions), "handler" is the object that these actions belong to and "methodname" gives the callback to be called on changes in this group. (See doc on gtk.RadioAction 'changed' signal for this callback.) ''' # A bit different from the other two methods since radioactions # come in mutual exclusive groups. Only need to connect to one # action to get signals from whole group. assert isinstance(actions[0], tuple), 'BUG: actions should be list of tupels' assert hasattr(handler, methodname), 'No such method %s' % methodname group = self.init_actiongroup(handler) group.add_radio_actions(actions) method = getattr(handler.__class__, methodname) action = group.get_action(actions[0][0]) action.connect('changed', self._log_action) action.connect_object('changed', method, handler) def add_ui(self, xml, handler): '''Wrapper for gtk.UIManager.add_ui_from_string(xml)''' id = self.uimanager.add_ui_from_string(xml) if hasattr(handler, '_ui_merge_ids') and handler._ui_merge_ids: handler._ui_merge_ids += (id,) else: handler._ui_merge_ids = (id,) return id def remove_ui(self, handler, id=None): '''Remove the ui definition(s) for a specific handler. If an id is given, only removes that ui part, else removes all ui parts defined for this handler. ''' if id: self.uimanager.remove_ui(id) if hasattr(handler, '_ui_merge_ids'): handler._ui_merge_ids = \ filter(lambda i: i != id, handler._ui_merge_ids) else: if hasattr(handler, '_ui_merge_ids'): for id in handler._ui_merge_ids: self.uimanager.remove_ui(id) handler._ui_merge_ids = None def set_readonly(self, readonly): if not self.readonly: # Save any modification now - will not be allowed after switch page = self.mainwindow.pageview.get_page() if page and page.modified: self.save_page(page) for group in self.uimanager.get_action_groups(): for action in group.list_actions(): if hasattr(action, 'zim_readonly') \ and not action.zim_readonly: action.set_sensitive(not readonly) self.readonly = readonly self.emit('readonly-changed') def register_preferences(self, section, preferences): '''Registers user preferences. Registering means that a preference will show up in the preferences dialog. The section given is the section to locate these preferences in the config file. Each preference is a tuple consisting of: * the key in the config file * an option type (see Dialog.add_fields() for more details) * a category (the tab in which the option will be shown) * a label to show in the dialog * a default value ''' register = self.preferences_register for p in preferences: key, type, category, label, default = p self.preferences[section].setdefault(key, default) # Preferences with None category won't be shown in the preferences dialog if category: register.setdefault(category, []) register[category].append((section, key, type, label)) def get_path_context(self): '''Returns the current 'context' for actions that want a path to start with. Asks the mainwindow for a selected page, defaults to the current page if any. ''' return self.mainwindow.get_selected_path() or self.page def open_notebook(self, notebook=None): '''Open a new notebook. If this is the first notebook the open-notebook signal is emitted and the notebook is opened in this process. Otherwise we let another instance handle it. If notebook=None the notebookdialog is run to prompt the user.''' if not self.notebook: assert not notebook is None, 'BUG: first initialize notebook' try: page = NotebookInterface.open_notebook(self, notebook) except NotebookLookupError, error: ErrorDialog(self, error).run() else: if page: self.open_page(page) elif notebook is None: # Handle menu item for 'open another notebook' from zim.gui.notebookdialog import NotebookDialog NotebookDialog.unique(self, self, callback=self.open_notebook).show() # implicit recurs else: # Could be call back from open notebook dialog # We are already intialized, so let another process handle it if self.usedaemon: from zim.daemon import DaemonProxy notebook = DaemonProxy().get_notebook(notebook) notebook.present() else: self.spawn(notebook) def do_open_notebook(self, notebook): '''Signal handler for open-notebook.''' def move_away(o, path): if path == self.page or self.page.ischild(path): self.open_page_back() \ or self.open_page_parent \ or self.open_page_home def follow(o, path, newpath, update_links): if self.page == path: self.open_page(newpath) elif self.page.ischild(path): newpath = newpath + self.page.relname(path) newpath = Path(newpath.name) # IndexPath -> Path self.open_page(newpath) def autosave(o, p, *a): # Here we explicitly do not save async # and also explicitly no need for _autosave_lock page = self.mainwindow.pageview.get_page() if p == page and page.modified: self.save_page(page) NotebookInterface.do_open_notebook(self, notebook) self.history = History(notebook, self.uistate) self.on_notebook_properties_changed(notebook) notebook.connect('properties-changed', self.on_notebook_properties_changed) notebook.connect('delete-page', autosave) # before action notebook.connect('move-page', autosave) # before action notebook.connect('deleted-page', move_away) notebook.connect('moved-page', follow) # Start a lightweight background check of the index self.notebook.index.update(background=True, checkcontents=False) self.set_readonly(notebook.readonly) def check_notebook_needs_upgrade(self): if not self.notebook.needs_upgrade: return ok = QuestionDialog(None, ( _('Upgrade Notebook?'), # T: Short question for question prompt _('This notebook was created by an older of version of zim.\n' 'Do you want to upgrade it to the latest version now?\n\n' 'Upgrading will take some time and may make various changes\n' 'to the notebook. In general it is a good idea to make a\n' 'backup before doing this.\n\n' 'If you choose not to upgrade now, some features\n' 'may not work as expected') # T: Explanation for question to upgrade notebook ) ).run() if not ok: return dialog = ProgressBarDialog(self, _('Upgrading notebook')) # T: Title of progressbar dialog dialog.show_all() self.notebook.index.ensure_update(callback=lambda p: dialog.pulse(p.name)) dialog.set_total(self.notebook.index.n_all_pages()) self.notebook.upgrade_notebook(callback=lambda p: dialog.pulse(p.name)) dialog.destroy() def on_notebook_properties_changed(self, notebook): has_doc_root = not notebook.get_document_root() is None for action in ('open_document_root', 'open_document_folder'): action = self.actiongroup.get_action(action) action.set_sensitive(has_doc_root) def open_page(self, path=None): '''Emit the open-page signal. The argument 'path' can either be a Page or a Path object. If 'page' is None a dialog is shown to specify the page. If 'path' is a HistoryRecord we assume that this call is the result of a history action and the page is not added to the history. The original path object is given as the second argument in the signal, so handlers can inspect how this method was called. ''' assert self.notebook if path is None: # the dialog will call us in turn with an argument return OpenPageDialog(self).run() assert isinstance(path, Path) if isinstance(path, Page) and path.valid: page = path else: page = self.notebook.get_page(path) if self.page and id(self.page) == id(page): # Check ID to enable reload_page but catch all other # redundant calls. return elif self.page: if not self.close_page(self.page): raise AssertionError, 'Could not close page' # assert statement could be optimized away logger.info('Open page: %s (%s)', page, path) self.emit('open-page', page, path) def do_open_page(self, page, path): '''Signal handler for open-page.''' is_first_page = self.page is None self.page = page back = self.actiongroup.get_action('open_page_back') forward = self.actiongroup.get_action('open_page_forward') parent = self.actiongroup.get_action('open_page_parent') child = self.actiongroup.get_action('open_page_child') if isinstance(path, HistoryRecord): historyrecord = path self.history.set_current(path) back.set_sensitive(not path.is_first) forward.set_sensitive(not path.is_last) else: self.history.append(page) historyrecord = self.history.get_current() back.set_sensitive(not is_first_page) forward.set_sensitive(False) if historyrecord and not historyrecord.cursor == None: self.mainwindow.pageview.set_cursor_pos(historyrecord.cursor) self.mainwindow.pageview.set_scroll_pos(historyrecord.scroll) parent.set_sensitive(len(page.namespace) > 0) child.set_sensitive(page.haschildren) def close_page(self, page=None): '''Emits the 'close-page' signal and returns boolean for success''' if page is None: page = self.page self.emit('close-page', page) return not page.modified def do_close_page(self, page): if page.modified: self.save_page(page) # No async here -- for now current = self.history.get_current() if current == page: current.cursor = self.mainwindow.pageview.get_cursor_pos() current.scroll = self.mainwindow.pageview.get_scroll_pos() if self.uistate.modified: schedule_on_idle(self.uistate.write_async) def open_page_back(self): record = self.history.get_previous() if not record is None: self.open_page(record) return True else: return False def open_page_forward(self): record = self.history.get_next() if not record is None: self.open_page(record) return True else: return False def open_page_parent(self): namespace = self.page.namespace if namespace: self.open_page(Path(namespace)) return True else: return False def open_page_child(self): if not self.page.haschildren: return False record = self.history.get_child(self.page) if not record is None: self.open_page(record) else: pages = list(self.notebook.index.list_pages(self.page)) self.open_page(pages[0]) return True def open_page_previous(self): path = self.notebook.index.get_previous(self.page) if not path is None: self.open_page(path) return True else: return False def open_page_next(self): path = self.notebook.index.get_next(self.page) if not path is None: self.open_page(path) return True else: return False def open_page_home(self): self.open_page(self.notebook.get_home_page()) def new_page(self): '''opens a dialog like 'open_page(None)'. Subtle difference is that this page is saved directly, so it is pesistent if the user navigates away without first adding content. Though subtle this is expected behavior for users not yet fully aware of the automatic create/save/delete behavior in zim. ''' NewPageDialog(self).run() def new_sub_page(self): '''Same as new_page() but sets the namespace widget one level deeper''' NewPageDialog(self, path=self.get_path_context(), subpage=True).run() def new_page_from_text(self, text, name=None): if not name: name = text.strip()[:30] if '\n' in name: name, _ = name.split('\n', 1) name = self.notebook.cleanup_pathname( name.replace(':', ''), purge=True) path = self.notebook.resolve_path(name) page = self.notebook.get_new_page(path) page.parse('plain', text) self.notebook.store_page(page) self.open_page(page) def open_new_window(self, page=None): '''Open page in a new window''' if page is None: page = self.get_path_context() PageWindow(self, page).show_all() def save_page(self, page=None): '''Save 'page', or current page when 'page' is None. Returns boolean for success. ''' page = self._save_page_check_page(page) if page is None: return logger.debug('Saving page: %s', page) try: self.notebook.store_page(page) except Exception, error: logger.exception('Failed to save page: %s', page.name) self._autosave_lock.increment() # We need this flag to prevent autosave trigger while we # are showing the SavePageErrorDialog SavePageErrorDialog(self, error, page).run() self._autosave_lock.decrement() return not page.modified def save_page_async(self, page=None): page = self._save_page_check_page(page) if page is None: return logger.debug('Saving page (async): %s', page) def callback(ok, error, exc_info, name): # This callback is called back here in the main thread. # We fetch the page again just to be sure in case of strange # edge cases. The SavePageErrorDialog will just take the # current state of the page, not the state that it had in # the async thread. This is done on purpose, current state # is what the user is concerned with anyway. #~ print '!!', ok, exc_info, name if exc_info: page = self.notebook.get_page(Path(name)) logger.error('Failed to save page: %s', page.name, exc_info=exc_info) SavePageErrorDialog(self, error, page).run() self._autosave_lock.decrement() self._autosave_lock.increment() # Prevent any new auto save to be scheduled while we are # still busy with this call. self.notebook.store_page_async(page, callback=callback, data=page.name) def _save_page_check_page(self, page): # Code shared between save_page() and save_page_async() try: assert not self.readonly, 'BUG: can not save page when read-only' if page is None: page = self.mainwindow.pageview.get_page() assert not page.readonly, 'BUG: can not save read-only page' except Exception, error: SavePageErrorDialog(self, error, page).run() return None else: return page def save_copy(self): '''Offer to save a copy of a page in the source format, so it can be imported again later. Subtly different from export. ''' SaveCopyDialog(self).run() def show_export(self): from zim.gui.exportdialog import ExportDialog ExportDialog(self).run() def email_page(self): text = ''.join(self.page.dump(format='plain')) url = 'mailto:?subject=%s&body=%s' % ( url_encode(self.page.name, mode=URL_ENCODE_DATA), url_encode(text, mode=URL_ENCODE_DATA), ) self.open_url(url) def import_page(self): '''Import a file from outside the notebook as a new page.''' ImportPageDialog(self).run() def move_page(self, path=None): '''Prompt dialog for moving a page''' MovePageDialog(self, path=path).run() def do_move_page(self, path, newpath, update_links): '''Callback for MovePageDialog and PageIndex for executing notebook.move_page but wrapping with all the proper exception dialogs. Returns boolean for success. ''' return self._wrap_move_page( lambda update_links, callback: self.notebook.move_page( path, newpath, update_links, callback), update_links ) def rename_page(self, path=None): '''Prompt a dialog for renaming a page''' RenamePageDialog(self, path=path).run() def do_rename_page(self, path, newbasename, update_heading=True, update_links=True): '''Callback for RenamePageDialog for executing notebook.rename_page but wrapping with all the proper exception dialogs. Returns boolean for success. ''' return self._wrap_move_page( lambda update_links, callback: self.notebook.rename_page( path, newbasename, update_heading, update_links, callback), update_links ) def _wrap_move_page(self, func, update_links): if self.notebook.index.updating: # Ask regardless of update_links because it might very # well be that the dialog thinks there are no links # but they are simply not indexed yet cont = QuestionDialog(dialog or self, _('The index is still busy updating. Until this ' 'is finished links can not be updated correctly. ' 'Performing this action now could break links, ' 'do you want to continue anyway?' ) # T: question dialog text ).run() if cont: update_links = False else: return False dialog = ProgressBarDialog(self, _('Updating Links')) # T: Title of progressbar dialog dialog.show_all() callback = lambda p, **kwarg: dialog.pulse(p.name, **kwarg) try: func(update_links, callback) except Exception, error: ErrorDialog(self, error).run() dialog.destroy() return False else: dialog.destroy() return True def delete_page(self, path=None): DeletePageDialog(self, path).run() def show_properties(self): from zim.gui.propertiesdialog import PropertiesDialog PropertiesDialog(self).run() def show_search(self, query=None): from zim.gui.searchdialog import SearchDialog SearchDialog(self, query).show_all() def show_search_backlinks(self): query = 'LinksTo: "%s"' % self.page.name self.show_search(query) def copy_location(self): '''Puts the name of the current page on the clipboard.''' Clipboard().set_pagelink(self.notebook, self.page) def show_preferences(self): from zim.gui.preferencesdialog import PreferencesDialog PreferencesDialog(self).run() def save_preferences(self): if self.preferences.modified: self.preferences.write_async() self.emit('preferences-changed') def do_preferences_changed(self): self.uimanager.set_add_tearoffs( self.preferences['GtkInterface']['tearoff_menus'] ) def reload_page(self): if self.page.modified \ and not self.save_page(self.page): raise AssertionError, 'Could not save page' # assert statement could be optimized away self.notebook.flush_page_cache(self.page) self.open_page(self.notebook.get_page(self.page)) def attach_file(self, path=None): AttachFileDialog(self, path=path).run() def open_file(self, file): '''Open either a File or a Dir in the file browser''' assert isinstance(file, (File, Dir)) if isinstance(file, (File)) and file.isdir(): file = Dir(file.path) if file.exists(): # TODO if isinstance(File) check default application for mime type # this is needed once we can set default app from "open with.." menu self._openwith( self.preferences['GtkInterface']['file_browser'], (file,) ) else: ErrorDialog(self, NoSuchFileError(file)).run() def open_url(self, url): assert isinstance(url, basestring) if url.startswith('file:/'): self.open_file(File(url)) elif url.startswith('mailto:'): self._openwith(self.preferences['GtkInterface']['email_client'], (url,)) elif url.startswith('zim+'): self.open_notebook(url) else: if is_win32_share_re.match(url): url = normalize_win32_share(url) self._openwith(self.preferences['GtkInterface']['web_browser'], (url,)) def _openwith(self, name, args): entry = get_application(name) entry.spawn(args) def open_attachments_folder(self): dir = self.notebook.get_attachments_dir(self.page) if dir is None: error = _('This page does not have an attachments folder') # T: Error message ErrorDialog(self, error).run() elif dir.exists(): self.open_file(dir) else: question = ( _('Create folder?'), # T: Heading in a question dialog for creating a folder _('The attachments folder for this page does not yet exist.\nDo you want to create it now?')) # T: Text in a question dialog for creating a folder create = QuestionDialog(self, question).run() if create: dir.touch() self.open_file(dir) def open_notebook_folder(self): if self.notebook.dir: self.open_file(self.notebook.dir) elif self.notebook.file: self.open_file(self.notebook.file.dir) else: assert False, 'BUG: notebook has neither dir or file' def open_document_root(self): dir = self.notebook.get_document_root() if dir and dir.exists(): self.open_file(dir) def open_document_folder(self): dir = self.notebook.get_document_root() if dir is None: return dirpath = encode_filename(self.page.name) dir = Dir([dir, dirpath]) if dir.exists(): self.open_file(dir) else: question = ( _('Create folder?'), # T: Heading in a question dialog for creating a folder _('The document folder for this page does not yet exist.\nDo you want to create it now?')) # T: Text in a question dialog for creating a folder create = QuestionDialog(self, question).run() if create: dir.touch() self.open_file(dir) def edit_page_source(self, page=None): '''Edit page source or source of a config file. Will keep application hanging untill done. ''' # This could also be defined as a custom tool, but defined here # because we want to determine the editor dynamically # We assume that the default app for a text file is a editor # and not e.g. a viewer or a browser. Of course users can still # define a custom tool for other editors. if not page: page = self.page if hasattr(self.page, 'source'): file = self.page.source else: ErrorDialog('This page does not have a source file').run() return if self._edit_file(file): if page == self.page: self.reload_page() def edit_config_file(self, configfile): if not configfile.file.exists(): if configfile.default.exists(): configfile.default.copyto(configfile.file) else: configfile.file.touch() self._edit_file(configfile.file) def _edit_file(self, file): # We hope the application we get here does not return before # editing is done, but unfortunately we have no way to force # this. So may e.g. open a new tab in an existing window and # return immediatly. application = get_default_application('text/plain') try: application.run((file,)) except: logger.exception('Error while running %s:', application.name) return False else: return True def show_server_gui(self): # TODO instead of spawn, include in this process self.spawn('--server', '--gui', self.notebook.uri) def reload_index(self, flush=False): '''Show a progress bar while updating the notebook index. Returns True unless the user cancelled the action. ''' # First make the index stop updating self.mainwindow.pageindex.disconnect_model() # Update the model index = self.notebook.index if flush: index.flush() dialog = ProgressBarDialog(self, _('Updating index')) # T: Title of progressbar dialog dialog.show_all() index.update(callback=lambda p: dialog.pulse(p.name)) dialog.destroy() # And reconnect the model - flushing out any sync error in treemodel self.mainwindow.pageindex.reload_model() return not dialog.cancelled def manage_custom_tools(self): from zim.gui.customtools import CustomToolManagerDialog CustomToolManagerDialog(self).run() self.load_custom_tools() def load_custom_tools(self): manager = CustomToolManager() # Remove old actions if self._custom_tool_ui_id: self.uimanager.remove_ui(self._custom_tool_ui_id) if self._custom_tool_actiongroup: self.uimanager.remove_action_group(self._custom_tool_actiongroup) if self._custom_tool_iconfactory: self._custom_tool_iconfactory.remove_default() # Load new actions actions = [] factory = gtk.IconFactory() factory.add_default() for tool in manager: icon = tool.icon if '/' in icon or '\\' in icon: # Assume icon is a file path - add it to IconFactory icon = 'zim-custom-tool' + tool.key try: pixbuf = tool.get_pixbuf(gtk.ICON_SIZE_LARGE_TOOLBAR) set = gtk.IconSet(pixbuf=pixbuf) factory.add(icon, set) except Exception: logger.exception('Got exception while loading application icons') icon = None action = (tool.key, icon, tool.name, '', tool.comment, self.exec_custom_tool) actions.append(action) self._custom_tool_iconfactory = factory self._custom_tool_actiongroup = gtk.ActionGroup('custom_tools') self._custom_tool_actiongroup.add_actions(actions) menulines = ["\n" % tool.key for tool in manager] toollines = ["\n" % tool.key for tool in manager if tool.showintoolbar] textlines = ["\n" % tool.key for tool in manager if tool.showincontextmenu == 'Text'] pagelines = ["\n" % tool.key for tool in manager if tool.showincontextmenu == 'Page'] ui = """\ %s %s %s %s """ % ( ''.join(menulines), ''.join(toollines), ''.join(textlines), ''.join(pagelines) ) self.uimanager.insert_action_group(self._custom_tool_actiongroup, 0) self._custom_tool_ui_id = self.uimanager.add_ui_from_string(ui) def exec_custom_tool(self, action): manager = CustomToolManager() tool = manager.get_tool(action.get_name()) logger.info('Execute custom tool %s', tool.name) args = (self.notebook, self.page, self.mainwindow.pageview) try: if tool.isreadonly: tool.spawn(args) else: tool.run(args) self.reload_page() #~ self.notebook.index.update(background=True) # TODO instead of using run, use spawn and show dialog # with cancel button. Dialog blocks ui. except Exception, error: ErrorDialog(self, error).run() def show_help(self, page=None): if page: self.spawn('--manual', page) else: self.spawn('--manual') def show_help_faq(self): self.show_help('FAQ') def show_help_keys(self): self.show_help('Help:Key Bindings') def show_help_bugs(self): self.show_help('Bugs') def show_about(self): gtk.about_dialog_set_url_hook(lambda d, l: self.open_url(l)) gtk.about_dialog_set_email_hook(lambda d, l: self.open_url(l)) dialog = gtk.AboutDialog() try: # since gtk 2.12 dialog.set_program_name('Zim') except AttributeError: pass dialog.set_version(zim.__version__) dialog.set_comments(_('A desktop wiki')) # T: General description of zim itself dialog.set_copyright(zim.__copyright__) dialog.set_license(zim.__license__) dialog.set_authors([zim.__author__]) dialog.set_translator_credits(_('translator-credits')) # T: This string needs to be translated with names of the translators for this language dialog.set_website(zim.__url__) dialog.run() dialog.destroy() # Need to register classes defining gobject signals gobject.type_register(GtkInterface) class MainWindow(Window): '''Main window of the application, showing the page index in the side pane and a pageview with the current page. Alse includes the menubar, toolbar, statusbar etc. ''' def __init__(self, ui, fullscreen=False, geometry=None): '''Constructor''' Window.__init__(self) self._fullscreen = False self.ui = ui ui.connect_after('open-notebook', self.do_open_notebook) ui.connect('open-page', self.do_open_page) ui.connect('close-page', self.do_close_page) ui.connect('preferences-changed', self.do_preferences_changed) self._sidepane_autoclose = False self._switch_focus_accelgroup = None # Catching this signal prevents the window to actually be destroyed # when the user tries to close it. The action for close should either # hide or destroy the window. def do_delete_event(*a): logger.debug('Action: close (delete-event)') self.hide() # look more responsive ui.close() return True # Do not destroy - let close() handle it self.connect('delete-event', do_delete_event) vbox = gtk.VBox() self.add(vbox) # setup menubar and toolbar self.add_accel_group(ui.uimanager.get_accel_group()) self.menubar = ui.uimanager.get_widget('/menubar') self.toolbar = ui.uimanager.get_widget('/toolbar') self.toolbar.connect('popup-context-menu', self.do_toolbar_popup) vbox.pack_start(self.menubar, False) vbox.pack_start(self.toolbar, False) # split window in side pane and editor self.hpane = gtk.HPaned() self.hpane.set_position(175) vbox.add(self.hpane) self.sidepane = gtk.VBox(spacing=5) self.hpane.add1(self.sidepane) self.sidepane.connect('key-press-event', lambda o, event: event.keyval == KEYVAL_ESC and self.toggle_sidepane()) self.pageindex = PageIndex(ui) self.sidepane.add(self.pageindex) vbox2 = gtk.VBox() self.hpane.add2(vbox2) self.pathbar = None self.pathbar_box = gtk.HBox() # FIXME other class for this ? self.pathbar_box.set_border_width(3) vbox2.pack_start(self.pathbar_box, False) self.pageview = PageView(ui) self.pageview.view.connect_after( 'toggle-overwrite', self.do_textview_toggle_overwrite) vbox2.add(self.pageview) # create statusbar hbox = gtk.HBox(spacing=0) vbox.pack_start(hbox, False, True, False) self.statusbar = gtk.Statusbar() if ui_environment['platform'].startswith('maemo'): # Maemo windows aren't resizeable so it makes no sense to show the resize grip self.statusbar.set_has_resize_grip(False) self.statusbar.push(0, '') hbox.add(self.statusbar) def statusbar_element(string, size): frame = gtk.Frame() frame.set_shadow_type(gtk.SHADOW_IN) self.statusbar.pack_end(frame, False) label = gtk.Label(string) label.set_size_request(size, 10) label.set_alignment(0.1, 0.5) frame.add(label) return label # specify statusbar elements right-to-left self.statusbar_style_label = statusbar_element('