Ejemplo n.º 1
0
def test_recent_items_queue_update_and_pop():
    ris = RecentItemStack(4)
    items = [8, 2, 6, 4, 5, 7]
    for item in items:
        ris.push(item)
    eq_(len(ris), 4)
    for item in reversed(items[-4:]):
        eq_(ris.pop(), item)
    eq_(len(ris), 0)
Ejemplo n.º 2
0
def test_recent_items_queue_push_existing():
    ris = RecentItemStack(4)
    ris.push(1)
    ris.push(2)
    ris.push(3)
    eq_(list(ris), [1, 2, 3])
    ris.push(1)
    eq_(list(ris), [2, 3, 1])
    ris.push(3)
    eq_(list(ris), [2, 1, 3])
Ejemplo n.º 3
0
 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
Ejemplo n.º 4
0
 def __init__(self, app, window_controller, state=None):
     self.app = app
     self._current_view = None
     self.wc = window_controller
     self.state = state
     self.command = CommandBar(self, app.text_commander)
     self.projects = KVOList.alloc().init()
     self.recent = self._suspended_recent = RecentItemStack(20)
     self.window_settings_loaded = False
Ejemplo n.º 5
0
def test_recent_items_queue_remove():
    ris = RecentItemStack(4)
    ris.push(1)
    ris.push(2)
    eq_(list(ris), [1, 2])
    ris.discard(1)
    eq_(list(ris), [2])
Ejemplo n.º 6
0
def test_recent_items_queue_reset():
    items = list(range(10))
    ris = RecentItemStack(4)
    ris.reset(items)
    eq_(len(ris), 4)
    eq_(list(ris), items[-4:])
    ris.reset()
    eq_(len(ris), 0)
