Exemple #1
0
def test_CommandManager_load_shortcuts():
    from editxt.config import config_schema

    @command
    def doc(editor, opts):
        pass

    shorts = config_schema()["shortcuts"]
    menu = const.Constant("menu")
    expect = []
    tags = {doc: doc.name}
    key = lambda kv: kv[1]["rank"].default
    for i, (hotkey, value) in enumerate(sorted(shorts.items(), key=key)):
        hkey = mod.parse_hotkey(hotkey)
        title = value["name"].default
        expect.append((menu, title) + hkey)
        tags[title] = i
    items = []

    def add_menu_item(menu, title, hotkey, modifiers):
        items.append((menu, title, hotkey, modifiers))
        return tags[title]

    with test_app() as app:
        ctl = CommandManager("<history>", app=app)
        ctl.add_command(doc, None, menu)
        ctl.add_menu_item = add_menu_item
        ctl.load_shortcuts(menu)
        eq_(items, expect)
        eq_(set(ctl.commands), set(tags.values()))
Exemple #2
0
class Application(object):

    def __init__(self, profile=None):
        self.launch_fault = False
        self.launching = True
        if profile is None:
            profile = self.default_profile()
        self.profile_path = os.path.expanduser(profile)
        assert os.path.isabs(self.profile_path), \
            'profile path cannot be relative (%s)' % self.profile_path
        self.documents = DocumentController(self)
        self.terminating = False
        self.errlog = ErrorLog(self)
        self.errlog_handler = LogViewHandler(self)
        self.errlog_handler.setLevel(logging.INFO)
        self.errlog_handler.setFormatter(
            logging.Formatter("%(levelname).7s %(name)s - %(message)s"))
        with self.logger():
            self.panels = []
            self._setup_profile = set()
            self.windows = []
            self.path_opener = None
            self.config_callbacks = WeakValueDictionary()
            self.config = Config(
                os.path.join(self.profile_path, const.CONFIG_FILENAME))
            logging_config = self.config["logging_config"]
            if logging_config:
                try:
                    logging.config.dictConfig(logging_config)
                    log.debug("logging config: %r", logging_config)
                except Exception:
                    log.exception("bad logging config: %r", logging_config)
            self.context = ContextMap()
            self.syntax_factory = None
            self.theme = Theme(self.config)
            state_dir = os.path.join(self.profile_path, const.STATE_DIR)
            command_history = CommandHistory(state_dir)
            self.text_commander = CommandManager(command_history, app=self)

    @classmethod
    def name(cls):
        return fn.NSBundle.mainBundle().objectForInfoDictionaryKey_("CFBundleName")

    @classmethod
    def resource_path(cls):
        return fn.NSBundle.mainBundle().resourcePath()

    @classmethod
    def default_profile(cls):
        return '~/.' + cls.name().lower()

    @contextmanager
    def logger(self):
        root = logging.getLogger()
        root.addHandler(self.errlog_handler)
        try:
            yield self.errlog
        finally:
            root.removeHandler(self.errlog_handler)

    def init_syntax_definitions(self):
        from editxt.syntax import SyntaxFactory
        self.syntax_factory = sf = SyntaxFactory()
        paths = [(self.resource_path(), False), (self.profile_path, True)]
        for path, log_info in paths:
            path = os.path.join(path, const.SYNTAX_DEFS_DIR)
            sf.load_definitions(path, log_info)
        sf.index_definitions()

    @property
    def syntaxdefs(self):
        return self.syntax_factory.definitions

    @property
    def default_font(self, cache=[]):
        """Get the default application font

        Uses the value specified in the config file (all fields are optinal):

        ```
        font:
            face: Inconsolata
            size: 13
            smooth: true
        ```

        Falling back to the default system fixed width font for unspecified
        or unknown values.

        :returns: A `editxt.datatypes.Font` instance.
        """
        cfg = self.config["font"]
        key = (cfg["face"], cfg["size"], cfg["smooth"])
        if not (cache and cache[0] == key):
            cache[:] = [key, get_font(*key)]
        return cache[1]

    def reload_config(self):
        old_theme = self.config.get("theme")
        self.config.reload()
        self.theme.reset()
        class event:
            theme_changed = self.config.get("theme") != old_theme
        for callback in self.config_callbacks:
            # without call_later this could cause
            # [NSMachPort sendBeforeDate:] destination port invalid
            # due to forced syntax highlighting taking a long time
            call_later(0, callback, event=event)

    def on_reload_config(self, callback, owner):
        self.config_callbacks[callback] = owner

    def application_will_finish_launching(self, app, delegate):
        self.init_syntax_definitions()
        self.text_commander.load_commands(delegate.textMenu)
        self.text_commander.load_shortcuts(delegate.shortcutsMenu)
        states = list(self.iter_saved_window_states())
        errors = []
        if states:
            for state in reversed(states):
                if isinstance(state, StateLoadFailure):
                    errors.append(state)
                else:
                    self.create_window(state)
        else:
            self.create_window()
        self.launching = False
        if self.launch_fault or errors:
            self.open_error_log(set_current=False)

    def create_window(self, state=None):
        from editxt.window import Window
        if state is None:
            state_dir = os.path.join(self.profile_path, const.STATE_DIR, const.CLOSED_DIR)
            for x, path in enumerate_state_paths(state_dir, reverse=True):
                try:
                    with open(path, encoding="utf-8") as f:
                        state = load_yaml(f)
                except Exception:
                    log.warn("cannot load editor state: %s", path)
                    continue
                try:
                    os.remove(path)
                except Exception:
                    log.exception('cannot remove %s', path)
                break
        window = Window(self, state)
        self.windows.append(window)
        window.show(self)
        return window

    def open_path_dialog(self):
        if self.path_opener is None:
            opc = OpenPathController(self)
            opc.showWindow_(self)
            self.path_opener = opc
        else:
           self.path_opener.window().makeKeyAndOrderFront_(self)
        self.path_opener.populateWithClipboard()

    def new_document(self):
        self.open_documents_with_paths([None])

    def new_project(self):
        window = self.current_window()
        if window is not None:
            return window.new_project()

    def document_with_path(self, path):
        """Get a text document with the given path

        Documents returned by this method have been added to the document
        controllers list of documents.
        """
        from editxt.document import TextDocument
        docs = self.documents
        if path is None:
            # untitled document will get associated with DocumentController on save
            doc = TextDocument(self)
        else:
            doc = docs.get_document(path)
        return doc

    def open_documents_with_paths(self, paths):
        window = self.current_window()
        if window is None:
            window = self.create_window()
        return window.open_paths(paths)

    def open_config_file(self):
        items = self.open_documents_with_paths([self.config.path])
        assert len(items) == 1, items
        editor = items[0]
        assert editor.file_path == self.config.path, \
            (editor.file_path, self.config.path)
        if not (os.path.exists(editor.file_path) or editor.document.text):
            editor.document.text = self.config.default_config

    def open_error_log(self, set_current=True):
        """Open the error log document

        This method should only be called while this application's
        ``logger`` context manager is active.
        """
        doc = self.errlog.document
        try:
            editor = next(self.iter_editors_of_document(doc))
        except StopIteration:
            window = self.current_window()
            if window is None:
                window = self.create_window()
            window.insert_items([doc], focus=set_current)
        else:
            if set_current:
                self.set_current_editor(editor)

    def get_internal_document(self, name):
        if name == "errlog":
            return self.errlog.document
        raise Error("unknown internal document: {}".format(name))

    def iter_dirty_editors(self):
        seen = set()
        for window in self.iter_windows():
            for proj in window.projects:
                dirty = False
                for editor in proj.dirty_editors():
                    if editor.document.id not in seen:
                        seen.add(editor.document.id)
                        yield editor
                        dirty = True
                if dirty:
                    yield proj

    def set_current_editor(self, editor):
        window = self.find_window_with_editor(editor)
        window.current_editor = editor

    def close_current_document(self):
        window = self.current_window()
        if window is not None:
            editor = window.current_editor
            if editor is not None:
                window.close_item(editor)

    def document_closed(self, document):
        """Remove document from the list of open documents"""
        self.documents.discard(document)

    def iter_editors_of_document(self, doc):
        for window in self.iter_windows():
            for editor in window.iter_editors_of_document(doc):
                yield editor

    def iter_windows_with_editor_of_document(self, document):
        for window in self.iter_windows():
            try:
                next(window.iter_editors_of_document(document))
            except StopIteration:
                pass
            else:
                yield window

    def find_window_with_editor(self, editor):
        for window in self.iter_windows():
            for proj in window.projects:
                for ed in proj.editors:
                    if ed is editor:
                        return window
        return None

    def find_windows_with_project(self, project):
        return [ed for ed in self.windows if project in ed.projects]

    def find_project_with_path(self, path):
        for ed in self.windows:
            proj = ed.find_project_with_path(path)
            if proj is not None:
                return proj
        return None

    def find_item_with_id(self, ident):
        # HACK slow implementation, violates encapsulation
        for window in self.windows:
            for proj in window.projects:
                if proj.id == ident:
                    return proj
                for doc in proj.editors:
                    if doc.id == ident:
                        return doc
        return None

    def iter_windows(self, app=None):
        """Iterate over windows in on-screen z-order starting with the
        front-most window"""
        from editxt.window import WindowController
        if app is None:
            app = ak.NSApp()
        z_ordered_eds = set()
        for win in app.orderedWindows():
            wc = win.windowController()
            if isinstance(wc, WindowController) and wc.window_ in self.windows:
                z_ordered_eds.add(wc.window_)
                yield wc.window_
        for ed in self.windows:
            if ed not in z_ordered_eds:
                yield ed

    def add_window(self, window):
        self.windows.append(window)

    def current_window(self):
        try:
            return next(self.iter_windows())
        except StopIteration:
            return None

    def discard_window(self, window):
        try:
            self.windows.remove(window)
        except ValueError:
            pass
        if self.setup_profile(windows=True) and not self.terminating:
            state_dir = os.path.join(self.profile_path, const.STATE_DIR, const.CLOSED_DIR)
            ident = next(enumerate_state_paths(state_dir, reverse=True), [-1])[0] + 1
            self.save_window_state(window, state_dir, ident)
        window.close()

    def setup_profile(self, windows=False):
        """Ensure that profile dir exists

        This will create the profile directory if it does not exist.

        :returns: True on success, otherwise False.
        """
        def setup(path, name):
            if name not in self._setup_profile and not os.path.isdir(path):
                try:
                    os.mkdir(path)
                except OSError:
                    log.error('cannot create %s', path, exc_info=True)
                    return False
                self._setup_profile.add(name)
            return True
        state_path = os.path.join(self.profile_path, const.STATE_DIR)
        closed_path = os.path.join(state_path, const.CLOSED_DIR)
        return setup(self.profile_path, ".") and (
            not windows or
            (setup(state_path, "editor") and setup(closed_path, "closed"))
        )

    def _legacy_window_states(self):
        # TODO remove once all users have upraded to new state persistence
        def pythonify(value):
            if isinstance(value, (str, int, float, bool)):
                return value
            if isinstance(value, (dict, fn.NSDictionary)):
                return {k: pythonify(v) for k, v in value.items()}
            if isinstance(value, (list, fn.NSArray)):
                return [pythonify(v) for v in value]
            raise ValueError('unknown value type: {} {}'
                .format(type(value), repr(value)))
        defaults = fn.NSUserDefaults.standardUserDefaults()
        serials = defaults.arrayForKey_(const.WINDOW_CONTROLLERS_DEFAULTS_KEY) or []
        settings = defaults.arrayForKey_(const.WINDOW_SETTINGS_DEFAULTS_KEY) or []
        serials = list(reversed(serials))
        for serial, setting in zip(serials, chain(settings, repeat(None))):
            try:
                state = dict(serial)
                if setting is not None:
                    state['window_settings'] = setting
                yield pythonify(state)
            except Exception:
                log.warn('cannot load legacy state: %r', serial, exc_info=True)

    def iter_saved_window_states(self):
        """Yield saved window states"""
        state_dir = os.path.join(self.profile_path, const.STATE_DIR)
        if not os.path.exists(state_dir):
            if self.profile_path == os.path.expanduser(self.default_profile()):
                # TODO remove once all users have upgraded
                for state in self._legacy_window_states():
                    yield state
            return
        for x, path in enumerate_state_paths(state_dir):
            try:
                with open(path, encoding="utf-8") as f:
                    yield load_yaml(f)
            except Exception:
                log.error('cannot load %s', path, exc_info=True)
                yield StateLoadFailure(path)

    def save_window_state(self, window, state_dir, ident):
        """Save a single window's state

        :param window: The window with state to be saved.
        :param state_dir: The directory in which to write the state file.
        :param ident: The identifier to use when saving window state.
        :returns: The name of the state file.
        """
        state_name = const.EDITOR_STATE.format(ident)
        state_file = os.path.join(state_dir, state_name)
        state = window.state
        try:
            with atomicfile(state_file, encoding="utf-8") as fh:
                dump_yaml(state, fh)
        except Exception:
            log.error('cannot write %s\n%s\n', state_file, state, exc_info=True)
        return state_name

    def save_window_states(self):
        """Save all windows' states"""
        state_dir = os.path.join(self.profile_path, const.STATE_DIR)
        old = {os.path.basename(x[1]) for x in enumerate_state_paths(state_dir)}
        for i, window in enumerate(self.iter_windows()):
            if i == 0:
                self.setup_profile(windows=True)
            state_name = self.save_window_state(window, state_dir, i)
            old.discard(state_name)
        for name in old:
            state_file = os.path.join(state_dir, name)
            try:
                os.remove(state_file)
            except Exception:
                log.error('cannot remove %s', state_file, exc_info=True)

    def should_terminate(self, callback):
        """Check if application can terminate

        :param callback: A callback to be used as the return value to signal
        a delayed termination. If this object is returned it will be called with
        a single boolean argument at some point in the future when it is known
        if the application should terminate or not.
        :returns: ``True``, ``False`` or ``callback``. ``True`` means the
        application should terminate immediately. ``False`` means the
        application should not terminate, and ``callback`` means the application
        will perform an extended check to determine if it should terminate
        before calling ``callback`` with either ``True`` or ``False``.
        """
        if next(self.iter_dirty_editors(), None) is not None:
            self.async_interactive_close(self.iter_dirty_editors(), callback)
            return callback
        return True

    @staticmethod
    def async_interactive_close(dirty_editors, callback):
        """Visit each dirty document and prompt for close

        Calls ``callback(False)`` if any unsaved document cancels the
        close operation. Otherwise calls ``callback(True)``.
        """
        class RecursiveCall(Exception): pass

        def continue_closing():
            def _callback(ok_to_close):
                if not ok_to_close:
                    callback(False)
                    return
                if recursive_call:
                    raise RecursiveCall()
                continue_closing()
            recursive_call = True
            for editor in dirty_editors:
                try:
                    editor.should_close(_callback)
                except RecursiveCall:
                    continue
                except Exception:
                    log.exception("termination sequence failed")
                    callback(False)
                    return
                recursive_call = False
                return
            callback(True)

        dirty_editors = iter(dirty_editors)
        continue_closing()

    def will_terminate(self):
        self.terminating = True
        self.save_window_states()