New OSC 21 protocol for color control

Needs to be specced up
This commit is contained in:
Kovid Goyal 2024-07-22 10:57:13 +05:30
parent a927e1e4f4
commit deff40df8a
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
9 changed files with 129 additions and 13 deletions

View file

@ -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; \

19
kitty/rgb.py generated
View file

@ -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

View file

@ -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); }

View file

@ -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*);

View file

@ -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;

View file

@ -56,6 +56,7 @@
SCROLL_LINE,
SCROLL_PAGE,
Color,
ColorProfile,
KeyEvent,
Screen,
add_timer,
@ -103,6 +104,7 @@
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 @@ def notify_child_of_resize(self) -> None:
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')

View file

@ -22,6 +22,7 @@
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 @@ def write(self, data) -> None:
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 @@ def clear(self) -> None:
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 @@ def create_screen(self, cols=5, lines=5, scrollback=5, cell_width=10, cell_heigh
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(

View file

@ -5,10 +5,8 @@
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 @@ def on_csi(x):
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)

View file

@ -1,8 +1,10 @@
#!/usr/bin/env python
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
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 @@ def t(q, e=None):
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': ''})