Пример #1
0
 def __init__(self, window, *, serial=None):
     self.id = next(DocumentController.id_gen)
     self.window = window
     self.proxy = KVOProxy(self)
     self.name = const.UNTITLED_PROJECT_NAME
     self.path = None
     self.expanded = True
     self.is_dirty = False
     self.undo_manager = UndoManager()
     self.editors = KVOList()
     self.recent = KVOList()
     self.main_view = None
     self.closing = False
     if serial is not None:
         self._deserialize(serial)
     self.reset_serial_cache()
Пример #2
0
 def __init__(self, app, state=None):
     self.app = app
     self._current_editor = None
     self.wc = WindowController(self)
     self.state = state
     self.command = CommandBar(self, app.text_commander)
     self.projects = KVOList()
     self.recent = self._suspended_recent = RecentItemStack(100)
     self._recent_history = None
     self.window_settings_loaded = False
     self.no_document_undo_manager = UndoManager()
     self.menu = self.make_context_menu()
     self.dirty_editors = WeakSet()
Пример #3
0
    def __init__(self, project, *, document=None, path=None, state=None):
        if state is not None:
            if "internal" in state:
                app = project.window.app
                document = app.get_internal_document(state["internal"])
            else:
                assert document is None, (state, document)
                assert path is None, (state, path)
                path = state["path"]
        if path is not None:
            assert document is None, (path, document)
        if document is None:
            document = project.window.app.document_with_path(path)
            assert document is not None, (project, path, state)
        self.editors = KVOList.alloc().init()
        self.id = next(DocumentController.id_gen)
        self._project = project
        self.document = document
        self.proxy = KVOProxy(self)
        self.main_view = None
        self.text_view = None
        self.scroll_view = None
        self._goto_line = None
        self.line_numbers = LineNumbers(self.text)
        props = document.props
        self.kvolink = KVOLink([
            (props, "is_dirty", self.proxy, "is_dirty"),
            (props, "indent_mode", self.proxy, "indent_mode"),
            (props, "indent_size", self.proxy, "indent_size"),
            (props, "newline_mode", self.proxy, "newline_mode"),
            (props, "syntaxdef", self.proxy, "syntaxdef"),
            (props, "character_encoding", self.proxy, "character_encoding"),
            (props, "highlight_selected_text", self.proxy, "highlight_selected_text"),
        ])
        if state is not None:
            self.edit_state = state

        self.undo_manager.on(self.on_dirty_status_changed)
