Files
vdiff/vdiff/palette.py

354 lines
16 KiB
Python

"""The colour-scheme scopes vDiff paints changed lines with, provisioned into a scheme override
view.add_regions repaints text with the region scope's foreground, so one scope cannot give both a
red/green background and the file's syntax colours. This module generates a scope per (background
kind, colour) pair carrying both, and render paints each run of same-coloured tokens with it.
Sublime picks up a new override only on a restart, so the palette is probed and written in one pass
rather than a colour at a time
"""
import json
import logging
import os
from typing import Any, Dict, List, Optional, Set, Tuple
import sublime
from .utils import settings
LOG = logging.getLogger("vdiff")
# tints under the token layer: the scheme's own diff scopes need no restart, and the token layer repaints their text
SCOPE_DEL = "diff.deleted"
SCOPE_INS = "diff.inserted"
SCOPE_DEL_CHAR = "diff.deleted.char"
SCOPE_INS_CHAR = "diff.inserted.char"
# context rows of the all-files view: the editor background, nudged so the colour still paints
SCOPE_CTX = "vdiff.context"
# background kinds (tinted ones paint changed lines, "ctx" the all-files context rows) and their tint scopes
TINT_KINDS: Tuple[str, ...] = ("del", "ins", "del.char", "ins.char")
KINDS: Tuple[str, ...] = TINT_KINDS + ("ctx",)
KIND_TINT: Dict[str, str] = {"del": SCOPE_DEL, "ins": SCOPE_INS, "del.char": SCOPE_DEL_CHAR, "ins.char": SCOPE_INS_CHAR,
"ctx": SCOPE_CTX}
# all-files file heading and its -/+ counts; their colours come from settings, these when one is empty
SCOPE_FILE_HEADER = "vdiff.file.header"
SCOPE_FILE_DEL_COUNT = "vdiff.file.del.count"
SCOPE_FILE_INS_COUNT = "vdiff.file.ins.count"
FILE_HEADER_BACKGROUND = "#0000FF"
# sublime's former markup.deleted / markup.inserted colours
FILE_DEL_FOREGROUND = "#d64646"
FILE_INS_FOREGROUND = "#46d646"
# colours the user may set; an empty line colour keeps the colour scheme's tint or the plain-text look
COLOUR_SETTINGS: Tuple[str, ...] = ("deleted_line_background", "inserted_line_background",
"line_numbers_foreground", "line_numbers_background",
"file_header_background", "file_header_deleted_foreground",
"file_header_inserted_foreground")
# the scopes those colours go into: the changed-line tints and the all-files line-number column
SCOPE_LINE_DEL = "vdiff.line.del"
SCOPE_LINE_INS = "vdiff.line.ins"
SCOPE_LINE_NUMBERS = "vdiff.line.numbers"
# tints to provision when the active scheme defines none of its own (some third-party schemes)
FALLBACK_TINTS: Dict[str, str] = {
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)",
}
# standard scope roots probed for the palette; no API returns it whole, and parsing the scheme
# file would mean re-doing sublime's merging
PALETTE_PROBES: Tuple[str, ...] = (
"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: Dict[str, Dict[str, int]] = {}
# the override name the last ensure() resolved, so scope_for knows which palette to read
_active: Optional[str] = None
# schemes the user has already been told need a restart
_notified: Set[Optional[str]] = set()
# {override file name: the user colours it was provisioned with}, so changing one re-provisions
_provisioned_with: Dict[str, Dict[str, str]] = {}
def reset() -> None:
"""Drop the caches, so the next ensure() re-reads and re-probes. Only the tests need this"""
global _active
_palettes.clear()
_notified.clear()
_provisioned_with.clear()
_active = None
def claim_restart_notice(view: sublime.View) -> bool:
"""Whether to tell the user to restart; True at most once per scheme
Only when the override is written but not yet loaded
"""
if not (_active and _palettes.get(_active)) or tokens_resolved(view, KINDS) or _active in _notified:
return False
_notified.add(_active)
return True
def scope_background(view: sublime.View, scope: str) -> Optional[str]:
"""The scheme's background for a scope, or None"""
return _style(view, scope).get("background")
def _style(view: sublime.View, scope: str) -> Dict[str, Any]:
"""The scheme's style for a scope, or {} when style_for_scope fails"""
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: sublime.View, scope: str) -> Optional[str]:
"""The scheme's foreground for a scope, normalised by sublime to #rrggbb"""
return _style(view, scope).get("foreground")
def tokens_resolved(view: sublime.View, kinds: Tuple[str, ...] = TINT_KINDS) -> bool:
"""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
sublime's unknown-scope green. A new override loads only on a restart, so the writing session sees false
"""
colours = (_active and _palettes.get(_active)) or {}
if not colours:
return False
# a kind added since the override was written loads only after a restart, so check each kind
# probe a colour unlike the default foreground (an unknown scope falls back to it);
# ctx's background may go unreported
default = foreground(view, "text.plain")
colour, index = next(((c, i) for c, i in colours.items() if c != default), next(iter(colours.items())))
# live when the rule's background shows, or its distinct foreground does
return all(scope_background(view, _scope_name(kind, index)) or
(colour != default and foreground(view, _scope_name(kind, index)) == colour)
for kind in kinds)
def scope_for(kind: str, colour: str) -> Optional[str]:
"""The generated scope carrying `kind`'s background and `colour`, or None if never provisioned"""
index = _palettes.get(_active or "", {}).get(colour)
return None if index is None else _scope_name(kind, index)
def token_key(scope: str) -> str:
"""The add_regions key for a generated scope"""
return scope.replace("vdiff.", "vdiff.tok.", 1)
def token_keys() -> List[str]:
"""Every region key the token layer can use, so a re-render can erase them all"""
colours = _palettes.get(_active or "", {})
return [token_key(_scope_name(kind, i)) for kind in KINDS for i in sorted(colours.values())]
def user_colours() -> Dict[str, str]:
"""{setting: colour} for COLOUR_SETTINGS, "" where unset"""
return {name: settings().get(name) or "" for name in COLOUR_SETTINGS}
def line_scope(view: sublime.View, kind: str) -> str:
"""Tint scope for a changed line, "del" or "ins": the user's colour once its rule is live, else the scheme's"""
own, setting, scheme = ((SCOPE_LINE_DEL, "deleted_line_background", SCOPE_DEL) if kind == "del" else
(SCOPE_LINE_INS, "inserted_line_background", SCOPE_INS))
return own if settings().get(setting) and scope_background(view, own) else scheme
def line_numbers_scope(view: sublime.View) -> str:
"""Scope for the all-files line-number column: the user's colours once their rule is live, else plain text"""
if (settings().get("line_numbers_foreground") or settings().get("line_numbers_background")) \
and scope_background(view, SCOPE_LINE_NUMBERS):
return SCOPE_LINE_NUMBERS
return "text.plain"
def _scope_name(kind: str, index: int) -> str:
"""Generated scope name for a background kind and palette index"""
return f"vdiff.{kind}.c{index}"
def ensure(view: sublime.View) -> None:
"""Provision the scopes for the active scheme, once per scheme per session
A new override loads only on a restart; tokens_resolved() says when it is live
"""
global _active
name = override_name(view)
_active = name
user = user_colours()
if not name or (name in _palettes and _provisioned_with.get(name) == user):
return
_provisioned_with[name] = user
# merge into the existing override, if any
path = os.path.join(sublime.packages_path(), "User", name)
try:
scheme: Dict[str, Any] = {}
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as f:
# invalid JSON raises here, leaving the file untouched; render still shows the changed lines
scheme = sublime.decode_value(f.read()) or {}
rules = scheme.setdefault("rules", [])
# probe the scheme, record the palette, and write only when a rule changed
tints = _tints(view, user)
colours = {colour: i for i, colour in enumerate(_probe_palette(view))}
_palettes[name] = colours
if not _merge_rules(rules, tints, colours, view, user):
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)
# mark the scheme as handled so this session does not retry it
_palettes.setdefault(name, {})
def _probe_palette(view: sublime.View) -> List[str]:
"""The distinct foregrounds the active scheme uses, in probe order so indices stay meaningful"""
seen: List[str] = []
for scope in PALETTE_PROBES:
colour = foreground(view, scope)
if colour and colour not in seen:
seen.append(colour)
return seen
def _tints(view: sublime.View, user: Dict[str, str]) -> Dict[str, str]:
"""{tint scope: background} for the scheme's diff colours, falling back to ours per colour pair
All or nothing per pair: taking only the half a scheme defines would put our hue next to its own.
A line colour the user set wins over both
"""
tints: Dict[str, str] = {}
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]
for scope, setting in ((SCOPE_DEL, "deleted_line_background"), (SCOPE_INS, "inserted_line_background")):
if user[setting]:
tints[scope] = user[setting]
# a region whose background is missing, transparent or equal to the editor's is filled with its
# foreground, so context colours carry the editor background nudged by an invisible step
tints[SCOPE_CTX] = _nudge(_global_background(view))
return tints
def _global_background(view: sublime.View) -> str:
"""The scheme's editor background; style_for_scope reports none for scopes that do not set one"""
try:
background = view.style().get("background")
except Exception:
LOG.debug("view.style failed", exc_info=True)
background = None
return background or "#000000"
def _nudge(colour: str) -> str:
"""#rrggbb[aa] with blue moved one step, towards the middle so it never overflows"""
if not (colour.startswith("#") and len(colour) in (7, 9)):
return colour
blue = int(colour[5:7], 16)
return f"{colour[:5]}{blue + 1 if blue < 128 else blue - 1:02x}{colour[7:]}"
def _put_rule(rules: List[Dict[str, str]], present: Dict[Optional[str], Dict[str, str]], wanted: Dict[str, str]) -> bool:
"""Add `wanted`, or make the rule with its scope equal to it; True when anything changed"""
rule = present.get(wanted["scope"])
if rule is None:
rules.append(wanted)
elif rule != wanted:
rule.clear()
rule.update(wanted)
else:
return False
return True
def _merge_rules(rules: List[Dict[str, str]], tints: Dict[str, str], colours: Dict[str, int],
view: sublime.View, user: Dict[str, str]) -> bool:
"""Bring the scheme's rules up to date, in place; True when anything changed
Each (kind, colour) rule carries a background and a foreground, as a background alone would flatten
the line's colours; plain tints are added only when missing and never overwritten, so a hand-tuned one survives
"""
present = {rule.get("scope"): rule for rule in rules}
# the all-files file heading and its deletion and insertion counts
header = user["file_header_background"] or FILE_HEADER_BACKGROUND
wanted = [{"scope": SCOPE_FILE_HEADER, "background": header}]
for scope, colour in ((SCOPE_FILE_DEL_COUNT, user["file_header_deleted_foreground"] or FILE_DEL_FOREGROUND),
(SCOPE_FILE_INS_COUNT, user["file_header_inserted_foreground"] or FILE_INS_FOREGROUND)):
wanted.append({"scope": scope, "background": header, "foreground": colour, "font_style": "bold"})
# the user's line colours; an unset one keeps any old rule, which render then ignores
for scope, setting in ((SCOPE_LINE_DEL, "deleted_line_background"), (SCOPE_LINE_INS, "inserted_line_background")):
if user[setting]:
wanted.append({"scope": scope, "background": user[setting]})
if user["line_numbers_foreground"] or user["line_numbers_background"]:
# both halves, as a region without a background is filled with its foreground
wanted.append({"scope": SCOPE_LINE_NUMBERS,
"foreground": user["line_numbers_foreground"] or foreground(view, "text.plain") or "#ffffff",
"background": user["line_numbers_background"] or _nudge(_global_background(view))})
changed = False
for rule in wanted:
changed = _put_rule(rules, present, rule) or changed
# plain tints the scheme lacks; the context tint gets no rule of its own
for scope, background in tints.items():
if scope != SCOPE_CTX and scope not in present and not scope_background(view, scope):
rules.append({"scope": scope, "background": background})
changed = True
# one rule per (kind, colour) pair; a palette shifted under an earlier session's rule is ours to correct
for colour, index in colours.items():
for kind in KINDS:
pair = {"scope": _scope_name(kind, index), "background": tints[KIND_TINT[kind]], "foreground": colour}
changed = _put_rule(rules, present, pair) or changed
return changed
def override_name(view: sublime.View) -> Optional[str]:
"""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 ""
# no usable scheme name: nothing to override
if not name or name == "auto":
return None
root, _ = os.path.splitext(os.path.basename(name))
return root + ".sublime-color-scheme"