diff --git a/README.md b/README.md index c6329e1..6e9f841 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ insertions in green, in the file's own syntax highlighting. Stage, unstage or di to the real file at the caret's line. Install: symlink this directory into `Packages` as `vDiff` (Python 3.8 plugin host, no dependencies). +On first use vDiff writes the scopes it paints changes with into `Packages/User/.sublime-color-scheme`; +Sublime loads a scheme file it has not seen before only at startup, so restart it once to get the colours. Command Palette: diff --git a/TODO.txt b/TODO.txt index e9f3ca7..12adde1 100644 --- a/TODO.txt +++ b/TODO.txt @@ -1,30 +1,19 @@ -macbook: claude --resume 275357c1-ce75-41de-a85a-4e06288bc8f0 -macstudio: claude --resume ca7e7e5d-0ad3-45e5-bd67-e5dbd3af1b4c +## New functionality + +- Add (commit) summary view - in one view just changes across all files with 5 lines before and after change?) + +- Add pull and push action +- Add tags into commits list (and branches?) +- Add stash action .. - -Add amend action. Similar to commit but commit --amend. When user has chosen amend open commit message input field with last commit message and cursor on the end of message and user can change commit message or confirm it by pressing Enter. When User presses Esc the commmit amend is canceled. -Amend action is available under key "m" - - - -- in Files in first line there is 2 staged, 2 unstaged - add there deletions and insertions - -- Add stash action .. - -- Add (commit) summary view (just changes across files with 5 lines before and after change?) - -- visual bell blinking time setting - -- When reviewing changes in python files the deleted and inserted lines have red and green background but the text on these lines has no syntax highlighting (it's white on darker theme). How difficult would be to add syntax highlighting to changed lines and keep red and green background? -(tady full syntax coloring je hard, nabizi se barevnej gutter, nebo barevne podtrzeni) - -- v detailu komitu when pressing n and p the second line is nonsence - -- add type hints - -- add tags into commits list (and branches?) +## Improvements - commit messages are oneline. What are options for multiline editing? +- visual bell blinking time setting + +## Code + +- add type hints diff --git a/tests/conftest.py b/tests/conftest.py index 1d5cef5..6158916 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,25 @@ import os import sys +import pytest + # tests import the package directly; sublime/sublime_plugin come from tests/stubs HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.dirname(HERE)) sys.path.insert(0, os.path.join(HERE, "stubs")) + + +@pytest.fixture(autouse=True) +def scheme_override_sandbox(tmp_path, monkeypatch): + """Keep the colour-scheme override palette.py writes inside the test's own tmp_path + + Both caches are per session and the stub hands every view the same id, so they would otherwise + leak between tests and make every assertion about the override depend on test order. + """ + import sublime + + from vdiff import palette, state + + palette.reset() + monkeypatch.setattr(state, "STATES", {}) + monkeypatch.setattr(sublime, "packages_path", lambda: str(tmp_path)) diff --git a/tests/stubs/sublime.py b/tests/stubs/sublime.py index 4e95118..461f4f0 100644 --- a/tests/stubs/sublime.py +++ b/tests/stubs/sublime.py @@ -1,4 +1,5 @@ """ Minimal sublime API stub so the plugin modules import and header/regions logic runs offline """ +import re OP_EQUAL = 0 OP_NOT_EQUAL = 1 @@ -109,6 +110,29 @@ class Window: return self.input_view +# a bundled scheme's styles: the diff.* and region.* scopes defined, the plugin's vdiff.* ones not. +# Nothing here simulates sublime merging in a written override, so a test that wants the plugin's +# scopes to resolve has to say so by adding them. +DEFAULT_FG = "#ffffff" +DEFAULT_STYLES = { + "diff.deleted": {"foreground": DEFAULT_FG, "background": "#4a2b2b"}, + "diff.deleted.char": {"foreground": DEFAULT_FG, "background": "#6a3535"}, + "diff.inserted": {"foreground": DEFAULT_FG, "background": "#2b4a3a"}, + "diff.inserted.char": {"foreground": DEFAULT_FG, "background": "#356a4a"}, + "region.yellowish": {"foreground": "#e6db74", "background": "#4a4a2b"}, + "markup.deleted": {"foreground": "#d64646"}, + "markup.inserted": {"foreground": "#46d646"}, + # a few of palette.PALETTE_PROBES, so the palette probe finds a working set of colours + "source": {"foreground": DEFAULT_FG}, + "comment": {"foreground": "#7c8595"}, + "string": {"foreground": "#99c794"}, + "keyword": {"foreground": "#c594c5"}, + "constant.numeric": {"foreground": "#f99157"}, + "entity.name.function": {"foreground": "#6699cc"}, + "punctuation.definition": {"foreground": "#5fb3b3"}, +} + + class View: """Text buffer with just the calls render.py makes""" @@ -125,6 +149,10 @@ class View: self.commands = [] self.popup = None self.popup_navigate = None + # what style_for_scope answers; a test reshapes it to model another colour scheme + self.styles = {scope: dict(style) for scope, style in DEFAULT_STYLES.items()} + # whether sublime has merged the override vdiff writes; it only does so on a restart + self.override_loaded = False def id(self): return 1 @@ -211,8 +239,25 @@ class View: def em_width(self): return 8.0 + def extract_tokens_with_scopes(self, region): + """One token per run of word characters and one per run of anything else, scoped by its shape + + Enough shape for the colouring tests: real sublime returns the syntax's tokens, and all the + painter needs is that adjacent tokens differ and each maps to a scope with a colour. + """ + tokens = [] + text = self.text[region.a:region.b] + for match in re.finditer(r"\w+|\s+|[^\w\s]+", text): + scope = "keyword" if match.group().isidentifier() else "punctuation.definition" + tokens.append((Region(region.a + match.start(), region.a + match.end()), scope)) + return tokens + def style_for_scope(self, scope): - return {"foreground": "#ffffff", "background": "#333333"} + # a fresh dict per call, and no background for an unknown scope, like the real API + if self.override_loaded and scope.startswith("vdiff.") and scope not in self.styles: + # sublime has merged the override, so every rule vdiff wrote in it resolves + return {"foreground": DEFAULT_FG, "background": "#333333"} + return dict(self.styles.get(scope, {"foreground": DEFAULT_FG})) def is_valid(self): return True diff --git a/tests/test_palette.py b/tests/test_palette.py new file mode 100644 index 0000000..222fb27 --- /dev/null +++ b/tests/test_palette.py @@ -0,0 +1,86 @@ +import json + +import sublime # the stub, via conftest + +from vdiff import palette + + +def scheme_file(tmp_path): + """The override of whichever scheme the stub reports as active""" + return tmp_path / "User" / palette.override_name(sublime.View()) + + +def override(tmp_path): + """The rules palette.py wrote into that override""" + path = scheme_file(tmp_path) + return json.loads(path.read_text())["rules"] if path.exists() else None + + +def by_scope(tmp_path): + return {rule["scope"]: rule for rule in override(tmp_path)} + + +def test_a_scheme_with_diff_colours_needs_no_tint_rules(tmp_path): + view = sublime.View() + palette.ensure(view) + + # the tints are the scheme's own scopes, so only the generated colour rules are ours to write + assert not [r for r in override(tmp_path) if r["scope"].startswith("diff.")] + + +def test_a_scheme_without_diff_colours_gets_our_tints(tmp_path): + view = sublime.View() + # all or nothing per pair: taking only the half a scheme has would put our hue next to its own + del view.styles["diff.deleted.char"] + del view.styles["diff.inserted"] + del view.styles["diff.inserted.char"] + palette.ensure(view) + + rules = by_scope(tmp_path) + assert rules[palette.SCOPE_INS]["background"] == palette.FALLBACK_TINTS[palette.SCOPE_INS] + assert rules[palette.SCOPE_DEL_CHAR]["background"] == palette.FALLBACK_TINTS[palette.SCOPE_DEL_CHAR] + # the one colour it does define is left to the scheme + assert palette.SCOPE_DEL not in rules + + +def test_every_palette_colour_gets_a_rule_per_background_kind(tmp_path): + view = sublime.View() + palette.ensure(view) + + rules = by_scope(tmp_path) + colours = {view.styles[scope]["foreground"] for scope in ("comment", "string", "keyword")} + for colour in colours: + scope = palette.scope_for("ins", colour) + assert scope is not None + # both properties, which is the whole point: the tint alone would flatten the text + assert rules[scope] == {"scope": scope, "background": view.styles["diff.inserted"]["background"], + "foreground": colour} + for kind in palette.KINDS: + assert palette.scope_for(kind, colour) in rules + + +def test_a_colour_the_scheme_does_not_use_has_no_scope(): + palette.ensure(sublime.View()) + assert palette.scope_for("ins", "#123456") is None + + +def test_the_override_is_written_once_per_scheme(tmp_path): + view = sublime.View() + palette.ensure(view) + scheme_file(tmp_path).write_text(json.dumps({"rules": []})) + + # sublime reloads the whole scheme every time we touch the file, so nothing must rewrite it + palette.ensure(view) + assert override(tmp_path) == [] + + +def test_a_hand_edited_override_is_never_clobbered(tmp_path): + (tmp_path / "User").mkdir() + path = tmp_path / "User" / "Mariana.sublime-color-scheme" + # comments are legal in a .sublime-color-scheme but decode_value here is plain json + path.write_text('{\n // mine\n "rules": []\n}') + + palette.ensure(sublime.View()) + assert "// mine" in path.read_text() + + diff --git a/tests/test_render.py b/tests/test_render.py index 8360b5e..209d3e5 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -4,7 +4,7 @@ import re import sublime # the stub, via conftest -from vdiff import commands, git, listeners, parse, render, state # noqa: F401 +from vdiff import commands, git, listeners, palette, parse, render, state # noqa: F401 DIFF = """--- a/x.py +++ b/x.py @@ -34,8 +34,18 @@ def header(view): return view.phantoms[render.PHANTOM_KEY][0].content +def with_loaded_override(view): + """Model a view whose scheme has picked up the override palette.py writes + + The stub deliberately does not simulate that merge, just as sublime does not until it restarts, + so a test wanting the generated colour scopes to resolve has to say so. + """ + view.override_loaded = True + return view + + def test_render_text_regions_and_header(): - view = sublime.View() + view = with_loaded_override(sublime.View()) render.init_view(view) st = make_state() render.render(view, st) @@ -316,9 +326,99 @@ def test_flash_survives_a_re_render(monkeypatch): assert render.BELL_KEY in view.regions -def test_bell_scope_falls_back_when_the_scheme_lacks_it(monkeypatch): - view = sublime.View() +def test_bell_scope_falls_back_when_the_scheme_lacks_it(): + view = with_loaded_override(sublime.View()) assert render._bell_scope(view) == render.SCOPE_BELL - monkeypatch.setattr(view, "style_for_scope", lambda scope: {"foreground": "#fff"}) - # an invisible bell is a silent failure, so fall back to a scope the plugin guarantees - assert render._bell_scope(view) == render.SCOPE_INS + del view.styles["region.yellowish"] + # an invisible bell is a silent failure, so fall back to a scope that is resolving + assert render._bell_scope(view) == palette.SCOPE_INS + # ... and when the plugin's own scopes are not resolving either, to the scheme's + view.override_loaded = False + assert render._bell_scope(view) == "diff.inserted" + + +def token_regions(view, kind, colour): + """The regions painted for one (background kind, colour) pair of the token layer""" + scope = palette.scope_for(kind, colour) + entry = view.regions.get(scope and scope.replace("vdiff.", "vdiff.tok.", 1)) + return entry[0] if entry else [] + + +def test_changed_lines_are_repainted_in_the_files_own_colours(): + view = with_loaded_override(sublime.View()) + render.init_view(view) + render.render(view, make_state()) + + keyword = view.styles["keyword"]["foreground"] + punctuation = view.styles["punctuation.definition"]["foreground"] + # " return 10" is an inserted line: its words carry the keyword colour, its spaces and digits + # are their own runs. Without this layer every one of them would be the tint's flat foreground. + inserted = token_regions(view, "ins", keyword) + assert sublime.Region(view.text_point(4, 4), view.text_point(4, 10)) in inserted + # and a deleted line is painted from the deleted palette, not the inserted one + assert sublime.Region(view.text_point(2, 4), view.text_point(2, 10)) in token_regions(view, "del", keyword) + assert token_regions(view, "del", punctuation) + + # the plain tint still covers the whole line underneath, so gaps and line ends stay coloured + assert view.regions[render.KEY_INS][1] == palette.SCOPE_INS + + +def test_token_runs_merge_but_stop_at_a_word_diff_range(): + view = with_loaded_override(sublime.View()) + render.init_view(view) + render.render(view, make_state("--- a/y\n+++ b/y\n@@ -1 +1 @@\n-foo = bar\n+foo = baz\n")) + + keyword = view.styles["keyword"]["foreground"] + # only the changed character sits in the stronger tint; the rest of the word keeps the line tint + assert token_regions(view, "ins.char", keyword) == [sublime.Region(view.text_point(2, 8), view.text_point(2, 9))] + plain = token_regions(view, "ins", keyword) + assert sublime.Region(view.text_point(2, 6), view.text_point(2, 8)) in plain + assert sublime.Region(view.text_point(2, 0), view.text_point(2, 3)) in plain + + +def test_token_layer_waits_for_the_scopes_to_resolve(): + view = sublime.View() + render.init_view(view) + render.render(view, make_state()) + + # sublime has not loaded the override yet, so the changed lines keep the scheme's flat tint and + # not one token key is painted: an unknown scope would draw its text in sublime's marker green + assert view.regions[render.KEY_DEL][1] == "diff.deleted" + assert view.regions[render.KEY_INS][1] == "diff.inserted" + assert not [key for key in view.regions if key.startswith("vdiff.tok.")] + + +def test_token_layer_is_skipped_for_a_diff_too_big_to_read(monkeypatch): + monkeypatch.setattr(render, "TOKEN_COLOUR_MAX_CHARS", 4) + view = with_loaded_override(sublime.View()) + render.init_view(view) + render.render(view, make_state()) + + assert not [key for key in view.regions if key.startswith("vdiff.tok.")] + + +def test_token_regions_are_dropped_when_a_re_render_no_longer_needs_them(): + view = with_loaded_override(sublime.View()) + render.init_view(view) + render.render(view, make_state()) + assert [key for key in view.regions if key.startswith("vdiff.tok.")] + + render.render(view, make_state("--- a/y\n+++ b/y\n@@ -1,2 +1,2 @@\n a\n-b\n+c\n")) + keyword = view.styles["keyword"]["foreground"] + # the new diff paints far fewer runs; the keys the old one used must not linger + assert token_regions(view, "ins", keyword) == [sublime.Region(view.text_point(3, 0), view.text_point(3, 1))] + + +def test_restart_notice_when_the_override_cannot_take_effect_yet(monkeypatch): + said = [] + monkeypatch.setattr(sublime, "status_message", said.append) + view = sublime.View() + render.init_view(view) + render.render(view, make_state()) + + assert said == ["vDiff: " + render.NEEDS_RESTART] + # said once per scheme, not once per render + render.render(view, make_state()) + assert said == ["vDiff: " + render.NEEDS_RESTART] + + diff --git a/vdiff/palette.py b/vdiff/palette.py new file mode 100644 index 0000000..e140c9f --- /dev/null +++ b/vdiff/palette.py @@ -0,0 +1,252 @@ +""" The colour-scheme scopes vDiff paints changed lines with, provisioned into a scheme override + +view.add_regions repaints the text it covers with the region scope's foreground, so one scope cannot +give both a red/green background and the file's own syntax colours. To get both, this module generates +a scope per (background kind, syntax colour) pair carrying both properties, and render paints each run +of same-coloured tokens with the matching one. + +Sublime only picks up a colour-scheme override it has not seen before on a restart, so the palette is +probed and written in one pass rather than growing a colour at a time. +""" +import json +import logging +import os + +import sublime + +LOG = logging.getLogger("vdiff") + +# the full-line tint under the token layer. The scheme's own, not ours: the token layer repaints the +# text on top, so the tint's foreground never shows, and a scope the scheme already defines needs no +# provisioning and so no restart before the changed lines have their colours. +SCOPE_DEL = "diff.deleted" +SCOPE_INS = "diff.inserted" +SCOPE_DEL_CHAR = "diff.deleted.char" +SCOPE_INS_CHAR = "diff.inserted.char" + +# background kinds, and the tint each one draws over +KINDS = ("del", "ins", "del.char", "ins.char") +KIND_TINT = {"del": SCOPE_DEL, "ins": SCOPE_INS, "del.char": SCOPE_DEL_CHAR, "ins.char": SCOPE_INS_CHAR} + +# tints to provision when the active scheme defines none of its own (some third-party schemes) +FALLBACK_TINTS = { + SCOPE_DEL: "hsla(357, 45%, 60%, 0.15)", + SCOPE_DEL_CHAR: "hsla(357, 60%, 60%, 0.30)", + SCOPE_INS: "hsla(114, 31%, 68%, 0.15)", + SCOPE_INS_CHAR: "hsla(114, 45%, 68%, 0.30)", +} + +# scope roots to resolve for the scheme's palette. Colour schemes colour by these standard names, so +# one pass over them yields the working set in one write. There is no API that returns a scheme's own +# palette: style_for_scope resolves one scope at a time, and reading the scheme file would mean +# re-implementing sublime's merging of overrides and .tmTheme inheritance. +PALETTE_PROBES = ( + "source", + "comment", + "string", + "constant.numeric", + "constant.language", + "constant.character.escape", + "variable", + "variable.parameter", + "variable.function", + "keyword", + "keyword.operator", + "storage.type", + "storage.modifier", + "entity.name.function", + "entity.name.class", + "entity.name.tag", + "entity.other.attribute-name", + "entity.other.inherited-class", + "support.function", + "support.class", + "support.type", + "support.constant", + "punctuation.definition", + "punctuation.separator", + "meta.function-call", + "invalid", +) + +# {override file name: {colour: index}} for every scheme provisioned this session +_palettes = {} +# the override name the last ensure() resolved, so scope_for knows which palette to read +_active = None +# schemes the reader has already been told need a restart +_notified = set() + + +def reset(): + """Drop the caches, so the next ensure() re-reads and re-probes. Only the tests need this""" + global _active + _palettes.clear() + _notified.clear() + _active = None + + +def claim_restart_notice(view): + """Whether the reader still has to be told to restart, asked once per scheme + + True only when a restart would actually help: the palette is provisioned but sublime has not + loaded it yet. A scheme we failed to provision at all is not something restarting fixes. + """ + if not _palettes.get(_active) or tokens_resolved(view) or _active in _notified: + return False + _notified.add(_active) + return True + + +def scope_background(view, scope): + """The scheme's background for a scope, or None; style_for_scope can fail on a closing view""" + return _style(view, scope).get("background") + + +def _style(view, scope): + try: + return view.style_for_scope(scope) + except Exception: + LOG.debug("style_for_scope failed for %s", scope, exc_info=True) + return {} + + +def foreground(view, scope): + """The scheme's foreground for a scope, normalised by sublime to #rrggbb""" + return _style(view, scope).get("foreground") + + +def tokens_resolved(view): + """Whether the generated (tint + colour) scopes are live + + The token layer must never paint with a scope sublime does not know: it would draw that text in + the bright green it uses for an unknown scope. Sublime loads an override it has not seen before + only on a restart, so this stays false for the session that first wrote one. + """ + colours = _palettes.get(_active) or {} + if not colours: + return False + # they are all written by the same pass into the same file, so any one of them answers for all + return bool(scope_background(view, _scope_name(KINDS[0], next(iter(colours.values()))))) + + +def scope_for(kind, colour): + """The generated scope carrying `kind`'s background and `colour`, or None if never provisioned""" + index = _palettes.get(_active, {}).get(colour) + return None if index is None else _scope_name(kind, index) + + +def token_key(scope): + """The add_regions key for a generated scope; one naming rule, spelled here only""" + return scope.replace("vdiff.", "vdiff.tok.", 1) + + +def token_keys(): + """Every region key the token layer can use, so a re-render can erase them all""" + colours = _palettes.get(_active, {}) + return [token_key(_scope_name(kind, i)) for kind in KINDS for i in sorted(colours.values())] + + +def _scope_name(kind, index): + return f"vdiff.{kind}.c{index}" + + +def ensure(view): + """Provision the scopes for the active scheme, once per scheme per session + + Whether the rules have taken effect is a separate question, asked by tokens_resolved(), because + sublime loads an override it has not seen before only on a restart. + """ + global _active + name = override_name(view) + _active = name + if not name or name in _palettes: + return + path = os.path.join(sublime.packages_path(), "User", name) + try: + scheme = {} + if os.path.exists(path): + with open(path, "r", encoding="utf-8") as f: + # a scheme the user has hand-edited may hold comments decode_value rejects; the raise + # leaves the file untouched, and render keeps the changed lines visible meanwhile + scheme = sublime.decode_value(f.read()) or {} + rules = scheme.setdefault("rules", []) + tints = _tints(view) + colours = {colour: i for i, colour in enumerate(_probe_palette(view))} + _palettes[name] = colours + if not _merge_rules(rules, tints, colours, view): + return + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(scheme, f, indent=4) + LOG.info("provisioned %d colours into colour scheme override %s", len(colours), path) + except Exception: + LOG.warning("could not write colour scheme override %s", path, exc_info=True) + _palettes.setdefault(name, {}) + + +def _probe_palette(view): + """The distinct foregrounds the active scheme uses, in probe order so indices stay meaningful""" + seen = [] + for scope in PALETTE_PROBES: + colour = foreground(view, scope) + if colour and colour not in seen: + seen.append(colour) + return seen + + +def _tints(view): + """{tint scope: background} for the scheme's diff colours, falling back to ours per colour pair + + All or nothing per pair: a scheme that defines diff.deleted but not diff.deleted.char would + otherwise get its line tint next to our char tint, in a different hue. + """ + tints = {} + for line_scope, char_scope in ((SCOPE_DEL, SCOPE_DEL_CHAR), (SCOPE_INS, SCOPE_INS_CHAR)): + line = scope_background(view, line_scope) + char = scope_background(view, char_scope) + tints[line_scope] = line if line and char else FALLBACK_TINTS[line_scope] + tints[char_scope] = char if line and char else FALLBACK_TINTS[char_scope] + return tints + + +def _merge_rules(rules, tints, colours, view): + """Bring the scheme's rules up to date, in place; True when anything changed + + Each (background kind, palette colour) pair needs a rule carrying both, because add_regions draws + the text it covers in the region scope's own foreground: a background alone would flatten the + changed line to the scheme's default. The plain tints are only written for a scheme that has none + of its own, and never overwritten, so one tuned by hand survives. + """ + present = {rule.get("scope"): rule for rule in rules} + changed = False + for scope, background in tints.items(): + if scope not in present and not scope_background(view, scope): + rules.append({"scope": scope, "background": background}) + changed = True + for colour, index in colours.items(): + for kind in KINDS: + wanted = {"scope": _scope_name(kind, index), "background": tints[KIND_TINT[kind]], + "foreground": colour} + rule = present.get(wanted["scope"]) + if rule is None: + rules.append(wanted) + changed = True + elif rule != wanted: + # the palette shifted under a rule an earlier session wrote; ours to keep correct + rule.clear() + rule.update(wanted) + changed = True + return changed + + +def override_name(view): + """File name in Packages/User that overrides the active scheme (same base name, .sublime-color-scheme)""" + try: + # already resolved through "auto" and dark/light settings by sublime + name = sublime.ui_info()["color_scheme"]["resolved_value"] + except Exception: + name = view.settings().get("color_scheme") or "" + if not name or name == "auto": + return None + root, _ = os.path.splitext(os.path.basename(name)) + return root + ".sublime-color-scheme" diff --git a/vdiff/render.py b/vdiff/render.py index 08aefc2..90cf731 100644 --- a/vdiff/render.py +++ b/vdiff/render.py @@ -1,6 +1,6 @@ """ Draw a parsed diff into a review view: the file text, coloured line regions, gutter marks, header phantom """ +import bisect import html -import json import logging import os from collections import namedtuple @@ -8,10 +8,10 @@ from collections import namedtuple import sublime import sublime_plugin -from . import parse +from . import palette, parse from .git import STATUS_NAMES from .state import VIEW_FLAG -from .utils import file_count, settings +from .utils import file_count, settings, status LOG = logging.getLogger("vdiff") @@ -31,28 +31,22 @@ STATUS_KEY = "vdiff" BELL_KEY = "vdiff.bell" BELL_MS = 90 +# above this much changed text, skip per-token colouring: a diff that large is scrolled, not read, +# and tokenising every changed line would cost more than the colours are worth +TOKEN_COLOUR_MAX_CHARS = 200000 + # a block phantom anchored at point 0 is drawn below row 0, so row 0 stays empty and the file starts at row 1. # Only index_point / caret_index know about it. _HEADER_ROWS = 1 -# scopes with a background in every bundled colour scheme (Mariana, Monokai, Breakers, Celeste, Sixteen) -SCOPE_DEL = "diff.deleted" -SCOPE_INS = "diff.inserted" -SCOPE_DEL_CHAR = "diff.deleted.char" -SCOPE_INS_CHAR = "diff.inserted.char" # gutter dots use the scope's foreground SCOPE_DEL_MARK = "markup.deleted" SCOPE_INS_MARK = "markup.inserted" # ST4 defines region.ish in every bundled scheme for exactly this kind of transient marking SCOPE_BELL = "region.yellowish" -# rules written into a colour-scheme override when the active scheme lacks the scopes above -FALLBACK_RULES = [ - {"scope": SCOPE_DEL, "background": "hsla(357, 45%, 60%, 0.15)", "foreground_adjust": "l(+ 5%)"}, - {"scope": SCOPE_DEL_CHAR, "background": "hsla(357, 60%, 60%, 0.30)", "foreground_adjust": "l(+ 10%)"}, - {"scope": SCOPE_INS, "background": "hsla(114, 31%, 68%, 0.15)", "foreground_adjust": "l(+ 5%)"}, - {"scope": SCOPE_INS_CHAR, "background": "hsla(114, 45%, 68%, 0.30)", "foreground_adjust": "l(+ 10%)"}, -] +# sublime only loads an override it has not seen before on a restart +NEEDS_RESTART = "restart Sublime Text to finish enabling syntax highlighting on changed lines" # one source for the status-bar hints and the ? popup. # actions: the ACTIONS names the row covers; a single one makes the row a link. short: status-bar @@ -171,7 +165,6 @@ def init_view(view): if not view.name(): view.set_name("vDiff") view.set_status(STATUS_KEY, KEY_HINTS) - ensure_scheme_scopes(view) def index_point(view, index, col=0): @@ -208,13 +201,11 @@ def flash(view): def _bell_scope(view): - """region.yellowish, or a scope the plugin guarantees a background for when a scheme lacks it""" - try: - if view.style_for_scope(SCOPE_BELL).get("background"): - return SCOPE_BELL - except Exception: - LOG.debug("style_for_scope failed for %s", SCOPE_BELL, exc_info=True) - return SCOPE_INS + """region.yellowish, or a scope with a background we know of when a scheme lacks it""" + if palette.scope_background(view, SCOPE_BELL): + return SCOPE_BELL + # an invisible bell is a silent failure, so borrow the insert tint + return palette.SCOPE_INS def render_message(view, st, message): @@ -243,19 +234,12 @@ def render(view, st): _apply_syntax(view, path if lines and not st.binary else None) if lines and not st.binary: - starts = _row_starts(lines) - deleted, inserted = _line_regions(lines, starts) - view.add_regions(KEY_DEL, deleted, SCOPE_DEL, flags=sublime.DRAW_NO_OUTLINE) - view.add_regions(KEY_INS, inserted, SCOPE_INS, flags=sublime.DRAW_NO_OUTLINE) - mark_flags = sublime.DRAW_NO_FILL | sublime.DRAW_NO_OUTLINE - del_marks = [sublime.Region(starts[i]) for i, line in enumerate(lines) if line.kind == "-"] - ins_marks = [sublime.Region(starts[i]) for i, line in enumerate(lines) if line.kind == "+"] - view.add_regions(KEY_DEL_GUTTER, del_marks, SCOPE_DEL_MARK, icon="dot", flags=mark_flags) - view.add_regions(KEY_INS_GUTTER, ins_marks, SCOPE_INS_MARK, icon="dot", flags=mark_flags) - if settings().get("word_diff", True): - del_chars, ins_chars = _char_regions(lines, st.blocks, starts) - view.add_regions(KEY_DEL_CHAR, del_chars, SCOPE_DEL_CHAR, flags=sublime.DRAW_NO_OUTLINE) - view.add_regions(KEY_INS_CHAR, ins_chars, SCOPE_INS_CHAR, flags=sublime.DRAW_NO_OUTLINE) + # here rather than in init_view: a session restore runs that while sublime is still settling, + # and the scheme colours we borrow read as absent that early + palette.ensure(view) + _paint_regions(view, st) + if palette.claim_restart_notice(view): + status(NEEDS_RESTART) ins, dele = parse.counts(lines) _finish(view, st, ins, dele, f"⟐ {os.path.basename(st.change.path)}") @@ -264,6 +248,97 @@ def render(view, st): view.set_viewport_position((0, 0), False) +def _paint_regions(view, st): + """The line tint, the gutter dots, the word-diff marks, and the syntax colours on top of them""" + lines = st.lines + starts = _row_starts(lines) + deleted, inserted = _line_regions(lines, starts) + view.add_regions(KEY_DEL, deleted, palette.SCOPE_DEL, flags=sublime.DRAW_NO_OUTLINE) + view.add_regions(KEY_INS, inserted, palette.SCOPE_INS, flags=sublime.DRAW_NO_OUTLINE) + mark_flags = sublime.DRAW_NO_FILL | sublime.DRAW_NO_OUTLINE + del_marks = [sublime.Region(starts[i]) for i, line in enumerate(lines) if line.kind == "-"] + ins_marks = [sublime.Region(starts[i]) for i, line in enumerate(lines) if line.kind == "+"] + view.add_regions(KEY_DEL_GUTTER, del_marks, SCOPE_DEL_MARK, icon="dot", flags=mark_flags) + view.add_regions(KEY_INS_GUTTER, ins_marks, SCOPE_INS_MARK, icon="dot", flags=mark_flags) + del_chars, ins_chars = [], [] + if settings().get("word_diff", True): + del_chars, ins_chars = _char_regions(lines, st.blocks, starts) + view.add_regions(KEY_DEL_CHAR, del_chars, palette.SCOPE_DEL_CHAR, flags=sublime.DRAW_NO_OUTLINE) + view.add_regions(KEY_INS_CHAR, ins_chars, palette.SCOPE_INS_CHAR, flags=sublime.DRAW_NO_OUTLINE) + _paint_tokens(view, lines, starts, del_chars + ins_chars) + + +def _paint_tokens(view, lines, starts, char_regions): + """Repaint each run of same-coloured tokens with a scope carrying the tint *and* that colour + + add_regions draws the text it covers in the region scope's own foreground, so the tints above have + just flattened every changed line to one colour. Nothing gives back the file's own highlighting + except handing each run the colour it should have had. + """ + if not palette.tokens_resolved(view): + return + changed = [i for i, line in enumerate(lines) if line.kind != " "] + if sum(len(lines[i].text) for i in changed) > TOKEN_COLOUR_MAX_CHARS: + # a huge diff would spend longer being tokenised than read; the flat tint still shows it + return + + stronger = sorted((r.a, r.b) for r in char_regions) + starts_of = [a for a, _ in stronger] + groups = {} + colours = {} # scope name -> colour, resolved once per render + for i in changed: + kind = "del" if lines[i].kind == "-" else "ins" + end = starts[i] + len(lines[i].text) + # only this line's word-diff ranges, so a diff with thousands of them stays linear + on_line = stronger[bisect.bisect_left(starts_of, starts[i]):bisect.bisect_left(starts_of, end)] + for region, colour in _colour_runs(view, starts[i], lines[i].text, colours): + # a word-diff range cuts across token boundaries, so the run splits on it + for part, inside in _split(region, on_line): + # a colour outside the scheme's probed palette keeps the plain tint under it + scope = palette.scope_for(kind + ".char" if inside else kind, colour) + if scope: + groups.setdefault(scope, []).append(part) + for scope, regions in groups.items(): + view.add_regions(palette.token_key(scope), regions, scope, flags=sublime.DRAW_NO_OUTLINE) + + +def _split(region, intervals): + """(part, inside) covering `region`, cut wherever one of the sorted `intervals` starts or ends""" + if not intervals: + return [(region, False)] + parts = [] + pos = region.a + for a, b in intervals: + a, b = max(a, region.a), min(b, region.b) + if b <= pos: + continue + if a >= region.b: + break + if a > pos: + parts.append((sublime.Region(pos, a), False)) + parts.append((sublime.Region(a, b), True)) + pos = b + if pos < region.b or not parts: + parts.append((sublime.Region(pos, region.b), False)) + return parts + + +def _colour_runs(view, start, text, colours): + """(region, colour) per run of same-coloured tokens on one line, adjacent equal colours merged""" + runs = [] + for region, scope in view.extract_tokens_with_scopes(sublime.Region(start, start + len(text))): + if scope not in colours: + colours[scope] = palette.foreground(view, scope) + colour = colours[scope] + if not colour: + continue + if runs and runs[-1][1] == colour and runs[-1][0].b == region.a: + runs[-1] = (sublime.Region(runs[-1][0].a, region.b), colour) + else: + runs.append((region, colour)) + return runs + + def _finish(view, st, ins, dele, name): """Header, tab name, status bar and bookkeeping shared by render and render_message""" _header(view, st, ins, dele) @@ -279,7 +354,8 @@ def _replace(view, text): view.run_command("vdiff_replace", {"text": "\n" * _HEADER_ROWS + text}) finally: view.set_read_only(True) - for key in REGION_KEYS: + # the token layer's keys vary with the palette, so they are listed rather than fixed + for key in list(REGION_KEYS) + palette.token_keys(): view.erase_regions(key) @@ -455,47 +531,3 @@ def show_help(view, st): em = view.em_width() or 8 view.show_popup(help_html(st), location=-1, max_width=int(em * 56), max_height=int(em * 44), on_navigate=navigate) - -# --- colour scheme fallback ------------------------------------------------- - -_scheme_checked = set() - - -def ensure_scheme_scopes(view): - """Write a scheme override with diff backgrounds when the active scheme has none (third-party schemes)""" - name = _scheme_override_name(view) - if not name or name in _scheme_checked: - return - _scheme_checked.add(name) - try: - if view.style_for_scope(SCOPE_INS).get("background"): - return - except Exception: - return - path = os.path.join(sublime.packages_path(), "User", name) - try: - scheme = {} - if os.path.exists(path): - with open(path, "r", encoding="utf-8") as f: - scheme = sublime.decode_value(f.read()) or {} - rules = scheme.setdefault("rules", []) - present = {rule.get("scope") for rule in rules} - rules.extend(rule for rule in FALLBACK_RULES if rule["scope"] not in present) - with open(path, "w", encoding="utf-8") as f: - json.dump(scheme, f, indent=4) - LOG.info("wrote diff scopes into colour scheme override %s", path) - except Exception: - LOG.warning("could not write colour scheme override %s", path, exc_info=True) - - -def _scheme_override_name(view): - """File name in Packages/User that overrides the active scheme (same base name, .sublime-color-scheme)""" - try: - # already resolved through "auto" and dark/light settings by sublime - name = sublime.ui_info()["color_scheme"]["resolved_value"] - except Exception: - name = view.settings().get("color_scheme") or "" - if not name or name == "auto": - return None - root, _ = os.path.splitext(os.path.basename(name)) - return root + ".sublime-color-scheme"