Пример #4
0
class Project(CommandSubject):

    window = WeakProperty()
    document = None
    soft_wrap = None
    indent_mode = None
    indent_size = None
    newline_mode = None
    syntaxdef = None
    character_encoding = None
    text_view = None
    is_leaf = False

    @staticmethod
    def is_project_path(path):
        return path.endswith("." + const.PROJECT_EXT)

    def __init__(self, window, *, serial=None):
        self.id = next(DocumentController.id_gen)
        self.window = window
        self.proxy = KVOProxy(self)
        self.name = const.UNTITLED_PROJECT_NAME
        self.path = None
        self.expanded = True
        self.is_dirty = False
        self.undo_manager = UndoManager()
        self.editors = KVOList()
        self.recent = KVOList()
        self.main_view = None
        self.closing = False
        if serial is not None:
            self._deserialize(serial)
        self.reset_serial_cache()

    def serialize(self):
        data = {"expanded": self.expanded}
        if self.name != const.UNTITLED_PROJECT_NAME:
            data["name"] = str(self.name) # HACK dump_yaml doesn't like pyobjc_unicode
        if self.path is not None:
            data["path"] = self.path
        documents = [d.edit_state for d in self.editors]
        if documents:
            data["documents"] = documents
        if self.recent:
            data["recent"] = [str(r.path) for r in self.recent]
        return data

    def _deserialize(self, serial):
        if "path" in serial:
            self.path = serial["path"]
        if serial:
            if "name" in serial:
                self.name = serial["name"]
            for doc_state in serial.get("documents", []):
                try:
                    self.create_editor_with_state(doc_state)
                except Exception:
                    log.warn("cannot open document: %r" % (doc_state,))
            self.expanded = serial.get("expanded", True)
            if "recent" in serial:
                self.recent.extend(Recent(path) for path in serial["recent"])
        if not self.editors:
            self.create_editor()

    def reset_serial_cache(self):
        self.serial_cache = self.serialize()

    def save(self):
        if self.serial_cache != self.serialize():
            #if self.path is not None:
            #    self.save_with_path(self.path)
            self.app.save_window_states()
            self.reset_serial_cache()

    def save_with_path(self, path):
        raise NotImplementedError
        data = fn.NSMutableDictionary.alloc().init()
        data.update(self.serialize())
        data.writeToFile_atomically_(path, True)

    def should_close(self, callback):
        self.save()
        callback(True)

    def dirty_editors(self):
        return (e for e in self.editors if e.is_dirty)

    def icon(self):
        return None

    def can_rename(self):
        return True

    @property
    def app(self):
        return self.window.app

    @property
    def project(self):
        return self

    @property
    def file_path(self):
        return self.path

    def short_path(self, name=True):
        return self.path or ""

    def dirname(self):
        """Return a tuple: (directory, filename or None)"""
        if self.path:
            assert os.path.isabs(self.path), self.path
            return self.path
        return None

    def editor_for_path(self, path):
        """Get the editor for the given path

        Returns None if this project does not have a editor with path
        """
        raise NotImplementedError

    def create_editor(self, path=None):
        editor = Editor(self, path=path)
        self.insert_items([editor])
        return editor

    def create_editor_with_state(self, state):
        editor = Editor(self, state=state)
        self.insert_items([editor])
        return editor

    def insert_items(self, items, index=-1, action=None):
        """Insert items into project, creating editors as necessary

        :param items: An iterable yielding editors and/or documents.
        :param index: The index in this project's list of editors at
            which items should be inserted.
        :param action: What to do with items that already exist in this
            project:

            - None : insert new item(s), ignore existing item(s).
            - MOVE : move existing item(s) to index.
            - COPY : copy item(s) to index.

            An item is considered to be "existing" if there is another
            editor with the same path.
        :returns: A tuple: list of editors for the items that were
        inserted and the editor that should receive focus.
        """
        if index < 0:
            if self.window is not None:
                current = self.window.current_editor
                if current is not None:
                    try:
                        index = self.editors.index(current) + 1
                    except ValueError:
                        pass
            if index < 0:
                index = len(self.editors)
        is_move = action == const.MOVE
        is_copy = action == const.COPY
        focus = None
        inserted = []
        for item in items:
            if isinstance(item, Editor):
                editor, item = item, item.document
            else:
                if not isinstance(item, TextDocument):
                    raise ValueError("invalid item: {!r}".format(item))
                editor = next(self.iter_editors_of_document(item), None)
            if is_move and editor is not None:
                if editor.project is self:
                    vindex = self.editors.index(editor)
                    if vindex in [index - 1, index]:
                        # TODO why not set `focus = editor`?
                        inserted.append(editor)
                        continue
                    if vindex - index <= 0:
                        index -= 1
                    del self.editors[vindex]
                else:
                    editor.project = self
            elif is_copy or editor is None or editor.project is not self:
                editor = Editor(self, document=item)
            else:
                assert editor.project is self, (editor, editor.project)
                if editor in self.editors:
                    focus = editor
                    inserted.append(editor)
                    continue
            assert editor.project is self, (editor, editor.project, self)
            self._discard_recent(editor.file_path)
            self.editors.insert(index, editor)
            inserted.append(editor)
            focus = editor
            index += 1
        return inserted, focus

    def remove(self, editor):
        """Remove an editor from this project

        Adds the document to this projects recent documents. Does
        nothing if the editor is not in this project.
        """
        if not self.closing and editor in self.editors:
            update_current = editor in self.window.selected_items or \
                             not self.window.selected_items
            with self.window.suspend_recent_updates(update_current):
                self.editors.remove(editor)
                assert editor not in self.editors, (editor, self.editors)
                self._add_recent(editor.document)

    def iter_editors_of_document(self, document):
        for editor in self.editors:
            if editor.document is document:
                yield editor

    def _add_recent(self, document):
        """Add document to this projects recent documents

        Does nothing if the document does not have an absolute path or there are
        other editors with the same document in the project.
        """
        if os.path.isabs(document.file_path):
            itr = self.iter_editors_of_document(document)
            if next(itr, None) is None:
                self._discard_recent(document.file_path)
                self.recent.insert(0, Recent(document.file_path))
                add_recent_document(document.file_path)
                # TODO make limit customizable?
                if len(self.recent) > 20:
                    del self.recent[20:]

    def _discard_recent(self, path):
        """Discard recent items matching path"""
        for item in reversed(list(self.recent)):
            if item.path == path:
                self.recent.remove(item)
                item.close()

    def set_main_view_of_window(self, view, window):
        if self.main_view is None:
            def open_recent(item):
                self.window.current_editor = self.create_editor(item.path)
            self.listview = ListView(self.recent, RECENT_COLSPEC)
            self.listview.on.double_click(open_recent)
            self.main_view = add_command_view(
                self.listview.scroll, view.bounds(), self)
            self.command_view = self.main_view.bottom
        self.main_view.become_subview_of(view, focus=self.listview.view)

    def focus(self):
        if self is not self.window.current_editor:
            self.window.current_editor = self

    def interactive_close(self, do_close):
        def dirty_editors():
            def other_project_has(document):
                return any(editor.project is not self
                           for editor in app.iter_editors_of_document(document))
            app = self.app
            seen = set()
            for editor in self.editors:
                if editor.document in seen:
                    continue
                seen.add(editor.document)
                if editor.is_dirty and not other_project_has(editor.document):
                    yield editor
        def callback(should_close):
            if should_close:
                do_close()
        self.app.async_interactive_close(dirty_editors(), callback)

    def close(self):
        self.closing = True
        try:
            for editor in list(self.editors):
                editor.close()
            #self.editors.setItems_([])
        finally:
            self.closing = False
        self.window = None
        self.editors = None
        self.proxy = None
        self.main_view = None
        self.command_view = None

    def __repr__(self):
        return '<%s 0x%x name=%s>' % (type(self).__name__, id(self), self.name)
