Files
vterm/vterm/scheme.py
2026-09-10 10:07:12 +02:00

269 lines
9.6 KiB
Python

""" Colour scheme generated on demand: a region can only name a scope, and a scope's colours must already exist in the scheme """
import json
import logging
import os
import threading
import sublime
from .emulator import ANSI_COLORS
from .utils import settings
from .themes import THEMES
LOG = logging.getLogger(__name__)
# written under Packages/User so it survives a package reinstall
SCHEME_DIR_NAME = "User/vterm"
# "hidden-" keeps the generated scheme out of the scheme picker
SCHEME_FILE_NAME = "vterm.hidden-color-scheme"
# the value terminal views get as their color_scheme setting
SCHEME_PATH = "Packages/{}/{}".format(SCHEME_DIR_NAME, SCHEME_FILE_NAME)
# each write makes sublime reload the scheme and restyle every view
WRITE_DEBOUNCE_MS = 250
# palette used when no theme is selected, overridable via vterm.sublime-settings
DEFAULT_ANSI = {
"black": "#000000",
"red": "#cd3131",
"green": "#0dbc79",
"brown": "#e5e510",
"blue": "#2472c8",
"magenta": "#bc3fbc",
"cyan": "#11a8cd",
"white": "#e5e5e5",
"light_black": "#666666",
"light_red": "#f14c4c",
"light_green": "#23d18b",
"light_brown": "#f5f543",
"light_blue": "#3b8eea",
"light_magenta": "#d670d6",
"light_cyan": "#29b8db",
"light_white": "#ffffff",
}
def next_color(color_text):
"""Nudge a #rrggbb colour by one; add_regions inverts fg/bg when they match exactly"""
hex_value = int(color_text[5:7], 16)
if hex_value == 255:
return color_text[:5] + "fe"
else:
return color_text[:5] + "{:02x}".format(hex_value + 1)
def blend(base, other, ratio):
"""Mix two #rrggbb colours"""
channels = []
for i in (1, 3, 5):
a = int(base[i : i + 2], 16)
b = int(other[i : i + 2], 16)
channels.append(round(a + (b - a) * ratio))
return "#{:02x}{:02x}{:02x}".format(*channels)
def _is_dark(color_text):
"""True when a #rrggbb colour is dark by perceived luminance."""
r, g, b = (int(color_text[i : i + 2], 16) for i in (1, 3, 5))
return 0.299 * r + 0.587 * g + 0.114 * b < 128
class SchemeManager:
"""Owns the generated scheme file and the set of known scopes."""
def __init__(self):
"""Start on the built-in palette; initialize() picks up the real one."""
self.lock = threading.Lock()
self.rules = {}
# key -> scope name; the render thread reads it without the lock, so it
# must never outlive self.rules (cleared together in _build)
self._names = {}
self.foreground = "#ffffff"
self.background = "#000000"
self._editor_fg = "#ffffff"
self._editor_bg = "#000000"
self.ansi = dict(DEFAULT_ANSI)
self._write_pending = False
self._initialized = False
self._dir = None
self._host_view = None
self._listeners = []
def initialize(self, view):
"""Pick up the palette, seed the rules, write the file. Main thread only"""
if self._initialized:
return
self._initialized = True
self._host_view = view
settings().add_on_change("vterm.theme", self.reload)
self._build()
def ensure_file(self):
"""Write the scheme if missing: a restored tab's color_scheme is loaded during startup"""
if os.path.exists(os.path.join(self._directory(), SCHEME_FILE_NAME)):
return
with self.lock:
self._build()
def add_listener(self, callback):
"""Called after every palette rebuild"""
self._listeners.append(callback)
def remove_listener(self, callback):
"""Drop a rebuild subscriber; terminals unsubscribe when they die."""
if callback in self._listeners:
self._listeners.remove(callback)
def reload(self):
"""Rebuild every rule for a new palette, then notify the terminals"""
if not self._initialized:
# no scheme file to maintain yet; initialize() will read the setting
return
with self.lock:
self._build()
for callback in list(self._listeners):
try:
callback()
except Exception:
LOG.warning("scheme listener failed", exc_info=True)
def _build(self):
"""Resolve the palette and regenerate every rule, then write the file.
Rules cannot be appended after a palette change: each one stores resolved
colours, so they all have to be rebuilt. Caller holds the lock.
"""
self._names = {}
self.rules = {}
self.ansi = dict(DEFAULT_ANSI)
theme = THEMES.get(settings().get("theme"))
if theme:
self.foreground = theme["foreground"]
self.background = theme["background"]
self.ansi.update({name: theme[name] for name in ANSI_COLORS if name in theme})
else:
# captured values outlive the view they came from
if self._host_view is not None and self._host_view.is_valid():
style = self._host_view.style()
self._editor_fg = style.get("foreground", self._editor_fg)
self._editor_bg = style.get("background", self._editor_bg)
self.foreground = self._editor_fg
self.background = self._editor_bg
# user overrides sit on top of whichever palette was chosen
user_ansi = settings().get("ansi_colors", {})
for name in ANSI_COLORS:
if name in user_ansi:
self.ansi[name] = user_ansi[name]
# seeded so the first frame does not reference scopes the file lacks
for fg in ANSI_COLORS:
self._add_rule(fg, "default", "")
self._add_rule(fg, "default", "b")
self._add_rule("default", "default", "b")
self._add_rule("reverse_default", "reverse_default", "")
self._write_file()
def is_dark(self):
"""Whether the active palette is dark; reported to the app via OSC 11 and DSR 996."""
return _is_dark(self.background)
def scope(self, fg, bg, style):
"""Scope for a colour combination, creating its rule if new. Thread-safe"""
key = (fg, bg, style)
# hot path: one GIL-atomic dict read, no lock, no formatting
name = self._names.get(key)
if name is not None:
return name
with self.lock:
if key not in self.rules:
self._add_rule(fg, bg, style)
if not self._write_pending:
self._write_pending = True
# a warm-up burst coalesces into one write, off the UI thread
sublime.set_timeout_async(self._flush, WRITE_DEBOUNCE_MS)
name = self._scope_name(fg, bg, style)
self._names[key] = name
return name
def _scope_name(self, fg, bg, style):
"""Scope for a colour combination, e.g. vterm.red.default.b"""
if style:
return "vterm.{}.{}.{}".format(fg, bg, style)
return "vterm.{}.{}".format(fg, bg)
def _resolve(self, color, for_bg):
"""Turn a pyte colour name into #rrggbb against the active palette."""
if color == "default":
return self.background if for_bg else self.foreground
if color == "reverse_default":
return self.foreground if for_bg else self.background
if color in self.ansi:
return self.ansi[color]
return "#" + color
def _add_rule(self, fg, bg, style):
"""Add the scheme rule for one colour combination. Caller holds the lock."""
fg_color = self._resolve(fg, for_bg=False)
bg_color = self._resolve(bg, for_bg=True)
if bg_color.lower() == self.background.lower():
bg_color = next_color(bg_color)
rule = {
"scope": self._scope_name(fg, bg, style),
"foreground": fg_color,
"background": bg_color,
}
font_style = []
if "b" in style:
font_style.append("bold")
if "i" in style:
font_style.append("italic")
if "u" in style:
font_style.append("underline")
if font_style:
rule["font_style"] = " ".join(font_style)
self.rules[(fg, bg, style)] = rule
def _directory(self):
"""Path of the generated scheme's directory, created on first use."""
if self._dir is None:
self._dir = os.path.join(sublime.packages_path(), *SCHEME_DIR_NAME.split("/"))
os.makedirs(self._dir, exist_ok=True)
return self._dir
def _flush(self):
"""Debounced write, on sublime's async thread."""
with self.lock:
self._write_pending = False
self._write_file()
def _write_file(self):
"""Serialize globals and every rule to the scheme file. Caller holds the lock."""
scheme = {
"name": "vterm",
"globals": {
"background": self.background,
"foreground": self.foreground,
"caret": self.foreground,
"block_caret": self.foreground,
"gutter": self.background,
"selection": blend(self.background, self.foreground, 0.3),
"selection_foreground": self.foreground,
"selection_border_width": "0",
"inactive_selection": blend(self.background, self.foreground, 0.15),
},
"rules": list(self.rules.values()),
}
path = os.path.join(self._directory(), SCHEME_FILE_NAME)
with open(path, "w", encoding="utf-8") as f:
# compact: sublime reparses the whole file on every write
json.dump(scheme, f, separators=(",", ":"))
LOG.debug("scheme written: rules=%s", len(self.rules))
scheme_manager = SchemeManager()