Ejemplo n.º 7
0
class Editor(object):

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

    def __init__(self, app, window_controller, state=None):
        self.app = app
        self._current_view = None
        self.wc = window_controller
        self.state = state
        self.command = CommandBar(self, app.text_commander)
        self.projects = KVOList.alloc().init()
        self.recent = self._suspended_recent = RecentItemStack(20)
        self.window_settings_loaded = False

    def window_did_load(self):
        wc = self.wc
        wc.setShouldCloseDocument_(False)
        wc.docsView.setRefusesFirstResponder_(True)
        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))

        fn.NSNotificationCenter.defaultCenter().addObserver_selector_name_object_(
            wc, "windowDidBecomeKey:", ak.NSWindowDidBecomeKeyNotification, wc.window())
        assert hasattr(EditorWindowController, "windowDidBecomeKey_")

        wc.cleanImages = {
            BUTTON_STATE_HOVER: load_image(const.CLOSE_CLEAN_HOVER),
            BUTTON_STATE_NORMAL: load_image(const.CLOSE_CLEAN_NORMAL),
            BUTTON_STATE_PRESSED: load_image(const.CLOSE_CLEAN_PRESSED),
            BUTTON_STATE_SELECTED: load_image(const.CLOSE_CLEAN_SELECTED),
        }
        wc.dirtyImages = {
            BUTTON_STATE_HOVER: load_image(const.CLOSE_DIRTY_HOVER),
            BUTTON_STATE_NORMAL: load_image(const.CLOSE_DIRTY_NORMAL),
            BUTTON_STATE_PRESSED: load_image(const.CLOSE_DIRTY_PRESSED),
            BUTTON_STATE_SELECTED: load_image(const.CLOSE_DIRTY_SELECTED),
        }

        wc.docsView.registerForDraggedTypes_(self.supported_drag_types)

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

        if not self.projects:
            self.new_project()

    def _setstate(self, state):
        if state:
            for serial in state.get("project_serials", []):
                proj = Project.create_with_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.documents()):
                        doc = proj.documents()[doc_index]
                        self.recent.push(doc.id)
            if 'window_settings' in state:
                self.window_settings = state['window_settings']
            self.discard_and_focus_recent(None)

    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>"]
                offset = 0
                for j, doc in enumerate(project.documents()):
                    if doc.file_path and os.path.exists(doc.file_path):
                        indexes[doc.id] = [i, j - offset]
                    else:
                        offset += 1
            yield "project_serials", 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_and_focus_recent(self, item):
        ident = None if item is None else item.id
        lookup = {}
        recent = self.recent
        self.suspend_recent_updates()
        try:
            for project in list(self.projects):
                pid = project.id
                for docview in list(project.documents()):
                    did = docview.id
                    if ident in (pid, did):
                        recent.discard(did)
                        project.remove_document_view(docview)
                        docview.close()
                    else:
                        lookup[did] = docview
                if ident == pid:
                    recent.discard(pid)
                    self.projects.remove(project)
                    project.close()
                else:
                    lookup[pid] = project
        finally:
            self.resume_recent_updates()
        while True:
            ident = recent.pop()
            if ident is None:
                break
            item = lookup.get(ident)
            if item is not None:
                self.current_view = item
                break
        if not recent and self.current_view is not None:
            recent.push(self.current_view.id)

    def suspend_recent_updates(self):
        self.recent = RecentItemStack(20)

    def resume_recent_updates(self):
        self.recent = self._suspended_recent

    def _get_current_view(self):
        return self._current_view

    def _set_current_view(self, view):
        if view is self._current_view:
            return
        self._current_view = view
        main_view = self.wc.mainView
        if view is not None:
            sel = self.wc.docsController.selectedObjects()
            if not sel or sel[0] is not view:
                self.wc.docsController.setSelectedObject_(view)
            self.recent.push(view.id)
            if isinstance(view, TextDocumentView):
                if view.scroll_view not in main_view.subviews():
                    for subview in main_view.subviews():
                        subview.removeFromSuperview()
                    view.document.addWindowController_(self.wc)
                    view.set_main_view_of_window(main_view, self.wc.window())
                    #self.wc.setDocument_(view.document)
                    if self.find_project_with_document_view(view) is None:
                        self.add_document_view(view)
                return
            #else:
            #    self.wc.window().setTitle_(view.displayName())
            #    log.debug("self.wc.window().setTitle_(%r)", view.displayName())
        for subview in main_view.subviews():
            subview.removeFromSuperview()
        self.wc.setDocument_(None)

    current_view = property(_get_current_view, _set_current_view)

    def selected_view_changed(self):
        selected = self.wc.docsController.selectedObjects()
        if selected and selected[0] is not self.current_view:
            self.current_view = selected[0]

    def add_document_view(self, doc_view):
        """Add document view to current project

        This does nothing if the current project already contains a view of the
        document encapsulated by doc_view.

        :returns: The document view from the current project.
        """
        proj = self.get_current_project(create=True)
        view = proj.document_view_for_document(doc_view.document)
        if view is None:
            view = doc_view
            proj.append_document_view(doc_view)
        return view

    def iter_views_of_document(self, doc):
        for project in self.projects:
            view = project.find_view_with_document(doc)
            if view is not None:
                yield view

    def count_views_of_document(self, doc):
        return len(list(self.iter_views_of_document(doc)))

    def should_select_item(self, outlineview, item):
        return True
        obj = outlineview.realItemForOpaqueItem_(item)
        if isinstance(obj, TextDocumentView):
            return True
        return False

    def new_project(self):
        project = Project.create()
        view = project.create_document_view()
        self.projects.append(project)
        self.current_view = view
        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_document_view(self, doc):
        for proj in self.projects:
            for d in proj.documents():
                if doc is d:
                    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):
        docs_controller = self.wc.docsController
        if docs_controller is not None:
            path = docs_controller.selectionIndexPath()
            if path is not None:
                index = path.indexAtPosition_(0)
                path2 = fn.NSIndexPath.indexPathWithIndex_(index)
                return docs_controller.objectAtArrangedIndexPath_(path2)
        if create:
            proj = Project.create()
            self.projects.append(proj)
            return proj
        return None

    def item_changed(self, item, change_type):
        view = self.wc.docsView
        if item is not None and view is not None:
            for row, obj in view.iterVisibleObjects():
                if obj is item or getattr(obj, "document", None) is item:
                    view.setNeedsDisplayInRect_(view.rectOfRow_(row))
                    break

    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 close_button_clicked(self, row):
        docs_view = self.wc.docsView
        if row < docs_view.numberOfRows():
            item = docs_view.itemAtRow_(row)
            item = docs_view.realItemForOpaqueItem_(item)
            item.perform_close(self)

    def window_did_become_key(self, window):
        view = self.current_view
        if isinstance(view, TextDocumentView):
            # TODO refactor TextDocumentView to support check_for_external_changes()
            view.document.check_for_external_changes(window)

    def window_should_close(self, window):
        from editxt.application import DocumentSavingDelegate
        # this method is called after the window controller has prompted the
        # user to save the current document (if it is dirty). This causes some
        # wierdness with the window and subsequent sheets. Technically we
        # do not need to prompt to save the current document a second time.
        # However, we will because it is easier... THIS IS UGLY! but there
        # doesn't seem to be a way to prevent the window controller from
        # prompting to save the current document when the window's close button
        # is clicked. UPDATE: the window controller seems to only prompt to save
        # the current document if the document is new (untitled).
        def iter_dirty_docs():
            app = self.app
            for proj in self.projects:
                eds = app.find_editors_with_project(proj)
                if eds == [self]:
                    for dv in proj.dirty_documents():
                        doc = dv.document
                        editors = app.iter_editors_with_view_of_document(doc)
                        if list(editors) == [self]:
                            yield dv
                    yield proj
        def callback(should_close):
            if should_close:
                window.close()
        saver = DocumentSavingDelegate.alloc(). \
            init_callback_(iter_dirty_docs(), callback)
        saver.save_next_document()
        return False

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

    def _get_window_settings(self):
        return dict(
            frame_string=str(self.wc.window().stringWithSavedFrame()),
            splitter_pos=self.wc.splitView.fixedSideThickness(),
            properties_hidden=(self.wc.propsViewButton.state() == ak.NSOnState),
        )
    def _set_window_settings(self, settings):
        fs = settings.get("frame_string")
        if fs is not None:
            self.wc.window().setFrameFromString_(fs)
            self.wc.setShouldCascadeWindows_(False)
        sp = settings.get("splitter_pos")
        if sp is not None:
            self.wc.splitView.setFixedSideThickness_(sp)
        if settings.get("properties_hidden", False):
            # REFACTOR eliminate boilerplate here (similar to toggle_properties_pane)
            self.wc.propsViewButton.setState_(ak.NSOnState)
            tree_view = self.wc.docsScrollview
            prop_view = self.wc.propsView
            tree_rect = tree_view.frame()
            prop_rect = prop_view.frame()
            tree_rect.size.height += prop_rect.size.height - 1.0
            tree_rect.origin.y = prop_rect.origin.y
            tree_view.setFrame_(tree_rect)
            prop_rect.size.height = 0.0
            prop_view.setFrame_(prop_rect)
        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
            for proj in self.projects:
                proj.close()
            #wc.docsController.setContent_(None)
            #wc.setDocument_(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:
            items = self.iter_dropped_id_list(pb)
            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 write_items_to_pasteboard(self, outline_view, items, pboard):
        """Write dragged items to pasteboard

        :param outline_view: The OutlineView containing the items.
        :param items: A list of opaque outline view item objects.
        :param pboard: ak.NSPasteboard object.
        :returns: True if items were written else False.
        """
        data = defaultdict(list)
        for item in items:
            item = outline_view.realItemForOpaqueItem_(item)
            data[const.DOC_ID_LIST_PBOARD_TYPE].append(item.id)
            path = item.file_path
            if path is not None and os.path.exists(path):
                data[ak.NSFilenamesPboardType].append(path)
        if data:
            types = [t for t in self.supported_drag_types if t in data]
            pboard.declareTypes_owner_(types, None)
            for t in types:
                pboard.setPropertyList_forType_(data[t], t)
        return bool(data)

    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:
                nprojs = len(self.projects)
                outline_view.setDropItem_dropChildIndex_(None, nprojs)
        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.documents()))
                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.documents()))
                    else:
                        outline_view.setDropItem_dropChildIndex_(None, -1)
                elif index == 0:
                    return ak.NSDragOperationNone # prevent drop above top project
        # src = info.draggingSource()
        # if src is not None:
        #   # internal drag
        #   if src is not outline_view:
        #       delegate = getattr(src, "delegate", lambda:None)()
        #       if isinstance(delegate, EditorWindowController) and \
        #           delegate is not self.wc:
        #           # drag from some other window controller
        #           # allow copy (may need to override outline_view.ignoreModifierKeysWhileDragging)
        return ak.NSDragOperationGeneric

    def accept_drop(self, outline_view, info, item, index):
        """Accept drop operation

        :param outline_view: The OutlineView on which the drop occurred.
        :param info: NSDraggingInfo object.
        :param item: The parent item in the outline view.
        :param index: The index in the outline view or parent item at which the
            drop occurred.
        :returns: True if the drop was accepted, otherwise False.
        """
        pb = info.draggingPasteboard()
        t = pb.availableTypeFromArray_(self.supported_drag_types)
        action = None
        if t == const.DOC_ID_LIST_PBOARD_TYPE:
            items = self.iter_dropped_id_list(pb)
            action = const.MOVE
        elif t == ak.NSFilenamesPboardType:
            items = self.iter_dropped_paths(pb)
        else:
            assert t is None, t
            return False
        parent = None if item is None else representedObject(item)
        return self.accept_dropped_items(items, parent, index, action)

    def iter_dropped_id_list(self, pasteboard):
        """Iterate TextDocument objects referenced by pasteboard (if any)"""
        IDLT = const.DOC_ID_LIST_PBOARD_TYPE
        if not pasteboard.types().containsObject_(IDLT):
            raise StopIteration()
        for ident in pasteboard.propertyListForType_(IDLT):
            item = self.app.find_item_with_id(ident)
            if item is not None:
                yield item

    def iter_dropped_paths(self, pasteboard):
        from editxt.document import TextDocument
        if not pasteboard.types().containsObject_(ak.NSFilenamesPboardType):
            raise StopIteration()
        for path in pasteboard.propertyListForType_(ak.NSFilenamesPboardType):
            if Project.is_project_path(path):
                proj = self.app.find_project_with_path(path)
                if proj is None:
                    proj = Project.create_with_path(path)
                yield proj
            else:
                yield TextDocument.get_with_path(path)

    @untested("untested with non-null project and index < 0")
    def accept_dropped_items(self, items, project, index, action):
        """Insert dropped items into the document tree

        :param items: A sequence of dropped projects and/or documents.
        :param project: The parent project into which items are being dropped.
        :param index: The index in the outline view or parent project at which
            the drop occurred.
        :param action: The type of drop: None (unspecified), MOVE, or COPY.
        :returns: True if the items were accepted, otherwise False.
        """
        if project is None:
            # a new project will be created if/when needed
            if index < 0:
                proj_index = 0
            else:
                proj_index = index
            index = 0
        else:
            proj_index = len(self.projects) # insert projects at end of list
            assert isinstance(project, Project), project
            if index < 0:
                index = len(project.documents())
        accepted = False
        focus = None
        is_move = action is not const.COPY
        self.suspend_recent_updates()
        try:
            for item in items:
                accepted = True
                if isinstance(item, Project):
                    if not is_move:
                        raise NotImplementedError('cannot copy project yet')
                    editors = self.app.find_editors_with_project(item)
                    assert len(editors) < 2, editors
                    if item in self.projects:
                        editor = self
                        pindex = self.projects.index(item)
                        if pindex == proj_index:
                            continue
                        if pindex - proj_index <= 0:
                            proj_index -= 1
                    else:
                        editor = editors[0]

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

                    self.projects.insert(proj_index, item)
                    proj_index += 1
                    focus = item
                    continue

                if project is None:
                    if isinstance(item, TextDocumentView) and is_move:
                        view = item
                        item.project.remove_document_view(view)
                    else:
                        view = TextDocumentView.create_with_document(item)
                    project = Project.create()
                    self.projects.insert(proj_index, project)
                    proj_index += 1
                    index = 0
                else:
                    if isinstance(item, TextDocumentView):
                        view, item = item, item.document
                    else:
                        view = project.document_view_for_document(item)
                    if is_move and view is not None:
                        if view.project == project:
                            vindex = project.documents().index(view)
                            if vindex in [index - 1, index]:
                                continue
                            if vindex - index <= 0:
                                index -= 1
                        view.project.remove_document_view(view)
                    else:
                        view = TextDocumentView.create_with_document(item)
                project.insert_document_view(index, view)
                focus = view
                index += 1
        finally:
            self.resume_recent_updates()
        if focus is not None:
            self.current_view = focus
        return accepted

    def undo_manager(self):
        doc = self.wc.document()
        if doc is None:
            return fn.NSUndoManager.alloc().init()
        return doc.undoManager()
Ejemplo n.º 8
0
 def suspend_recent_updates(self):
     self.recent = RecentItemStack(20)
Ejemplo n.º 9
0
def test_recent_items_size_zero():
    ris = RecentItemStack(0)
    eq_(list(ris), [])
    ris.push(1)
    eq_(list(ris), [])
    eq_(ris.pop(), None)
Ejemplo n.º 10
0
def test_recent_items_pop_empty():
    ris = RecentItemStack(4)
    eq_(len(ris), 0)
    assert ris.pop() is None
Ejemplo n.º 11
0
def test_recent_items_queue_size():
    ris = RecentItemStack(20)
    eq_(len(ris), 0)
    eq_(ris.max_size, 20)
Ejemplo n.º 12
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