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])
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)
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])
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()
def test_recent_items_size_zero(): ris = RecentItemStack(0) eq_(list(ris), []) ris.push(1) eq_(list(ris), []) eq_(ris.pop(), None)
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