def test(num, expect, text, iter_to_line=None): text = Text(text) lines = LineNumbers(text) if iter_to_line is END: list(lines.iter_from(0)) assert lines.end is not None elif iter_to_line is not None: if iter_to_line is LINE: iter_to_line = num for line, index in lines.iter_from(0): if line >= iter_to_line: break if isinstance(expect, int): eq_(lines.index_of(num), expect) else: with assert_raises(expect): lines.index_of(num)
class Editor(CommandSubject): """Editor Reference graph: strong: app -> window -> KVOProxy(project) -> KVOProxy(self) weak: self -> project -> window -> app """ _project = WeakProperty() is_leaf = True def __init__(self, project, *, document=None, path=None, state=None): if state is not None: if "internal" in state: app = project.window.app document = app.get_internal_document(state["internal"]) else: assert document is None, (state, document) assert path is None, (state, path) path = state["path"] if path is not None: assert document is None, (path, document) if document is None: document = project.window.app.document_with_path(path) assert document is not None, (project, path, state) self.editors = KVOList.alloc().init() self.id = next(DocumentController.id_gen) self._project = project self.document = document self.proxy = KVOProxy(self) self.main_view = None self.text_view = None self.scroll_view = None self._goto_line = None self.line_numbers = LineNumbers(self.text) props = document.props self.kvolink = KVOLink([ (props, "is_dirty", self.proxy, "is_dirty"), (props, "indent_mode", self.proxy, "indent_mode"), (props, "indent_size", self.proxy, "indent_size"), (props, "newline_mode", self.proxy, "newline_mode"), (props, "syntaxdef", self.proxy, "syntaxdef"), (props, "character_encoding", self.proxy, "character_encoding"), (props, "highlight_selected_text", self.proxy, "highlight_selected_text"), ]) if state is not None: self.edit_state = state self.undo_manager.on(self.on_dirty_status_changed) def icon(self): return self.document.icon() @property def app(self): return self.project.window.app @property def project(self): """Get/set this editor's project The setter will remove this editor from it's previous project's editors if it is found there. """ return self._project @project.setter def project(self, new): old = getattr(self, "_project", None) if old is not None: old.remove(self) self._project = new @property def window(self): return self.project.window @property def name(self): return self.document.name @property def text(self): return self.document.text_storage @property def undo_manager(self): return self.document.undo_manager @property def file_path(self): return self.document.file_path @file_path.setter def file_path(self, value): self.document.file_path = value @property def selection(self): if self.text_view is None: return None return self.text_view.selectedRange() @selection.setter def selection(self, rng): self.text_view.select(rng) @property def is_dirty(self): return self.document.is_dirty() def on_dirty_status_changed(self, dirty): self.window.on_dirty_status_changed(self, dirty) def short_path(self, name=True): path = self.file_path if not name: path = os.path.dirname(path) if self.project.path and path.startswith(self.project.path + os.path.sep): path = path[len(self.project.path) + 1:] return user_path(path) def dirname(self): if self.file_path and os.path.isabs(self.file_path): return os.path.dirname(self.file_path) return self.project.dirname() def save(self, prompt=False, callback=(lambda saved:None)): """Save the document to disk Possible UI interactions: - get file path if the file has not been saved. - ask to overwrite existing file if file has not been opened from or saved to its current file_path before. - ask to overwrite if the file has changed on disk and there has been no subsequent prompt to reload. :param prompt: Optional boolean argument, defaults to False. Unconditionally prompt for new save location if True. :param callback: Optional callback to be called with the save result of the save operation (True if successful else False). """ document = self.document window = self.window def save_with_path(path): saved = False try: if path is not None: if document.file_path != path: document.file_path = path document.save() saved = True if self.text_view is not None: self.text_view.breakUndoCoalescing() except DocumentError as err: log.error(err) except Exception: log.exception("cannot save %s", path) finally: callback(saved) if prompt or not document.file_exists(): window.save_document_as(self, save_with_path) elif document.file_changed_since_save(): window.prompt_to_overwrite(self, save_with_path) else: save_with_path(document.file_path) def should_close(self, callback): """Check if the document can be closed Prompt for save, discard, or cancel if the document is dirty and call ``callback(<should close>)`` once the appropriate action has been performed. Otherwise call ``callback(True)``. The callback may raise an exception; if it does it must be allowed to propagate to continue the termination sequence. """ if not self.is_dirty: callback(True) return def save_discard_or_cancel(save): """Save, discard, or cancel the current operation :param save: True => save, False => discard, None => cancel """ if save: self.save(callback=callback) else: callback(save is not None) document = self.document save_as = not document.has_real_path() self.window.prompt_to_close(self, save_discard_or_cancel, save_as) def set_main_view_of_window(self, view, window): frame = view.bounds() if self.scroll_view is None: self.main_view = setup_main_view(self, frame) self.scroll_view = self.main_view.top self.command_view = self.main_view.bottom self.text_view = self.scroll_view.documentView() # HACK deep reach self.set_text_attributes() self.reset_edit_state() self.on_selection_changed(self.text_view) if self._goto_line is not None: self.text_view.goto_line(self._goto_line) else: self.main_view.setFrame_(frame) view.addSubview_(self.main_view) window.makeFirstResponder_(self.text_view) self.document.update_syntaxer() self.document.check_for_external_changes(window) def focus(self): if self is not self.window.current_editor: self.window.current_editor = self elif self.text_view is not None: self.text_view.focus() def set_text_attributes(self, attrs=None): view = self.text_view if view is None: return if attrs is None: attrs = self.document.default_text_attributes() ruler = self.scroll_view.verticalRulerView() # HACK deep reach view.font_smoothing = ruler.font_smoothing = self.document.font.smooth view.setTypingAttributes_(attrs) view.setDefaultParagraphStyle_(attrs[ak.NSParagraphStyleAttributeName]) self.scroll_view.setBackgroundColor_(self.app.theme.background_color) del view.margin_params font = attrs[ak.NSFontAttributeName] half_char = font.advancementForGlyph_(ord("8")).width / 2 ruler.invalidateRuleThickness() view.setTextContainerInset_(fn.NSMakeSize(half_char, half_char)) # width/height view.setNeedsDisplay_(True) if self.window.current_editor is self: self.document.update_syntaxer() @property def soft_wrap(self): if self.text_view is None: return self.edit_state["soft_wrap"] return self.text_view.soft_wrap() @soft_wrap.setter def soft_wrap(self, value): if self.text_view is None: state = getattr(self, "_state", {}) state["soft_wrap"] = value self._state = state return self.text_view.soft_wrap(value) @document_property def indent_size(self, new, old): mode = self.document.indent_mode if mode == const.INDENT_MODE_TAB: self.change_indentation(mode, old, mode, new, True) elif new != old: self.document.props.indent_size = new @document_property def indent_mode(self, new, old): if new != old: self.document.props.indent_mode = new @document_property def newline_mode(self, new, old): undoman = self.undo_manager if not (undoman.isUndoing() or undoman.isRedoing()): replace_newlines(self.text_view, const.EOLS[new]) self.document.props.newline_mode = new def undo(): self.proxy.newline_mode = old register_undo_callback(undoman, undo) @document_property def syntaxdef(self, new, old): self.document.syntaxdef = new @document_property def character_encoding(self, new, old): self.document.character_encoding = new @document_property def font(self, new, old): self.document.font = new @document_property def highlight_selected_text(self, new, old): if not new: self.finder.mark_occurrences("") self.document.highlight_selected_text = new @document_property def updates_path_on_file_move(self, new, old): self.document.updates_path_on_file_move = new def change_indentation(self, old_mode, old_size, new_mode, new_size, convert_text): if convert_text: old_indent = "\t" if old_mode == const.INDENT_MODE_TAB else (" " * old_size) new_indent = "\t" if new_mode == const.INDENT_MODE_TAB else (" " * new_size) change_indentation(self.text_view, old_indent, new_indent, new_size) if old_mode != new_mode: self.document.props.indent_mode = new_mode if old_size != new_size: self.document.props.indent_size = new_size if convert_text or convert_text is None: def undo(): self.change_indentation(new_mode, new_size, old_mode, old_size, None) register_undo_callback(self.undo_manager, undo) @property def edit_state(self): if self.text_view is not None: sel = self.selection sp = self.scroll_view.documentVisibleRect().origin state = dict( selection=[sel.location, sel.length], scrollpoint=[sp.x, sp.y], soft_wrap=self.soft_wrap, ) else: state = dict(getattr(self, "_state", {})) state.setdefault("soft_wrap", self.app.config["soft_wrap"]) upfm_default = bool(self.app.config["updates_path_on_file_move"]) if bool(self.updates_path_on_file_move) != upfm_default: state["updates_path_on_file_move"] = False if self.document is self.app.errlog.document \ and not self.document.has_real_path(): state["internal"] = "errlog" else: assert self.file_path is not None, repr(self) state["path"] = str(self.file_path) state.pop("internal", None) return state @edit_state.setter def edit_state(self, state): if self.text_view is not None: point = state.get("scrollpoint", [0, 0]) self.point = point sel = state.get("selection", [0, 0]) self.soft_wrap = state.get("soft_wrap", self.app.config["soft_wrap"]) # HACK text_view.scrollPoint_ does not work without this char_index, ignore = self.text_view.layoutManager() \ .characterIndexForPoint_inTextContainer_fractionOfDistanceBetweenInsertionPoints_( (0.0, point[1] + self.text_view.bounds().size.height), self.text_view.textContainer(), None) length = self.document.text_storage.length() char_index = min(char_index, length - 1) self.line_numbers[char_index] # count lines for ruler view self.scroll_view.verticalRulerView().invalidateRuleThickness() self.text_view.scrollPoint_(point) if sel[0] > length: sel = (length, 0) elif sel[0] + sel[1] > length: sel = (sel[0], length - sel[0]) self.text_view.setSelectedRange_(sel) else: self._state = state if "updates_path_on_file_move" in state: self.proxy.updates_path_on_file_move = bool(state["updates_path_on_file_move"]) def reset_edit_state(self): state = getattr(self, "_state", None) if state is not None: self.edit_state = state del self._state else: self.soft_wrap = self.app.config["soft_wrap"] def goto_line(self, line): if self.text_view is None: self._goto_line = line else: self.text_view.goto_line(line) def interactive_close(self, do_close): """Close this editor if the user agrees to do so :param do_close: A function to be called to close the document. """ def last_editor_of_document(): return all(editor is self for editor in self.app.iter_editors_of_document(self.document)) if self.is_dirty and last_editor_of_document(): def callback(should_close): if should_close: do_close() self.should_close(callback) else: do_close() def close(self): project = self.project doc = self.document self.undo_manager.off(self.on_dirty_status_changed) # remove from window.dirty_editors if present project.window.on_dirty_status_changed(self, False) self.stop_output() self.project = None # removes editor from project.editors if self.text_view is not None and doc.text_storage is not None: doc.text_storage.removeLayoutManager_(self.text_view.layoutManager()) if all(e is self for e in doc.app.iter_editors_of_document(doc)): doc.close() self.document = None if self.main_view is not None: teardown_main_view(self.main_view) self.main_view = None self.text_view = None self.scroll_view = None self.command_view = None self.proxy = None self.line_numbers.close() def __repr__(self): name = 'N/A' if self.document is None else self.name return '<%s 0x%x name=%s>' % (type(self).__name__, id(self), name) # TextView delegate ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @property def finder(self): try: finder = self._finder except Exception: finder = self._finder = Finder( (lambda:self), FindOptions(ignore_case=False, wrap_around=False), self.app, ) return finder @noraise def on_selection_changed(self, textview): from editxt.platform.text import composed_length lines = self.line_numbers text = lines.text length = len(text) range = textview.selectedRange() index = min(range.location, length) if length else 0 try: line = lines[index] except IndexError: if index != length or not lines.end: log.warn("expected index (%s) to equal length (%s) or " "newline (%s lines)", index, length, lines.end, lines.newline_at_end, len(lines)) line = len(lines) line_index = self.line_numbers.index_of(line) if line_index < index: col = composed_length(text[line_index:index]) else: col = 0 sel = composed_length(text[range]) self.scroll_view.status_view.updateLine_column_selection_(line, col, sel) if self.document.highlight_selected_text: self.highlight_selection(text, range) @debounce def highlight_selection(self, text, range): if self.project is None: return ftext = text[range] if len(ftext.strip()) < 3 or " " in ftext: ftext = "" self.finder.mark_occurrences(ftext)