Пример #5
0
class Window(object):

    supported_drag_types = [const.DOC_ID_LIST_PBOARD_TYPE, ak.NSFilenamesPboardType]
    app = WeakProperty()

    def __init__(self, app, state=None):
        self.app = app
        self._current_editor = None
        self.wc = WindowController(self)
        self.state = state
        self.command = CommandBar(self, app.text_commander)
        self.projects = KVOList()
        self.recent = self._suspended_recent = RecentItemStack(100)
        self._recent_history = None
        self.window_settings_loaded = False
        self.no_document_undo_manager = UndoManager()
        self.menu = self.make_context_menu()
        self.dirty_editors = WeakSet()

    def window_did_load(self):
        wc = self.wc
        wc.docsView.default_menu = self.menu
        wc.docsView.setRefusesFirstResponder_(True)
        wc.docsView.registerForDraggedTypes_(self.supported_drag_types)
        wc.plusButton.setRefusesFirstResponder_(True)
        wc.plusButton.setImage_(load_image(const.PLUS_BUTTON_IMAGE))
        wc.propsViewButton.setRefusesFirstResponder_(True)
        wc.propsViewButton.setImage_(load_image(const.PROPS_DOWN_BUTTON_IMAGE))
        wc.propsViewButton.setAlternateImage_(load_image(const.PROPS_UP_BUTTON_IMAGE))

        self._setstate(self._state)
        self._state = None

        if not self.projects:
            self.new_project()

    def make_context_menu(self):
        def has_path(item):
            return item and item.file_path
        return Menu([
            MenuItem("Copy Path", self.copy_path, is_enabled=has_path),
            MenuItem("Close", self.close_item, "Command+w"),
        ])

    def _setstate(self, state):
        if state:
            projects = state.get("projects")
            if projects is None:
                projects = state.get("project_serials", []) # legacy
            for serial in projects:
                proj = Project(self, serial=serial)
                self.projects.append(proj)
            for proj_index, doc_index in state.get("recent_items", []):
                if proj_index < len(self.projects):
                    proj = self.projects[proj_index]
                    if doc_index == "<project>":
                        self.recent.push(proj.id)
                    elif doc_index < len(proj.editors):
                        doc = proj.editors[doc_index]
                        self.recent.push(doc.id)
            if 'window_settings' in state:
                self.window_settings = state['window_settings']
            with self.suspend_recent_updates():
                pass # focus recent

    def __getstate__(self):
        if self._state is not None:
            return self._state
        def iter_settings():
            indexes = {}
            serials = []
            for i, project in enumerate(self.projects):
                serial = project.serialize()
                if serial:
                    serials.append(serial)
                indexes[project.id] = [i, "<project>"]
                for j, doc in enumerate(project.editors):
                    indexes[doc.id] = [i, j]
            yield "projects", serials
            rits = []
            for ident in self.recent:
                pair = indexes.get(ident)
                if pair is not None:
                    rits.append(pair)
            yield "recent_items", rits
            yield "window_settings", self.window_settings
        return {key: val for key, val in iter_settings() if val}

    def __setstate__(self, state):
        assert not hasattr(self, '_state'), 'can only be called once'
        self._state = state

    state = property(__getstate__, __setstate__)

    def discard(self, item):
        ident = None if item is None else item.id
        recent = self.recent
        update_current = item in self.selected_items or not self.selected_items
        with self.suspend_recent_updates(update_current):
            for project in list(self.projects):
                pid = project.id
                for editor in list(project.editors):
                    did = editor.id
                    if ident in (pid, did):
                        recent.discard(did)
                        assert editor.project is project, (editor.project, project)
                        editor.close()
                if ident == pid:
                    recent.discard(pid)
                    self.projects.remove(project)
                    project.close()

    def focus(self, value, offset=1):
        """Change the current document by navigating the tree or recent documents

        :param value: One of the direction constants in
        `editxt.constants` or an editor's file path. `NEXT` and
        `PREVIOUS` select items in the recent editors stack. `UP` and
        `DOWN` move up or down in the tree.
        :param offset: The number of positions to move in direction.
        :returns: True if a new editor was focused, otherwise false.
        """
        def focus(ident):
            for project in self.projects:
                if project.id == ident:
                    self.current_editor = project
                    return True
                else:
                    for editor in project.editors:
                        if editor.id == ident:
                            self.current_editor = editor
                            return True
            return False
        def get_item_in_tree(current, offset):
            if current is not None:
                items = []
                index = 0
                stop = sys.maxsize
                for project in self.projects:
                    items.append(project)
                    if current.id == project.id:
                        stop = index + offset
                        if stop <= index:
                            break
                    index += 1
                    if project.expanded:
                        for editor in project.editors:
                            items.append(editor)
                            if current.id == editor.id:
                                stop = index + offset
                                if stop <= index:
                                    break
                            index += 1
                if 0 <= stop < len(items):
                    return items[stop]
            return None
        if isinstance(value, const.Constant):
            if value == const.PREVIOUS or value == const.NEXT:
                history = ((list(reversed(self.recent)) + [0])
                           if self._recent_history is None
                           else self._recent_history)
                if value == const.PREVIOUS:
                    offset = offset + history[-1]
                else:
                    offset = history[-1] - offset
                if 0 <= offset < len(history) - 1:
                    ok = focus(history[offset])
                    if ok:
                        history[-1] = offset
                        self._recent_history = history
                    return ok
                return False
            if value == const.UP:
                offset = -offset
            editor = get_item_in_tree(self.current_editor, offset)
            if editor is not None:
                self.current_editor = editor
                return True
        if isinstance(value, (Editor, Project)):
            return focus(value.id)
        return False

    @contextmanager
    def suspend_recent_updates(self, update_current=True):
        self.recent = RecentItemStack(1)
        try:
            yield
        finally:
            self.recent = recent = self._suspended_recent
        if not update_current:
            return
        lookup = {}
        for project in self.projects:
            lookup[project.id] = project
            lookup.update((e.id, e) for e in project.editors)
        current = self.current_editor
        current_id = None if current is None else current.id
        if current_id in lookup and recent and current_id == recent[-1]:
            return
        while True:
            ident = recent.pop()
            if ident is None:
                if self.projects:
                    for project in self.projects:
                        if project.expanded and project.editors:
                            self.current_editor = project.editors[0]
                            break
                    else:
                        self.current_editor = self.projects[0]
                break
            item = lookup.get(ident)
            if item is not None:
                self.current_editor = item
                break

    def do_menu_command(self, sender):
        self.app.text_commander.do_menu_command(self.current_editor, sender)

    def validate_menu_command(self, item):
        return self.app.text_commander.is_menu_command_enabled(self.current_editor, item)

    @property
    def current_editor(self):
        return self._current_editor
    @current_editor.setter
    def current_editor(self, editor):
        self._current_editor = editor
        self._recent_history = None
        if editor is None:
            self.wc.setup_current_editor(None)
            self.selected_items = []
            return
        if self.wc.is_current_view(editor.main_view):
            editor.focus()
        else:
            self.recent.push(editor.id)
            if self.wc.setup_current_editor(editor):
                if isinstance(editor, Editor) \
                        and self.find_project_with_editor(editor) is None:
                    self.insert_items([editor])
        if not self.selected_items or editor is not self.selected_items[0]:
            self.selected_items = [editor]

    @property
    def selected_items(self):
        return self.wc.selected_items
    @selected_items.setter
    def selected_items(self, value):
        self.wc.selected_items = value

    def selected_editor_changed(self):
        selected = self.selected_items
        if selected and selected[0] is not self.current_editor:
            self.current_editor = selected[0]

    def on_dirty_status_changed(self, editor, dirty):
        if dirty:
            self.dirty_editors.add(editor)
        else:
            self.dirty_editors.discard(editor)
        self.wc.on_dirty_status_changed(editor, self.is_dirty)

    @property
    def is_dirty(self):
        return bool(self.dirty_editors)

    def iter_editors_of_document(self, doc):
        for project in self.projects:
            for editor in project.iter_editors_of_document(doc):
                yield editor

    def should_select_item(self, outlineview, item):
        return True

    def open_documents(self):
        editor = self.current_editor
        if editor is not None and editor.dirname():
            directory = editor.dirname()
        else:
            directory = os.path.expanduser("~")
        self.wc.open_documents(directory, None, self.open_paths)

    def save_as(self):
        self.save(prompt=True)

    def save(self, prompt=False):
        editor = self.current_editor
        if isinstance(editor, Editor):
            editor.save(prompt=prompt)

    def reload_current_document(self):
        editor = self.current_editor
        if isinstance(editor, Editor):
            editor.document.reload_document()

    def save_document_as(self, editor, save_with_path):
        """Prompt for path to save document

        :param editor: The editor of the document to be saved.
        :param save_with_path: A callback accepting a sinlge parameter (the
        chosen file path) that does the work of actually saving the file.
        Call with ``None`` to cancel the save operation.
        """
        directory, filename = self._directory_and_filename(editor.file_path)
        self.wc.save_document_as(directory, filename, save_with_path)

    def prompt_to_overwrite(self, editor, save_with_path):
        """Prompt to overwrite the given editor's document's file path

        :param editor: The editor of the document to be saved.
        :param save_with_path: A callback accepting a sinlge parameter (the
        chosen file path) that does the work of actually saving the file.
        Call with ``None`` to cancel the save operation.
        """
        def save_as():
            self.save_document_as(editor, save_with_path)
        if editor is None:
            diff_with_original = None
        else:
            def diff_with_original():
                from editxt.command.diff import diff
                from editxt.command.parser import Options
                diff(editor, Options(file=editor.file_path))
        self.wc.prompt_to_overwrite(
            editor.file_path, save_with_path, save_as, diff_with_original)

    def prompt_to_close(self, editor, save_discard_or_cancel, save_as=True):
        """Prompt to see if the document can be closed

        :param editor: The editor of the document to be closed.
        :param save_discard_or_cancel: A callback to be called with the outcome
        of the prompt: save (True), discard (False), or cancel (None).
        :param save_as: Boolean, if true prompt to "save as" (with dialog),
        otherwise prompt to save (without dialog).
        """
        self.current_editor = editor
        self.wc.prompt_to_close(editor.file_path, save_discard_or_cancel, save_as)

    @staticmethod
    def _directory_and_filename(path):
        if isabs(path):
            directory, filename = split(path)
            while directory and directory != sep and not isdir(directory):
                directory = dirname(directory)
        else:
            directory = None
            filename = basename(path)
        assert filename, path
        if not directory:
            # TODO editor.project.path or path of most recent document
            # None -> directory used in the previous invocation of the panel
            directory = None
        return directory, filename

    def new_project(self):
        project = Project(self)
        editor = project.create_editor()
        self.projects.append(project)
        self.current_editor = editor
        return project

    def toggle_properties_pane(self):
        tree_rect = self.wc.docsScrollview.frame()
        prop_rect = self.wc.propsView.frame()
        if self.wc.propsViewButton.state() == ak.NSOnState:
            # hide properties view
            tree_rect.size.height += prop_rect.size.height - 1.0
            tree_rect.origin.y = prop_rect.origin.y
            prop_rect.size.height = 0.0
        else:
            # show properties view
            tree_rect.size.height -= 115.0
            if prop_rect.size.height > 0:
                tree_rect.size.height += (prop_rect.size.height - 1.0)
            tree_rect.origin.y = prop_rect.origin.y + 115.0
            prop_rect.size.height = 116.0
            self.wc.propsView.setHidden_(False)
        resize_tree = fn.NSDictionary.dictionaryWithObjectsAndKeys_(
            self.wc.docsScrollview, ak.NSViewAnimationTargetKey,
            fn.NSValue.valueWithRect_(tree_rect), ak.NSViewAnimationEndFrameKey,
            None,
        )
        resize_props = fn.NSDictionary.dictionaryWithObjectsAndKeys_(
            self.wc.propsView, ak.NSViewAnimationTargetKey,
            fn.NSValue.valueWithRect_(prop_rect), ak.NSViewAnimationEndFrameKey,
            None,
        )
        anims = fn.NSArray.arrayWithObjects_(resize_tree, resize_props, None)
        animation = ak.NSViewAnimation.alloc().initWithViewAnimations_(anims)
        #animation.setAnimationBlockingMode_(NSAnimationBlocking)
        animation.setDuration_(0.25)
        animation.startAnimation()

    def find_project_with_editor(self, editor):
        for proj in self.projects:
            for e in proj.editors:
                if editor is e:
                    return proj
        return None

    def find_project_with_path(self, path):
        for proj in self.projects:
            p = proj.file_path
            if p and os.path.exists(p) and os.path.samefile(p, path):
                return proj
        return None

    def get_current_project(self, create=False):
        item = self.current_editor
        if item is not None:
            return item if isinstance(item, Project) else item.project
        if self.projects:
            for project in self.projects:
                if project.expanded:
                    return project
            return self.projects[0]
        if create:
            proj = Project(self)
            self.projects.append(proj)
            return proj
        return None

    def tooltip_for_item(self, view, item):
        it = view.realItemForOpaqueItem_(item)
        null = it is None or it.file_path is None
        return None if null else user_path(it.file_path)

    def should_edit_item(self, col, item):
        if col.isEditable():
            obj = representedObject(item)
            return isinstance(obj, Project) and obj.can_rename()
        return False

    def copy_path(self, item):
        """Copy item path to pasteboard

        Put newline-delimited paths on pasteboard if there are multiple
        items selected and the given item is one of them.
        """
        selected = self.selected_items
        if item not in selected:
            selected = [item]
        Pasteboard().text = "\n".join(item.file_path for item in selected)

    def close_item(self, item):
        """Close editor or project

        Close all selected items if there are multiple items selected
        and the given item is one of them.
        """
        def do_close(should_close):
            if should_close:
                for item in selected:
                    self.discard(item)
        selected = self.selected_items
        if item not in selected:
            selected = [item]
        self.app.async_interactive_close(selected, do_close)

    def window_did_become_key(self, window):
        editor = self.current_editor
        if isinstance(editor, Editor):
            # TODO refactor Editor to support check_for_external_changes()
            editor.document.check_for_external_changes(window)

    def should_close(self, do_close):
        """Determine if the window should be closed

        If this returns false an interactive close loop will be started, which
        may eventually result in the window being closed.
        """
        def iter_dirty_editors():
            app = self.app
            for proj in self.projects:
                wins = app.find_windows_with_project(proj)
                if wins == [self]:
                    for editor in proj.dirty_editors():
                        doc_windows = app.iter_windows_with_editor_of_document(
                                            editor.document)
                        if all(win is self for win in doc_windows):
                            yield editor
        if next(iter_dirty_editors(), None) is None:
            return True
        def callback(should_close):
            if should_close:
                do_close()
        self.app.async_interactive_close(iter_dirty_editors(), callback)
        return False

    def window_will_close(self):
        self.app.discard_window(self)

    def _get_window_settings(self):
        return dict(
            frame_string=str(self.wc.frame_string),
            splitter_pos=self.wc.splitter_pos,
            properties_hidden=self.wc.properties_hidden,
        )
    def _set_window_settings(self, settings):
        fs = settings.get("frame_string")
        if fs is not None:
            self.wc.frame_string = fs
        sp = settings.get("splitter_pos")
        if sp is not None:
            self.wc.splitter_pos = sp
        self.wc.properties_hidden = settings.get("properties_hidden", False)
        self.window_settings_loaded = True
    window_settings = property(_get_window_settings, _set_window_settings)

    def close(self):
        wc = self.wc
        if wc is not None:
            self.window_settings_loaded = False
            while self.projects:
                self.projects.pop().close()
            #wc.docsController.setContent_(None)
            #self.wc = None

    # drag/drop logic ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

    def is_project_drag(self, info):
        """Return True if only projects are being dropped else False"""
        pb = info.draggingPasteboard()
        t = pb.availableTypeFromArray_(self.supported_drag_types)
        if t == const.DOC_ID_LIST_PBOARD_TYPE:
            id_list = pb.propertyListForType_(const.DOC_ID_LIST_PBOARD_TYPE)
            items = self.iter_dropped_id_list(id_list)
            return all(isinstance(item, Project) for item in items)
        elif t == ak.NSFilenamesPboardType:
            paths = pb.propertyListForType_(ak.NSFilenamesPboardType)
            return all(Project.is_project_path(path) for path in paths)
        return False

    def get_id_path_pairs(self, items):
        """Get a list of (<item id>, <item path>) pairs for given items

        :param items: A list of editors and/or projects.
        :returns: A list of two-tuples (<item id>, <item path>). <item id> is
        an opaque internal identifier for the document, and <item path> is
        the file system path of the item or ``None`` if the item does not have
        a path.
        """
        def pair(item):
            path = item.file_path
            return (item.id, path if path and os.path.exists(path) else None)
        return [pair(item) for item in items]

    def validate_drop(self, outline_view, info, item, index):
        if self.is_project_drag(info):
            if item is not None:
                obj = representedObject(item)
                path = self.wc.docsController.indexPathForObject_(obj)
                if path is not None:
                    index = path.indexAtPosition_(0)
                    outline_view.setDropItem_dropChildIndex_(None, index)
                else:
                    return ak.NSDragOperationNone
            elif index < 0:
                outline_view.setDropItem_dropChildIndex_(None, len(self.projects))
            return ak.NSDragOperationMove
        else:
            # text document drag
            if item is not None:
                obj = representedObject(item)
                if isinstance(obj, Project):
                    if index < 0:
                        #outline_view.setDropItem_dropChildIndex_(item, 0)
                        # the following might be more correct, but is too confusing
                        outline_view.setDropItem_dropChildIndex_(item, len(obj.editors))
                else:
                    return ak.NSDragOperationNone # document view cannot have children
            else:
                if index < 0:
                    # drop on listview background
                    last_proj_index = len(self.projects) - 1
                    if last_proj_index > -1:
                        # we have at least one project
                        path = fn.NSIndexPath.indexPathWithIndex_(last_proj_index)
                        node = self.wc.docsController.nodeAtArrangedIndexPath_(path)
                        proj = representedObject(node)
                        outline_view.setDropItem_dropChildIndex_(node, len(proj.editors))
                    else:
                        outline_view.setDropItem_dropChildIndex_(None, -1)
                elif index == 0:
                    return ak.NSDragOperationNone # prevent drop above top project
        op = info.draggingSourceOperationMask()
        if op not in [ak.NSDragOperationCopy, ak.NSDragOperationGeneric]:
            op = ak.NSDragOperationMove
        return op

    def accept_drop(self, view, pasteboard, parent=const.CURRENT, index=-1, action=const.MOVE):
        """Accept drop operation

        :param view: The view on which the drop occurred.
        :param pasteboard: NSPasteboard object.
        :param parent: The parent item in the outline view.
        :param index: The index in the outline view or parent item at which the
            drop occurred.
        :param action: The action to perform when dragging (see
        ``insert_items(..., action)``). Ignored if the items being dropped are
        paths.
        :returns: True if the drop was accepted, otherwise False.
        """
        pb = pasteboard
        t = pb.availableTypeFromArray_(self.supported_drag_types)
        if t == const.DOC_ID_LIST_PBOARD_TYPE:
            id_list = pb.propertyListForType_(const.DOC_ID_LIST_PBOARD_TYPE)
            items = self.iter_dropped_id_list(id_list)
        elif t == ak.NSFilenamesPboardType:
            paths = pb.propertyListForType_(ak.NSFilenamesPboardType)
            items = self.iter_dropped_paths(paths)
            action = None
        else:
            assert t is None, t
            return False
        return bool(self.insert_items(items, parent, index, action))

    def iter_dropped_id_list(self, id_list):
        """Iterate TextDocument objects referenced by pasteboard (if any)"""
        if not id_list:
            return
        for ident in id_list:
            item = self.app.find_item_with_id(ident)
            if item is not None:
                yield item

    def open_url(self, url, link, focus=True):
        """Open file specified by URL

        The URL must have two attributes:
        - path : The path to the file. The first leading slash is
          stripped, so absolute paths must have an extra slash.
        - query : A query string from which an optional "goto" parameter
          may be parsed. The goto parameter specifies a line or line +
          selection (`line.sel_start.sel_length`) to goto/select after
          opening the file.

        :param url: Parsed URL. See `urllib.parse.urlparse` for structure.
        :param link: The original URL string.
        :param focus: Focus newly opened editor.
        """
        path = unquote(url.path)
        if path.startswith("/"):
            path = path[1:]
        editors = self.open_paths([path], focus=focus)
        if editors:
            assert len(editors) == 1, (link, editors)
            query = parse_qs(url.query)
            if "goto" in query:
                goto = query["goto"][0]
                try:
                    if "." in goto:
                        line, start, end = goto.split(".")
                        num = (int(line), int(start), int(end))
                    else:
                        num = int(goto)
                except ValueError:
                    log.debug("invalid goto: %r (link: %s)", goto, link)
                else:
                    editors[0].goto_line(num)

    def open_paths(self, paths, focus=True):
        return self.insert_items(self.iter_dropped_paths(paths), focus=focus)

    def iter_dropped_paths(self, paths):
        if not paths:
            return
        for path in paths:
            if path is None or os.path.isfile(path) or not os.path.exists(path):
                yield self.app.document_with_path(path)
