diff --git a/kitty/colors.c b/kitty/colors.c index b54946a13..e5c181a85 100644 --- a/kitty/colors.c +++ b/kitty/colors.c @@ -464,7 +464,7 @@ static Color* alloc_color(unsigned char r, unsigned char g, unsigned char b, uns } else if (PyObject_TypeCheck(v, &Color_Type)) { \ Color *c = (Color*)v; self->overridden.name.rgb = c->color.rgb; self->overridden.name.type = COLOR_IS_RGB; \ } else if (v == Py_None) { \ - if (!nullable) { PyErr_SetString(PyExc_ValueError, #name " cannot be set to None"); return -1; } \ + if (!nullable) { PyErr_SetString(PyExc_TypeError, #name " cannot be set to None"); return -1; } \ self->overridden.name.type = COLOR_IS_SPECIAL; self->overridden.name.rgb = 0; \ } \ self->dirty = true; return 0; \ diff --git a/kitty/rgb.py b/kitty/rgb.py index 614ab7f90..9ee9e134b 100644 --- a/kitty/rgb.py +++ b/kitty/rgb.py @@ -41,6 +41,17 @@ def parse_rgb(spec: str) -> Optional[Color]: return None +def parse_single_intensity(x: str) -> int: + return int(max(0, min(abs(float(x)), 1)) * 255) + + +def parse_rgbi(spec: str) -> Optional[Color]: + colors = spec.split('/') + if len(colors) == 3: + return Color(*map(parse_single_intensity, colors)) + return None + + def color_from_int(x: int) -> Color: return Color((x >> 16) & 255, (x >> 8) & 255, x & 255) @@ -67,8 +78,12 @@ def to_color(raw: str, validate: bool = False) -> Optional[Color]: with suppress(Exception): if raw.startswith('#'): val = parse_sharp(raw[1:]) - elif raw.startswith('rgb:'): - val = parse_rgb(raw[4:]) + else: + k, sep, v = raw.partition(':') + if k == 'rgb': + val = parse_rgb(v) + elif k == 'rgbi': + val = parse_rgbi(v) if val is None and validate: raise ValueError(f'Invalid color name: {raw!r}') return val diff --git a/kitty/screen.c b/kitty/screen.c index 41963b5f2..a4ae03bcc 100644 --- a/kitty/screen.c +++ b/kitty/screen.c @@ -2283,6 +2283,11 @@ set_dynamic_color(Screen *self, unsigned int code, PyObject *color) { else { CALLBACK("set_dynamic_color", "IO", code, color); } } +void +color_control(Screen *self, unsigned int code, PyObject *spec) { + if (spec) CALLBACK("color_control", "IO", code, spec); +} + void clipboard_control(Screen *self, int code, PyObject *data) { if (code == 52 || code == -52) { CALLBACK("clipboard_control", "OO", data, code == -52 ? Py_True: Py_False); } diff --git a/kitty/screen.h b/kitty/screen.h index e74b8f5f9..6b5747590 100644 --- a/kitty/screen.h +++ b/kitty/screen.h @@ -227,6 +227,7 @@ void set_title(Screen *self, PyObject*); void desktop_notify(Screen *self, unsigned int, PyObject*); void set_icon(Screen *self, PyObject*); void set_dynamic_color(Screen *self, unsigned int code, PyObject*); +void color_control(Screen *self, unsigned int code, PyObject*); void clipboard_control(Screen *self, int code, PyObject*); void shell_prompt_marking(Screen *self, char *buf); void file_transmission(Screen *self, PyObject*); diff --git a/kitty/vt-parser.c b/kitty/vt-parser.c index 090a9bafe..f35565f45 100644 --- a/kitty/vt-parser.c +++ b/kitty/vt-parser.c @@ -528,6 +528,10 @@ dispatch_osc(PS *self, uint8_t *buf, size_t limit, bool is_extended_osc) { START_DISPATCH DISPATCH_OSC_WITH_CODE(set_dynamic_color); END_DISPATCH + case 21: + START_DISPATCH + DISPATCH_OSC_WITH_CODE(color_control); + END_DISPATCH case 52: case 5522: START_DISPATCH if (is_extended_osc && code == 52) code = -52; diff --git a/kitty/window.py b/kitty/window.py index 440183398..305c3d383 100644 --- a/kitty/window.py +++ b/kitty/window.py @@ -56,6 +56,7 @@ from .fast_data_types import ( SCROLL_LINE, SCROLL_PAGE, Color, + ColorProfile, KeyEvent, Screen, add_timer, @@ -103,6 +104,7 @@ from .terminfo import get_capabilities from .types import MouseEvent, OverlayType, WindowGeometry, ac, run_once from .typing import BossType, ChildType, EdgeLiteral, TabType, TypedDict from .utils import ( + color_as_int, docs_url, key_val_matcher, kitty_ansi_sanitizer_pat, @@ -460,6 +462,62 @@ def process_remote_print(msg: memoryview) -> str: return replace_c0_codes_except_nl_space_tab(base64_decode(msg)).decode('utf-8', 'replace') +def color_control(cp: ColorProfile, code: int, value: Union[str, bytes, memoryview] = '') -> str: + if isinstance(value, (bytes, memoryview)): + value = str(value, 'utf-8', 'replace') + responses = {} + for rec in value.split(';'): + key, sep, val = rec.partition('=') + attr = { + 'foreground': 'default_fg', 'background': 'default_bg', + 'selection_background': 'highlight_bg', 'selection_foreground': 'highlight_fg', + 'cursor': 'cursor_color', 'cursor_text': 'cursor_text_color', + 'visual_bell': 'visual_bell_color', 'second_transparent_background': 'second_transparent_bg', + }.get(key, '') + colnum = -1 + with suppress(Exception): + colnum = int(key) + + def serialize_color(c: Optional[Color]) -> str: + return '' if c is None else f'rgb:{c.red:02x}/{c.green:02x}/{c.blue:02x}' + + if sep == '=': + if val == '?': + if attr: + c = getattr(cp, attr) + responses[key] = serialize_color(c) + else: + if 0 <= colnum <= 255: + c = cp.as_color((colnum << 8) | 1) + responses[key] = serialize_color(c) + else: + responses[key] = '?' + else: + if attr: + if val: + col = to_color(val) + if col is not None: + setattr(cp, attr, col) + else: + with suppress(TypeError): + setattr(cp, attr, None) + else: + if 0 <= colnum <= 255: + col = to_color(val) + if col is not None: + cp.set_color(colnum, color_as_int(col)) + else: + if attr: + delattr(cp, attr) + else: + if 0 <= colnum <= 255: + cp.set_color(colnum, get_options().color_table[colnum]) + if responses: + payload = ';'.join(f'{k}={v}' for k, v in responses.items()) + return f'{code};{payload}' + return '' + + class EdgeWidths: left: Optional[float] top: Optional[float] @@ -1192,6 +1250,11 @@ class Window: if pty_size[0] > -1 and self.screen.in_band_resize_notification: self.screen.send_escape_code_to_child(ESC_CSI, f'48;{pty_size[0]};{pty_size[1]};{pty_size[3]};{pty_size[2]}t') + def color_control(self, code: int, value: Union[str, bytes, memoryview] = '') -> None: + response = color_control(self.screen.color_profile, code, value) + if response: + self.screen.send_escape_code_to_child(ESC_OSC, response) + def set_dynamic_color(self, code: int, value: Union[str, bytes, memoryview] = '') -> None: if isinstance(value, (bytes, memoryview)): value = str(value, 'utf-8', 'replace') diff --git a/kitty_tests/__init__.py b/kitty_tests/__init__.py index 64a53de2b..7233cab8a 100644 --- a/kitty_tests/__init__.py +++ b/kitty_tests/__init__.py @@ -22,6 +22,7 @@ from kitty.config import finalize_keys, finalize_mouse_mappings from kitty.fast_data_types import Cursor, HistoryBuf, LineBuf, Screen, get_options, monotonic, set_options from kitty.options.parse import merge_result_dicts from kitty.options.types import Options, defaults +from kitty.rgb import to_color from kitty.types import MouseEvent from kitty.utils import read_screen_size from kitty.window import decode_cmdline, process_remote_print, process_title_from_child @@ -53,6 +54,18 @@ class Callbacks: def notify_child_of_resize(self): self.num_of_resize_events += 1 + def color_control(self, code, data) -> None: + from kitty.window import color_control + response = color_control(self.color_profile, code, data) + if response: + def p(x): + ans = to_color(x) + if ans is None: + ans = x + return ans + parts = {x.partition('=')[0]:p(x.partition('=')[2]) for x in response.split(';')[1:]} + self.color_control_responses.append(parts) + def title_changed(self, data, is_base64=False) -> None: self.titlebuf.append(process_title_from_child(data, is_base64, '')) @@ -100,6 +113,7 @@ class Callbacks: self.iconbuf = self.colorbuf = self.ctbuf = '' self.titlebuf = [] self.printbuf = [] + self.color_control_responses = [] self.notifications = [] self.open_urls = [] self.cc_buf = [] @@ -242,6 +256,7 @@ class BaseTest(TestCase): self.set_options(options) c = Callbacks() s = Screen(c, lines, cols, scrollback, cell_width, cell_height, 0, c) + c.color_profile = s.color_profile return s def create_pty( diff --git a/kitty_tests/datatypes.py b/kitty_tests/datatypes.py index 75410c66e..7d48505f4 100644 --- a/kitty_tests/datatypes.py +++ b/kitty_tests/datatypes.py @@ -5,10 +5,8 @@ import os import sys import tempfile -from kitty.config import defaults from kitty.fast_data_types import ( Color, - ColorProfile, HistoryBuf, LineBuf, expand_ansi_c_escapes, @@ -476,13 +474,6 @@ class TestDataTypes(BaseTest): self.ae(sanitize_url_for_dispay_to_user( 'h://a\u0430b.com/El%20Ni%C3%B1o/'), 'h://xn--ab-7kc.com/El NiƱo/') - def test_color_profile(self): - c = ColorProfile(defaults) - for i in range(8): - col = getattr(defaults, f'color{i}') - self.ae(c.as_color(i << 8 | 1), col) - self.ae(c.as_color(255 << 8 | 1), Color(0xee, 0xee, 0xee)) - def test_historybuf(self): lb = filled_line_buf() hb = HistoryBuf(5, 5) diff --git a/kitty_tests/screen.py b/kitty_tests/screen.py index 704901837..e3460ae8f 100644 --- a/kitty_tests/screen.py +++ b/kitty_tests/screen.py @@ -1,8 +1,10 @@ #!/usr/bin/env python # License: GPL v3 Copyright: 2016, Kovid Goyal -from kitty.fast_data_types import DECAWM, DECCOLM, DECOM, IRM, VT_PARSER_BUFFER_SIZE, Cursor +from kitty.config import defaults +from kitty.fast_data_types import DECAWM, DECCOLM, DECOM, IRM, VT_PARSER_BUFFER_SIZE, Color, ColorProfile, Cursor from kitty.marks import marker_from_function, marker_from_regex +from kitty.rgb import color_names from kitty.window import pagerhist from . import BaseTest, parse_bytes @@ -1258,3 +1260,23 @@ class TestScreen(BaseTest): t('<', '0') t('=left_ptr', 'default') t('=fleur', 'move') + + def test_color_profile(self): + c = ColorProfile(defaults) + for i in range(8): + col = getattr(defaults, f'color{i}') + self.ae(c.as_color(i << 8 | 1), col) + self.ae(c.as_color(255 << 8 | 1), Color(0xee, 0xee, 0xee)) + s = self.create_screen() + s.color_profile.reload_from_opts(defaults) + def q(send, expected=None): + s.callbacks.clear() + parse_bytes(s, b'\x1b]21;' + ';'.join(f'{k}={v}' for k, v in send.items()).encode() + b'\a') + self.ae(s.callbacks.color_control_responses, [expected] if expected else []) + q({k: '?' for k in 'background foreground 213 unknown'.split()}, { + 'background': defaults.background, 'foreground': defaults.foreground, '213': defaults.color213, 'unknown': '?'}) + q({'background':'aquamarine'}) + q({'background':'?', 'selection_background': '?'}, {'background': color_names['aquamarine'], 'selection_background': s.color_profile.highlight_bg}) + q({'selection_background': ''}) + self.assertIsNone(s.color_profile.highlight_bg) + q({'selection_background': '?'}, {'selection_background': ''})