def setup(view, syntax_control, get_syntax): ALL = object() edit_range = None highlighter = Highlighter(self.app.theme) highlighter.syntaxdef = get_syntax() view.highlighter = highlighter def coalesce_edit_ranges(rng=None): nonlocal edit_range rng_ = edit_range if rng is None or rng_ is ALL: rng = None elif rng_ is not None: rng = union_range(rng, rng_) edit_range = rng or ALL return (rng,), {} @debounce(0.05, coalesce_edit_ranges) def color_text(rng): nonlocal edit_range highlighter.color_text(view.text, rng, timeout=0.05) edit_range = None def text_changed(rng): if view.text.editedMask() == ak.NSTextStorageEditedAttributes: # break color text -> attribute change -> color text -> ... return color_text(rng) view.text.on_edit(text_changed) def change_syntax(): syntaxdef = get_syntax() if highlighter.syntaxdef is not syntaxdef: highlighter.syntaxdef = syntaxdef color_text() syntax_control.on.click(change_syntax)
def edit(lang, string, expect, *pairs): hl = Highlighter(theme) hl.syntaxdef = get_syntax_definition(lang) assert len(pairs) % 2 == 0, "got odd number of edit/expect pairs" string = dedent(string) if string.startswith("\n") else string text = Text(string) yield test(hl, text, expect) for edit, expect in zip(islice(pairs, 0, None, 2), islice(pairs, 1, None, 2)): edit_text = dedent(edit[2]).lstrip() if edit[2].startswith("\n") else edit[2] edit = edit[0], edit[1], edit_text yield test(hl, text, expect, edit)
def __init__(self, app, path=None): self.app = app self._updates_path_on_file_move = app.config["updates_path_on_file_move"] self.file_path = path or const.UNTITLED_DOCUMENT_NAME self.persistent_path = path self.id = next(DocumentController.id_gen) self.icon_cache = (None, None) self.document_attrs = { ak.NSDocumentTypeDocumentAttribute: ak.NSPlainTextDocumentType, ak.NSCharacterEncodingDocumentAttribute: fn.NSUTF8StringEncoding, } self.undo_manager = UndoManager() self.syntaxer = Highlighter(app.theme) self.props = KVOProxy(self) self._kvo = KVOLink([ (self.undo_manager, "has_unsaved_actions", self.props, "is_dirty"), ]) self.indent_mode = app.config["indent.mode"] self.indent_size = app.config["indent.size"] # should come from syntax definition self.newline_mode = app.config["newline_mode"] self.highlight_selected_text = app.config["theme.highlight_selected_text.enabled"] self.syntax_needs_color = False self._edit_range = None app.on_reload_config(self.reset_text_attributes, self)
def test(lang, string, expect, edit=None): theme.reset() if isinstance(lang, Highlighter): hl = lang else: hl = Highlighter(theme) hl.syntaxdef = get_syntax_definition(lang) text = string if isinstance(string, Text) else Text(dedent(string)) get_color = lambda value: value with replattr(editxt.theme, "get_color", get_color, sigcheck=False), CaptureLogging(mod) as log: if edit: start, length, insert = edit text[(start, length)] = Text(insert) hl.color_text(text, (start, len(insert))) else: hl.color_text(text) errors = log.data.get("error") assert not errors, "Errors logged:\n" + "\n---\n\n".join(errors) a = list(chain.from_iterable(x.split("\n") for x in text.colors(hl))) b = dedent(expect).strip().split("\n") if expect else [] assert a == b, "\n" + "\n".join(unified_diff(b, a, "expected", "actual", lineterm=""))
def test_Highlighter_color_text(): from objc import NULL from textwrap import dedent from editxt.platform.text import Text as BaseText from editxt.syntax import SYNTAX_RANGE, SYNTAX_TOKEN class Text(BaseText): def colors(self, highlighter): seen = set() lines = [] length = len(self) full = rng = (0, length) fg_color = ak.NSForegroundColorAttributeName long_range = self.attribute_atIndex_longestEffectiveRange_inRange_ while rng[1] > 0: key, xrng = long_range(SYNTAX_RANGE, rng[0], None, rng) lang = highlighter.langs[key].name if key else "" if lang == highlighter.syntaxdef.name: lang = "" # print(lang.strip(), xrng) level = 1 if lang else 0 xend = sum(xrng) while True: attr, attr_rng = long_range(SYNTAX_TOKEN, xrng[0], None, full) color, color_rng = long_range(fg_color, xrng[0], None, full) if attr: color_rng = attr_rng else: color_rng = intersect_ranges(attr_rng, color_rng) text = self[color_rng] if attr or color and color != "text_color" and text.strip(): if attr: attr_name = attr else: # default text color, not a matched token attr_name = "{} {} -".format(lang, color) print(attr_name.ljust(30), (key or "").ljust(15), repr(text)[1:-1]) print_lang = lang and attr and attr.startswith(lang) attr = attr.rsplit(" ", 1)[-1] if attr else color lines.append( "{}{} {}{}".format( " " * level, text, attr if attr == color else "{} {}".format(attr, color), (" " + lang) if print_lang else "", ) ) start = sum(attr_rng) if start >= xend: xend = start break xrng = (start, xend - start) rng = (xend, length - xend) return lines @gentest def test(lang, string, expect, edit=None): theme.reset() if isinstance(lang, Highlighter): hl = lang else: hl = Highlighter(theme) hl.syntaxdef = get_syntax_definition(lang) text = string if isinstance(string, Text) else Text(dedent(string)) get_color = lambda value: value with replattr(editxt.theme, "get_color", get_color, sigcheck=False), CaptureLogging(mod) as log: if edit: start, length, insert = edit text[(start, length)] = Text(insert) hl.color_text(text, (start, len(insert))) else: hl.color_text(text) errors = log.data.get("error") assert not errors, "Errors logged:\n" + "\n---\n\n".join(errors) a = list(chain.from_iterable(x.split("\n") for x in text.colors(hl))) b = dedent(expect).strip().split("\n") if expect else [] assert a == b, "\n" + "\n".join(unified_diff(b, a, "expected", "actual", lineterm="")) def edit(lang, string, expect, *pairs): hl = Highlighter(theme) hl.syntaxdef = get_syntax_definition(lang) assert len(pairs) % 2 == 0, "got odd number of edit/expect pairs" string = dedent(string) if string.startswith("\n") else string text = Text(string) yield test(hl, text, expect) for edit, expect in zip(islice(pairs, 0, None, 2), islice(pairs, 1, None, 2)): edit_text = dedent(edit[2]).lstrip() if edit[2].startswith("\n") else edit[2] edit = edit[0], edit[1], edit_text yield test(hl, text, expect, edit) config = Config( None, schema={ "theme": { "text_color": String("text_color"), "syntax": { "default": { "attribute": String("attribute"), "builtin": String("builtin"), "comment": String("comment"), "group": String("group"), "header": String("header"), "keyword": String("keyword"), "name": String("name"), "operator": String("operator"), "punctuation": String("punctuation"), "string": String("string"), "string.multiline.single-quote": String("string.multiline.single-quote"), "tag": String("tag"), "value": String("value"), }, "JavaScript": {"string": String("js.string")}, }, } }, ) theme = editxt.theme.Theme(config) text = Text("def f(self, x): x # TODO") hl = Highlighter(theme) hl.syntaxdef = get_syntax_definition("python") hl.color_text(text) yield test(mod.PLAIN_TEXT, text, "") yield test( "python", "def f(self, x): x # TODO", """ def keyword self builtin # TODO comment.single-line comment """, ) yield test( "python", "# \U0001f612 \U0001f34c\ndef f(self, x): x # TODO", """ # \U0001f612 \U0001f34c comment.single-line comment def keyword self builtin # TODO comment.single-line comment """, ) yield test( "python", "'\nfor x", """ ' string.single-quote string for keyword """, ) yield test( "python", '"\ndef f(', """ " string.double-quote string def keyword """, ) yield test( "python", r""" x = "\"" y = "\\" z = "\\\"" Q = "\\\\\"" """, r""" " string.double-quote string \" operator.escape operator " string.double-quote string " string.double-quote string \\ operator.escape operator " string.double-quote string " string.double-quote string \\\" operator.escape operator " string.double-quote string " string.double-quote string \\\\\" operator.escape operator " string.double-quote string """, ) yield test( "python", r""" x = r"\"" y = r"\\" z = r"\\\"" Q = r"\\\\\"" """, r""" r" string.double-quote string \" operator.escape operator Regular Expression " string.double-quote string r" string.double-quote string \\ operator.escape operator Regular Expression " string.double-quote string r" string.double-quote string \\\" operator.escape operator Regular Expression " string.double-quote string r" string.double-quote string \\\\\" operator.escape operator Regular Expression " string.double-quote string """, ) yield test( "python", "''' for x", """ ''' string.multiline.single-quote for x string.multiline.single-quote """, ) yield test( "python", "''' for x'''", """ ''' string.multiline.single-quote for x string.multiline.single-quote ''' string.multiline.single-quote """, ) yield test( "python", "'begin\\\nend'", """ ' string.single-quote string begin string \\ operator.escape.continuation operator end string ' string.single-quote string """, ) yield test( "python", '''"""A doc string\nWith multiple lines\n"""''', ''' """ string.multiline.double-quote string A doc string With multiple lines string """ string.multiline.double-quote string ''', ) yield from edit( "python", "\ndef f(", """ def keyword """, (0, 0, '"'), """ " string.double-quote string def keyword """, (1, 0, '"'), """ "" string.double-quote string def keyword """, (2, 0, '"'), ''' """ string.multiline.double-quote string def f( string ''', (2, 1, ""), """ "" string.double-quote string def keyword """, ) yield from edit( "python", "def\n", """ def keyword """, (0, 4, ""), # bug: error on delete all content "", ) yield from edit( "python", ' "word" ', """ " string.double-quote string word string " string.double-quote string """, (1, 0, "r"), """ r" string.double-quote string word string " string.double-quote string """, (1, 1, ""), """ " string.double-quote string word string " string.double-quote string """, ) yield from edit( "python", r"""r"(?P<xyz>)" """, """ r" string.double-quote string (?P< group.named group Regular Expression xyz name Regular Expression > group.named group Regular Expression ) group Regular Expression " string.double-quote string """, (4, 0, " "), """ r" string.double-quote string ( string ? keyword Regular Expression P<xyz> string ) group Regular Expression " string.double-quote string """, (4, 1, ""), """ r" string.double-quote string (?P< group.named group Regular Expression xyz name Regular Expression > group.named group Regular Expression ) group Regular Expression " string.double-quote string """, ) yield from edit( "python", ''' r""" [\s] """ [] ' """ """ ' ''', ''' r""" string.multiline.double-quote string [ keyword.set keyword Regular Expression \s operator.class operator Regular Expression ] keyword.set keyword Regular Expression """ string.multiline.double-quote string ' string.single-quote string """ """ string ' string.single-quote string ''', (9, 1, ""), ''' r""" string.multiline.double-quote string [ keyword.set keyword Regular Expression \s operator.class operator Regular Expression """ string.multiline.double-quote string ' string.single-quote string """ """ string ' string.single-quote string ''', (9, 0, "]"), ''' r""" string.multiline.double-quote string [ keyword.set keyword Regular Expression \s operator.class operator Regular Expression ] keyword.set keyword Regular Expression """ string.multiline.double-quote string ' string.single-quote string """ """ string ' string.single-quote string ''', ) yield from edit( "python", ''' """x """ def ''', ''' """ string.multiline.double-quote string x string """ string.multiline.double-quote string def keyword ''', (16, 0, "\n"), ''' """ string.multiline.double-quote string x string """ string.multiline.double-quote string def keyword ''', ) yield test( "markup", """<div""", """ <div tag """, ) yield test( "markup", """<div <span>""", """ <div tag <span> tag """, ) yield test( "markup", """<div tal:wrap='<span>'""", """ <div tag tal:wrap attribute = tag.punctuation tag '<span>' value """, ) yield test( "markup", """<div class='ext' data id="xpr"> </div>""", """ <div tag class attribute = tag.punctuation tag 'ext' value data attribute id attribute = tag.punctuation tag "xpr" value > tag </div> tag """, ) yield test( "markup", """<!DOCTYPE>\n<div/>""", """ <!DOCTYPE> tag.doctype tag <div/> tag """, ) yield test( "markup", """<!DOCTYPE html encoding="utf-8">\n<div/>""", """ <!DOCTYPE tag.doctype tag html attribute encoding attribute = tag.punctuation tag "utf-8" value > tag.doctype tag <div/> tag """, ) yield test("markup", """<!---->""", """<!----> comment""") yield test("markup", """<!-- <head><style> -->""", """<!-- <head><style> --> comment""") yield test( "markup", """<div><![CDATA[ abc <xyz> 3&""", """ <div> tag <![CDATA[ tag.cdata tag """, ) yield test( "markup", """<div><![cdata[ abc <xyz> 3& ]]>""", """ <div> tag <![cdata[ tag.cdata tag ]]> tag.cdata tag """, ) yield test( "markup", "<style attr='value'></style>", """ <style tag attr attribute = tag.punctuation tag 'value' value ></style> tag """, ) yield test( "markup", "<script>var x = 'y';</script>", """ <script> tag var keyword JavaScript 'y' string.single-quote js.string JavaScript </script> tag """, ) yield test( "markup", "<style>.error { color: red; }</style>", """ <style> tag .error selector-class text_color CSS { text_color CSS color attribute CSS : text_color CSS ; text_color CSS } text_color CSS </style> tag """, ) yield test( "markdown", """ ```Python def inc(arg): return arg + 1 ``` """, """ ``` code text_color Python tag def keyword Python return keyword Python ``` code text_color """, ) yield test( "markdown", """ ```unknown-language-name def inc(arg): return arg + 1 ``` """, """ ``` code text_color unknown-language-name text_color ``` code text_color """, ) yield test( "markdown", """ *Clojure REPL* ```clojure-repl user=> (defn f [x y] #_=> (+ x y)) #'user/f user=> (f 5 7) 12 user=> nil nil ``` *Clojure* """, """ *Clojure REPL* emphasis text_color ``` code text_color clojure-repl tag user=> meta text_color Clojure REPL ( text_color Clojure defn builtin-name text_color Clojure [ text_color Clojure ] text_color Clojure #_=> meta text_color Clojure REPL ( text_color Clojure + builtin-name text_color Clojure ) text_color Clojure user=> meta text_color Clojure REPL ( text_color Clojure f name Clojure 5 number text_color Clojure 7 number text_color Clojure ) text_color Clojure user=> meta text_color Clojure REPL nil literal text_color Clojure ``` code text_color *Clojure* emphasis text_color """, ) yield test( "markdown", """ ```asciidoc Want to see a image::images/tiger.png[Tiger]? *strong* [quote, Sir Arthur Conan Doyle] ____ When you have eliminated all... ``` not *strong* """, """ ``` code text_color asciidoc tag image::images/tiger.png link text_color AsciiDoc [ text_color AsciiDoc Tiger string ] text_color AsciiDoc *strong* strong text_color AsciiDoc [quote, Sir Arthur Conan Doyle] meta text_color AsciiDoc ____ When you have eliminated all... quote text_color AsciiDoc ``` code text_color *strong* emphasis text_color """, ) yield test( "markdown", """ ## YAML ```yaml --- # comment string_1: "Bar" """, """ ## YAML section text_color ``` code text_color yaml tag --- meta text_color YAML # comment YAML comment comment string_1: attr text_color YAML " string YAML Bar string " string YAML """, ) yield test( "javascript", "var x = 'y';", """ var keyword 'y' string.single-quote js.string """, ) yield test( "javascript", "var regex = /y*/ig;", """ var keyword / regexp text_color * keyword Regular Expression /ig regexp text_color """, ) yield test( "javascript", "/[x-z/ig - var;", """ / regexp text_color [ keyword.set keyword Regular Expression - operator.range operator Regular Expression /ig regexp text_color var keyword """, ) yield test( "regular-expression", r"^a(.)c$", """ ^ keyword ( group . keyword ) group $ keyword """, ) yield test( "regular-expression", r"a(?:(.))c", """ (?:( group . keyword )) group """, ) yield test( "regular-expression", r" good (?# junk ) good (?=aft.r)(?!n.t)", """ (?# junk ) comment (?= group . keyword )(?! group . keyword ) group """, ) yield test( "regular-expression", r"(?<=b.hind)(?<!n.t)", """ (?<= group . keyword )(?<! group . keyword ) group """, ) yield test( "regular-expression", r"(.)?abc(?(1).|$)", """ ( group . keyword ) group ? keyword (?(1) group .|$ keyword ) group """, ) yield test( "regular-expression", r"\1 \01 \7654 \999", r""" \1 group.ref group \01 operator.escape.char operator \765 operator.escape.char operator \99 group.ref group """, ) yield test( "regular-expression", r"\A \b \B \w \W \Z \\ \. \u0a79 \U000167295", r""" \A operator.class operator \b operator.class operator \B operator.class operator \w operator.class operator \W operator.class operator \Z operator.class operator \\ operator.escape operator \. operator.escape operator \u0a79 operator.escape.char operator \U00016729 operator.escape.char operator """, ) yield test( "regular-expression", r"[-\wa-z\-\S-]", r""" [ keyword.set keyword \w operator.class operator - operator.range operator \- operator.escape operator \S operator.class operator ] keyword.set keyword """, ) yield test( "regular-expression", r" [^-\wa-z\-\S-] ", r""" [^ keyword.set.inverse keyword \w operator.class operator - operator.range operator \- operator.escape operator \S operator.class operator ] keyword.set.inverse keyword """, ) yield test( "erlang", """ -module(ssh_cli). -behaviour(ssh_channel). -include("ssh.hrl"). %% backwards compatibility """, """ -module keyword ( params text_color ) params text_color . text_color -behaviour keyword ( params text_color ) params text_color . text_color -include keyword ( params text_color " string ssh.hrl string " string ) params text_color . text_color % comment % backwards compatibility comment """, ) yield test( "erlang", """ init([Shell, Exec]) -> {ok, #state{shell = Shell, exec = Exec}}; """, """ init title text_color ( params text_color ) params text_color -> function text_color { text_color #state{ text_color }} text_color ; function text_color """, ) yield from edit( "YAML", """ #theme: # text_color: EFEFEF # selection_color: 584848 # background_color: 101010 font: face: Inconsolata size: 13 """, """ # comment theme: comment # comment text_color: EFEFEF comment # comment selection_color: 584848 comment # comment background_color: 101010 comment font: attr text_color face: attr text_color size: attr text_color 13 number text_color """, ( 93, 0, """ shortcuts: "Command+{": " doc down" "Command+}": " doc up" """, ), """ # comment theme: comment # comment text_color: EFEFEF comment # comment selection_color: 584848 comment # comment background_color: 101010 comment shortcuts: attr text_color " string Command+{ string " string " string doc down string " string " string Command+} string " string " string doc up string " string font: attr text_color face: attr text_color size: attr text_color 13 number text_color """, ) class lang: class sub: rules = [("keyword", ["for"])] rules = [("keyword", ["for", "in"]), ("tag", "[", ["]"], sub)] lang = make_definition(lang) yield from edit( lang, "for x in [y for y in z]:", """ for keyword in keyword [ tag for keyword ] tag """, (9, 1, ""), """ for keyword in keyword for keyword in keyword """, ) yield from edit( lang, "for x in [y for y in z]:", """ for keyword in keyword [ tag for keyword ] tag """, (4, 6, "[x for "), """ for keyword [ tag for keyword for keyword ] tag """, ) class xkw: rules = [("name", ["x"])] class lang: name = "transition" class params: rules = [("group", "(", [")"], xkw)] class func_name: rules = [("name", [RE(r"[a-z]+")])] rules = [ ("keyword", ["end"]), ("keyword", "def", [RE(r"$"), params], func_name), ("tag", RE(r"[a-z]+\("), [")"], xkw), ] lang = make_definition(lang) yield test( lang, "def f(x) x + do(x + 1) end", """ def keyword f name ( group x name ) group do( tag x name ) tag end keyword """, ) class lang: name = "recursive" class call: rules = [("tag", ["do"])] rules = [("keyword", ["def"]), ("group", "(", [")"], call)] lang.call.rules.append(lang.rules[1]) lang = make_definition(lang) yield test( lang, "def (def do(do (def)))", """ def keyword ( group do tag ( group do tag ( group ))) group """, ) class lang: name = "start-end-lang-no-body" default_text = const.DELIMITER class _lparen: rules = [("_lparen", ["("])] class _rparen: rules = [("_rparen", [")"])] rules = [("keyword", ["def"]), ("group", _lparen, [_rparen])] lang = make_definition(lang) yield test( lang, "def (do def) def", """ def keyword ( text_color do def group ) text_color def keyword """, ) def test_err(msg, *args): with CaptureLogging(mod) as log: test.test(*args) eq_(log.data["exception"], [Regex(msg)]) class lang: name = "non-advancing-range" rules = [("group", RE(r"(?=.)"), [RE(r"\b|\B")])] lang = make_definition(lang) yield test_err, "non-advancing range: index=0 ", lang, "a", "" class lang: name = "infinite-language-recursion" rules = [] lang.rules.append(("group", RE(r"(?=.)"), [RE(r"x")], lang)) lang = make_definition(lang) yield test_err, "max recursion exceeded", lang, "a", ""
class TextDocument(object): app = WeakProperty() def __init__(self, app, path=None): self.app = app self._updates_path_on_file_move = app.config["updates_path_on_file_move"] self.file_path = path or const.UNTITLED_DOCUMENT_NAME self.persistent_path = path self.id = next(DocumentController.id_gen) self.icon_cache = (None, None) self.document_attrs = { ak.NSDocumentTypeDocumentAttribute: ak.NSPlainTextDocumentType, ak.NSCharacterEncodingDocumentAttribute: fn.NSUTF8StringEncoding, } self.undo_manager = UndoManager() self.syntaxer = Highlighter(app.theme) self.props = KVOProxy(self) self._kvo = KVOLink([(self.undo_manager, "has_unsaved_actions", self.props, "is_dirty")]) self.indent_mode = app.config["indent.mode"] self.indent_size = app.config["indent.size"] # should come from syntax definition self.newline_mode = app.config["newline_mode"] self.highlight_selected_text = app.config["theme.highlight_selected_text.enabled"] self.syntax_needs_color = False self._edit_range = None app.on_reload_config(self.reset_text_attributes, self) # self.save_hooks = [] @property def name(self): return os.path.basename(self.file_path) @property def file_path(self): if self.updates_path_on_file_move: return self._fileref.path return self._fileref @file_path.setter def file_path(self, value): if self.updates_path_on_file_move: ref = getattr(self, "_fileref", None) if ref is None: old_path = None self._fileref = FileRef(value) else: old_path = ref.original_path ref.path = value else: old_path = getattr(self, "_fileref", None) self._fileref = value self._refresh_file_mtime() # TODO should this (always) happen here? if self.has_real_path(): self.app.documents.change_document_path(old_path, self) @property def updates_path_on_file_move(self): return self._updates_path_on_file_move @updates_path_on_file_move.setter def updates_path_on_file_move(self, value): path = self.file_path = self.file_path if value: self._fileref = FileRef(path) else: self._fileref = path self._updates_path_on_file_move = value @property def file_mtime(self): return self._filestat.st_mtime if self._filestat else None @property def text_storage(self): try: return self._text_storage except AttributeError: self.text_storage = Text() self._load() return self._text_storage @text_storage.setter def text_storage(self, value): if hasattr(self, "_disable_text_edit_callback"): self._disable_text_edit_callback() del self._disable_text_edit_callback if value is not None: self._disable_text_edit_callback = value.on_edit(self.on_text_edit) self._text_storage = value @property def text(self): return self.text_storage.mutableString() @text.setter def text(self, value): self.text_storage.mutableString().setString_(value) self.reset_text_attributes() @property def newline_mode(self): return self._newline_mode @newline_mode.setter def newline_mode(self, value): self._newline_mode = value self.eol = const.EOLS[value] @property def character_encoding(self): return self.document_attrs.get(ak.NSCharacterEncodingDocumentAttribute) @character_encoding.setter def character_encoding(self, value): # TODO when value is None encoding should be removed from document_attrs if value is not None: self.document_attrs[ak.NSCharacterEncodingDocumentAttribute] = value else: self.document_attrs.pop(ak.NSCharacterEncodingDocumentAttribute, None) @property def font(self): try: return self._font except AttributeError: return self.app.default_font @font.setter def font(self, value): self._font = value self.reset_text_attributes() def reset_text_attributes(self, indent_size=0, event=None): attrs = self.default_text_attributes(indent_size) if self.text_storage is not None: no_color = {k: v for k, v in attrs.items() if k != ak.NSForegroundColorAttributeName} range = fn.NSMakeRange(0, self.text_storage.length()) self.text_storage.addAttributes_range_(no_color, range) self.text_storage.setFont_(self.font.font) self.syntax_needs_color = event and event.theme_changed for editor in self.app.iter_editors_of_document(self): editor.set_text_attributes(attrs) def default_text_attributes(self, indent_size=None): if indent_size is None: try: return self._text_attributes except AttributeError: indent_size = self.indent_size if indent_size == 0: indent_size = self.indent_size font = self.font.font tabw = ( ak.NSString.stringWithString_("8" * indent_size).sizeWithAttributes_({ak.NSFontAttributeName: font}).width ) ps = ak.NSParagraphStyle.defaultParagraphStyle().mutableCopy() ps.setTabStops_([]) ps.setDefaultTabInterval_(tabw) self._text_attributes = attrs = { ak.NSFontAttributeName: font, ak.NSParagraphStyleAttributeName: ps, ak.NSForegroundColorAttributeName: self.app.theme.text_color, } return attrs def _load(self): """Load the document's file contents from disk :returns: True if the file was loaded from disk, otherwise False. """ self.persistent_path = self.file_path self._refresh_file_mtime() if self._filestat is not None: data = ak.NSData.dataWithContentsOfFile_(self.file_path) success, err = self.read_from_data(data) if success: self.analyze_content() self.reset_text_attributes() else: log.error(err) # TODO display error in progress bar return success self.reset_text_attributes() return False def read_from_data(self, data): options = {ak.NSDefaultAttributesDocumentOption: self.default_text_attributes()} options.update(self.document_attrs) while True: success, attrs, err = self._text_storage.readFromData_options_documentAttributes_error_( data, options, None, None ) if success or ak.NSCharacterEncodingDocumentAttribute not in options: if success: self.document_attrs = attrs break if err: log.error(err) options.pop(ak.NSCharacterEncodingDocumentAttribute, None) return success, err def save(self): """Save the document to disk :raises: Error if the document does not have a real path. """ if not self.has_real_path(): if isabs(self.file_path): raise Error("parent directory is missing: {}".format(self.file_path)) else: raise Error("file path is not set") path = self.file_path self.write_to_file(path) self.file_path = path # update FileRef self.persistent_path = path for action in [self._refresh_file_mtime, self.clear_dirty, self.app.save_window_states, self.update_syntaxer]: try: action() except Exception: log.error("unexpected error", exc_info=True) def write_to_file(self, path): """Write the document's content to the given file path The given file path must be an absolute path. """ if not isabs(path): raise Error("cannot write to relative path: {}".format(path)) data, err = self.data() if err is None: ok, err = data.writeToFile_options_error_(path, 1, None) if not ok: raise Error("cannot write to {}: {}".format(path, err)) def data(self): range = fn.NSMakeRange(0, self.text_storage.length()) attrs = self.document_attrs data, err = self.text_storage.dataFromRange_documentAttributes_error_(range, attrs, None) return (data, err) def analyze_content(self): text = self.text_storage.string() start, end, cend = text.getLineStart_end_contentsEnd_forRange_(None, None, None, (0, 0)) if end != cend: eol = EOLREF.get(text[cend:end], const.NEWLINE_MODE_UNIX) self.props.newline_mode = eol mode, size = calculate_indent_mode_and_size(text) if size is not None: self.props.indent_size = size if mode is not None: self.props.indent_mode = mode def has_real_path(self): """Return true if this docuemnt has an absolute path where it could possibly be saved; otherwise false Note that this is not a garantee that the file can be saved at its currently assigned file_path. For example, this will not detect if file system permissions would prevent writing. """ return isabs(self.file_path) and isdir(dirname(self.file_path)) def file_exists(self): """Return True if this file has no absolute path on disk""" return self.has_real_path() and exists(self.file_path) def _refresh_file_mtime(self): if self.file_exists(): self._filestat = filestat(self.file_path) return self._filestat = None def is_externally_modified(self): """check if this document has been modified by another program :returns: True if the file has been modified by another program, otherwise False, and None if the file cannot be accessed. """ if self.file_exists(): stat = filestat(self.file_path) if stat is not None: return self._filestat != stat return None def file_changed_since_save(self): """Check if the file on disk has changed since the last save :returns: True if the file on disk has been edited or moved by an external program, False if the file exists but has not changed, and None if the file does not exist. """ return self.persistent_path != self.file_path or self.is_externally_modified() def check_for_external_changes(self, window): if not self.is_externally_modified(): return if self.is_dirty(): if window is None: return # ignore change (no gui for alert) stat = filestat(self.file_path) if self._filestat == stat: # TODO is this check necessary? return self._filestat = stat def callback(code, end_alert): if code == ak.NSAlertFirstButtonReturn: self.reload_document() # should self.file_changed_since_save() -> True on cancel here? alert = Alert.alloc().init() alert.setMessageText_("“%s” source document changed" % self.name) alert.setInformativeText_("Discard changes and reload?") alert.addButtonWithTitle_("Reload") alert.addButtonWithTitle_("Cancel") alert.beginSheetModalForWindow_withCallback_(window, callback) else: self.reload_document() @refactor("improve undo after reload - use difflib to replace changed text only") def reload_document(self): """Reload document with the given URL This implementation allows the user to undo beyond the reload. The down-side is that it may use a lot of memory if the document is very large. """ if not self.file_exists(): return textstore = self._text_storage self._text_storage = Text() try: ok = self._load() finally: tempstore = self._text_storage self._text_storage = textstore if not ok: return textview = None for editor in self.app.iter_editors_of_document(self): textview = editor.text_view if textview is not None: break text = tempstore.string() range = fn.NSRange(0, textstore.length()) if textview is None: textstore.replaceCharactersInRange_withString_(range, text) self.undo_manager.removeAllActions() elif textview.shouldChangeTextInRange_replacementString_(range, text): # state = self.documentState textstore.replaceCharactersInRange_withString_(range, text) # self.documentState = state textview.didChangeText() textview.breakUndoCoalescing() # HACK use timed invocation to allow didChangeText notification # to update change count before _clearUndo is invoked call_later(0, self.clear_dirty) textview.setSelectedRange_(fn.NSRange(0, 0)) self.reset_text_attributes() self.update_syntaxer() @untested def prepareSavePanel_(self, panel): try: panel.setCanSelectHiddenExtension_(True) panel.setExtensionHidden_(False) panel.setAllowsOtherFileTypes_(True) name = panel.nameFieldStringValue() # 10.6 API url = self.fileURL() if url is not None: filename = url.lastPathComponent() directory = url.URLByDeletingLastPathComponent() panel.setDirectoryURL_(directory) # 10.6 API else: filename = name name += ".txt" if name != filename or (name.endswith(".txt") and "." in name[:-4]): panel.setNameFieldStringValue_(filename) exts = ["txt"] if "." in filename: ext = filename.rsplit(".", 1)[1] if ext not in exts: exts.insert(0, ext) panel.setAllowedFileTypes_(exts) except Exception: log.error("cannot prepare save panel...", exc_info=True) return True def is_dirty(self): return self.undo_manager.has_unsaved_actions() def clear_dirty(self): self.undo_manager.savepoint() def icon(self): path = self.file_path key = "" if path is None else path old_key, data = self.icon_cache if old_key is None or old_key != key: data = fetch_icon(key) self.icon_cache = (key, data) return data @property def comment_token(self): return self.syntaxer.syntaxdef.comment_token @comment_token.setter def comment_token(self, value): self.syntaxer.syntaxdef.comment_token = value @property def syntaxdef(self): return self.syntaxer.syntaxdef @syntaxdef.setter def syntaxdef(self, value): self.syntaxer.syntaxdef = value self.color_text() def update_syntaxer(self): filename = os.path.basename(self.file_path) if filename != self.syntaxer.filename: self.syntaxer.filename = filename syntaxdef = self.app.syntax_factory.get_definition(filename) if self.syntaxdef is not syntaxdef: self.syntax_needs_color = False self.props.syntaxdef = syntaxdef self.color_text() return if self.syntax_needs_color: # force re-highlight entire document self.syntax_needs_color = False self.color_text() def on_text_edit(self, rng): if self.text_storage.editedMask() == ak.NSTextStorageEditedAttributes: # break color text -> attribute change -> color text -> ... return self.color_text(rng) def _coalesce_edit_ranges(self, rng=None): rng_ = self._edit_range if rng is None or rng_ is ALL: rng = None elif rng_ is not None: rng = union_range(rng, rng_) self._edit_range = rng or ALL return (self, rng), {} @debounce(0.05, _coalesce_edit_ranges) def color_text(self, rng): if self.text_storage is not None: self.syntaxer.color_text(self.text_storage, rng, timeout=0.05) self._edit_range = None def __repr__(self): return "<%s 0x%x %s>" % (type(self).__name__, id(self), self.name) @objc.typedSelector(b"v@:@ii") def document_shouldClose_contextInfo_(self, doc, should_close, info): self.app.context.pop(info)(should_close) def close(self): self.text_storage = None self.props = None self.app.document_closed(self) # -- TODO refactor NSDocument compat -------------------------------------- def displayName(self): return self.name def canCloseDocumentWithDelegate_shouldCloseSelector_contextInfo_(self, delegate, should_close, info): raise NotImplementedError