#            elif os.path.isdir(path):
#                yield Project(self, name=os.path.dirname(path))
            else:
                log.info("cannot open path: %s", path)

    def insert_items(self, items, project=const.CURRENT, index=-1, action=None,
                     focus=True):
        """Insert items into the document tree

        :param items: An iterable of projects, editors, and/or documents.
        :param project: The parent project into which items are being inserted.
            Documents will be inserted in the current project if unspecified.
        :param index: The index in the outline view or parent project at which
            the item(s) should be inserted. Add after current if < 0 (default).
        :param action: What to do with items that are already open in
            this window:

            - None : insert new item(s), but do not change existing item(s).
            - MOVE : move existing item(s) to index.
            - COPY : copy item(s) to index.

            A file is considered to be "existing" if there is an editor
            with the same path in the project where it is being
            inserted. A project is considered to be "existing" if there
            is a project with the same path in the window where it is
            being inserted.
        :param focus: Focus most recent newly opened editor if true (the
            default).
        :returns: A list of editors and projects that were inserted.
        """
        if (project is not None and
            project != const.CURRENT and
            project.window is not self):
            raise ValueError("project does not belong to this window")
        inserted = []
        focus_editor = None
        with self.suspend_recent_updates(update_current=False):
            pindex = index
            if pindex < 0:
                pindex = len(self.projects)
            for is_project_group, group in groupby(items, self.is_project):
                if is_project_group:
                    for item in group:
                        project, pindex = self._insert_project(item, pindex, action)
                        if project is not None:
                            inserted.append(project)
                            focus_editor = project
                    # Reset index since the project into which non-project
                    # items will be inserted has changed.
                    index = -1
                else:
                    if project == const.CURRENT or project is None:
                        if index >= 0:
                            raise NotImplementedError
                        project = self.get_current_project(create=True)
                    inserts, focus_editor = project.insert_items(group, index, action)
                    inserted.extend(inserts)
        if focus and focus_editor is not None:
            self.current_editor = focus_editor
        return inserted

    def is_project(self, item):
        """Return true if item can be inserted as a project"""
        # TODO return true if item is a directory path
        return isinstance(item, Project)

    def _insert_project(self, item, index, action):
        if action != const.MOVE:
            raise NotImplementedError('cannot copy project yet')
        if item.window is self:
            window = self
            pindex = self.projects.index(item)
            if pindex == index:
                return None, index
            if pindex - index <= 0:
                index -= 1
        else:
            window = item.window

        # BEGIN HACK crash on remove project with editors
        editors = item.editors
        tmp, editors[:] = list(editors), []
        window.projects.remove(item) # this line should be all that's necessary
        editors.extend(tmp)
        # END HACK

        item.window = self
        self.projects.insert(index, item)
        return item, index + 1

    def show(self, sender):
        self.wc.showWindow_(sender)

    @property
    def undo_manager(self):
        editor = self.current_editor
        if editor is None:
            return self.no_document_undo_manager
        return editor.undo_manager