diff --git a/kitty/fast_data_types.pyi b/kitty/fast_data_types.pyi index c91aa35c2..5ceeb9ff3 100644 --- a/kitty/fast_data_types.pyi +++ b/kitty/fast_data_types.pyi @@ -1137,7 +1137,7 @@ def set_font_data( prerender_func: Callable[ [int, int, int, int, int, int, int, float, float, float, float], Tuple[Tuple[int, ...], Tuple[Array[c_ubyte], ...]]], - descriptor_for_idx: Callable[[int], Tuple[FontObject, bool, bool]], + descriptor_for_idx: Callable[[int], Tuple[Union[FontObject|str], bool, bool]], bold: int, italic: int, bold_italic: int, num_symbol_fonts: int, symbol_maps: Tuple[Tuple[int, int, int], ...], font_sz_in_pts: float, narrow_symbols: Tuple[Tuple[int, int, int], ...], diff --git a/kitty/fonts.c b/kitty/fonts.c index a1e090acf..1714407c2 100644 --- a/kitty/fonts.c +++ b/kitty/fonts.c @@ -979,7 +979,7 @@ render_group( #define sendtogpu { FontCellMetrics scaled = fg->fcm; current_send_sprite_to_gpu((FONTS_DATA_HANDLE)fg, sp[i]->x, sp[i]->y, sp[i]->z, b); fg->fcm = scaled; } - if (scale == 1.f) { + if (scale == 1.f && rf.scale == 1 && !rf.subscale_n) { for (unsigned i = 0; i < num_cells; i++) { if (!sp[i]->rendered) { sp[i]->rendered = true; @@ -1757,7 +1757,8 @@ initialize_font(FontGroup *fg, unsigned int desc_idx, const char *ftype) { if (d == NULL) { PyErr_Print(); fatal("Failed for %s font", ftype); } bool bold = PyObject_IsTrue(PyTuple_GET_ITEM(d, 1)); bool italic = PyObject_IsTrue(PyTuple_GET_ITEM(d, 2)); - PyObject *face = desc_to_face(PyTuple_GET_ITEM(d, 0), (FONTS_DATA_HANDLE)fg); + PyObject *x = PyTuple_GET_ITEM(d, 0); + PyObject *face = PyUnicode_Check(x) ? face_from_path(PyUnicode_AsUTF8(x), 0, (FONTS_DATA_HANDLE)fg) : desc_to_face(x, (FONTS_DATA_HANDLE)fg); Py_CLEAR(d); if (face == NULL) { PyErr_Print(); fatal("Failed to convert descriptor to face for %s font", ftype); } size_t idx = fg->fonts_count++; diff --git a/kitty/fonts/box_drawing.py b/kitty/fonts/box_drawing.py index e993d468f..417af30da 100644 --- a/kitty/fonts/box_drawing.py +++ b/kitty/fonts/box_drawing.py @@ -28,11 +28,11 @@ def thickness(level: int = 1, horizontal: bool = True) -> int: return int(math.ceil(pts * (_dpi / 72.0))) -def draw_hline(buf: BufType, width: int, x1: int, x2: int, y: int, level: int, supersample_factor: int = 1) -> None: +def draw_hline(buf: BufType, width: int, height: int, x1: int, x2: int, y: int, level: int, supersample_factor: int = 1) -> None: ' Draw a horizontal line between [x1, x2) centered at y with the thickness given by level and supersample factor ' sz = int(supersample_factor * thickness(level=level, horizontal=False)) - start = y - sz // 2 - for y in range(start, start + sz): + start = max(0, y - sz // 2) + for y in range(start, min(start + sz, height)): offset = y * width for x in range(x1, x2): buf[offset + x] = 255 @@ -41,15 +41,15 @@ def draw_hline(buf: BufType, width: int, x1: int, x2: int, y: int, level: int, s def draw_vline(buf: BufType, width: int, y1: int, y2: int, x: int, level: int, supersample_factor: float = 1.0) -> None: ' Draw a vertical line between [y1, y2) centered at x with the thickness given by level and supersample factor ' sz = int(supersample_factor * thickness(level=level, horizontal=True)) - start = x - sz // 2 - for x in range(start, start + sz): + start = max(0, x - sz // 2) + for x in range(start, min(start + sz, width)): for y in range(y1, y2): buf[x + y * width] = 255 def half_hline(buf: BufType, width: int, height: int, level: int = 1, which: str = 'left', extend_by: int = 0) -> None: x1, x2 = (0, extend_by + width // 2) if which == 'left' else (width // 2 - extend_by, width) - draw_hline(buf, width, x1, x2, height // 2, level) + draw_hline(buf, width, height, x1, x2, height // 2, level) def half_vline(buf: BufType, width: int, height: int, level: int = 1, which: str = 'top', extend_by: int = 0) -> None: @@ -59,7 +59,7 @@ def half_vline(buf: BufType, width: int, height: int, level: int = 1, which: str def get_holes(sz: int, hole_sz: int, num: int) -> list[tuple[int, ...]]: all_holes_use = (num + 1) * hole_sz - individual_block_size = (sz - all_holes_use) // (num + 1) + individual_block_size = max(1, (sz - all_holes_use) // (num + 1)) half_hole_sz = hole_sz // 2 pos = - half_hole_sz holes = [] @@ -354,7 +354,7 @@ def fading_hline(buf: SSByteArray, width: int, height: int, level: int = 1, num: factor = buf.supersample_factor y = (height // 2 // factor) * factor for x1, x2 in get_fading_lines(width, num, fade): - draw_hline(buf, width, x1, x2, y, level, supersample_factor = factor) + draw_hline(buf, width, height, x1, x2, y, level, supersample_factor = factor) @supersampled() @@ -622,9 +622,9 @@ def commit(buf: SSByteArray, width: int, height: int, level: int = 1, scale: flo for line in lines: if line == 'horizontal' or line == 'right': - draw_hline(buf, width, hwidth, width, hheight, level, supersample_factor=factor) + draw_hline(buf, width, height, hwidth, width, hheight, level, supersample_factor=factor) if line == 'horizontal' or line == 'left': - draw_hline(buf, width, 0, hwidth, hheight, level, supersample_factor=factor) + draw_hline(buf, width, height, 0, hwidth, hheight, level, supersample_factor=factor) if line == 'vertical' or line == 'down': draw_vline(buf, width, hheight, height, hwidth, level, supersample_factor=factor) if line == 'vertical' or line == 'up': @@ -639,9 +639,9 @@ def half_dhline(buf: BufType, width: int, height: int, level: int = 1, which: st x1, x2 = (0, width // 2) if which == 'left' else (width // 2, width) gap = thickness(level + 1, horizontal=False) if only != 'bottom': - draw_hline(buf, width, x1, x2, height // 2 - gap, level) + draw_hline(buf, width, height, x1, x2, height // 2 - gap, level) if only != 'top': - draw_hline(buf, width, x1, x2, height // 2 + gap, level) + draw_hline(buf, width, height, x1, x2, height // 2 + gap, level) return height // 2 - gap, height // 2 + gap @@ -692,12 +692,12 @@ def dcorner(buf: BufType, width: int, height: int, level: int = 1, which: str = x2 += vgap else: x1 -= vgap - draw_hline(buf, width, x1, x2, height // 2 + ydelta, level) + draw_hline(buf, width, height, x1, x2, height // 2 + ydelta, level) if hw == 'left': x2 -= 2 * vgap else: x1 += 2 * vgap - draw_hline(buf, width, x1, x2, height // 2 - ydelta, level) + draw_hline(buf, width, height, x1, x2, height // 2 - ydelta, level) y1, y2 = (0, height // 2) if vw == 'top' else (height // 2, height) xdelta = vgap if hw == 'right' else -vgap yd = thickness(level, horizontal=True) // 2 @@ -717,7 +717,7 @@ def dpip(buf: BufType, width: int, height: int, level: int = 1, which: str = ' if which in '╟╢': left, right = dvline(buf, width, height) x1, x2 = (0, left) if which == '╢' else (right, width) - draw_hline(buf, width, x1, x2, height // 2, level) + draw_hline(buf, width, height, x1, x2, height // 2, level) else: top, bottom = dhline(buf, width, height) y1, y2 = (0, top) if which == '╧' else (bottom, height) @@ -730,7 +730,7 @@ def inner_corner(buf: BufType, width: int, height: int, which: str = 'tl', level vthick = thickness(level, horizontal=True) // 2 x1, x2 = (0, width // 2 - hgap + vthick + 1) if 'l' in which else (width // 2 + hgap - vthick, width) yd = -1 if 't' in which else 1 - draw_hline(buf, width, x1, x2, height // 2 + (yd * vgap), level) + draw_hline(buf, width, height, x1, x2, height // 2 + (yd * vgap), level) y1, y2 = (0, height // 2 - vgap) if 't' in which else (height // 2 + vgap, height) xd = -1 if 'l' in which else 1 draw_vline(buf, width, y1, y2, width // 2 + (xd * hgap), level) diff --git a/kitty/fonts/render.py b/kitty/fonts/render.py index 90056078d..9cbf486cb 100644 --- a/kitty/fonts/render.py +++ b/kitty/fonts/render.py @@ -163,8 +163,14 @@ def create_narrow_symbols(opts: Options) -> tuple[tuple[int, int, int], ...]: return tuple((a, b, v) for (a, b), v in coalesce_symbol_maps(opts.narrow_symbols).items()) -def descriptor_for_idx(idx: int) -> tuple[FontObject, bool, bool]: - return current_faces[idx] +descriptor_overrides: dict[int, tuple[str, bool, bool]] = {} + + +def descriptor_for_idx(idx: int) -> tuple[Union[FontObject | str], bool, bool]: + ans = descriptor_overrides.get(idx) + if ans is None: + return current_faces[idx] + return ans def dump_font_debug() -> None: @@ -431,10 +437,12 @@ def render_box_drawing(codepoint: int, cell_width: int, cell_height: int, dpi: f class setup_for_testing: - def __init__(self, family: str = 'monospace', size: float = 11.0, dpi: float = 96.0): + def __init__(self, family: str = 'monospace', size: float = 11.0, dpi: float = 96.0, main_face_path: str = ''): self.family, self.size, self.dpi = family, size, dpi + self.main_face_path = main_face_path def __enter__(self) -> tuple[dict[tuple[int, int, int], bytes], int, int]: + global descriptor_overrides opts = defaults._replace(font_family=parse_font_spec(self.family), font_size=self.size) set_options(opts) sprites = {} @@ -444,6 +452,10 @@ class setup_for_testing: sprite_map_set_limits(100000, 100) set_send_sprite_to_gpu(send_to_gpu) + self.orig_desc_overrides = descriptor_overrides + descriptor_overrides = {} + if self.main_face_path: + descriptor_overrides[0] = self.main_face_path, False, False try: set_font_family(opts) cell_width, cell_height = create_test_font_group(self.size, self.dpi, self.dpi) @@ -453,6 +465,8 @@ class setup_for_testing: raise def __exit__(self, *args: Any) -> None: + global descriptor_overrides + descriptor_overrides = self.orig_desc_overrides set_send_sprite_to_gpu(None) diff --git a/kitty_tests/fonts.py b/kitty_tests/fonts.py index e35b51136..de83a46fd 100644 --- a/kitty_tests/fonts.py +++ b/kitty_tests/fonts.py @@ -5,7 +5,7 @@ import array import os import tempfile import unittest -from functools import partial +from functools import lru_cache, partial from math import ceil from kitty.constants import is_macos, read_kitty_resource @@ -32,6 +32,11 @@ def parse_font_spec(spec): return FontSpec.from_setting(spec) +@lru_cache(maxsize=64) +def testing_font_data(name): + return read_kitty_resource(name, __name__.rpartition('.')[0]) + + class Selection(BaseTest): def test_font_selection(self): @@ -165,28 +170,110 @@ class Selection(BaseTest): self.ae(face_from_descriptor(ff['medium']).applied_features(), {'dlig': 'dlig', 'test': 'test=3'}) self.ae(face_from_descriptor(ff['bold']).applied_features(), {'dlig': 'dlig', 'test': 'test=3'}) +def block_helpers(s, sprites, cell_width, cell_height): + block_size = cell_width * cell_height * 4 -class Rendering(BaseTest): + def full_block(): + return b'\xff' * block_size + + def empty_block(): + return b'\0' * block_size + + def half_block(first=b'\xff', second=b'\0', swap=False): + frac = 0.5 + height = ceil(frac * cell_height) + rest = cell_height - height + if swap: + height, rest = rest, height + first, second = second, first + return (first * (height * cell_width * 4)) + (second * rest * cell_width * 4) + + def quarter_block(): + frac = 0.5 + height = ceil(frac * cell_height) + width = ceil(frac * cell_width) + ans = array.array('I', b'\0' * block_size) + for y in range(height): + pos = cell_width * y + for x in range(width): + ans[pos + x] = 0xffffffff + return ans.tobytes() + + def upper_half_block(): + return half_block() + + def lower_half_block(): + return half_block(swap=True) + + def block_as_str(a): + pixels = array.array('I', a) + def row(y): + pos = y * cell_width + return ' '.join(f'{int(pixels[pos + x] != 0)}' for x in range(cell_width)) + return '\n'.join(row(y) for y in range(cell_height)) + + def assert_blocks(a, b, msg=''): + if a != b: + msg = msg or 'block not equal' + if len(a) != len(b): + assert_blocks.__msg = msg + f'block lengths not equal: {len(a)/4} != {len(b)/4}' + else: + assert_blocks.__msg = msg + '\n' + block_as_str(a) + '\n\n' + block_as_str(b) + del a, b + raise AssertionError(assert_blocks.__msg) + + def multiline_render(text, scale=1, width=1, **kw): + s.reset() + draw_multicell(s, text, scale=scale, width=width, **kw) + ans = [] + for y in range(scale): + line = s.line(y) + test_render_line(line) + for x in range(width * scale): + ans.append(sprites[line.sprite_at(x)]) + return ans + + def block_test(*expected, **kw): + for i, (expected, actual) in enumerate(zip(expected, multiline_render(kw.pop('text', '█'), **kw), strict=True)): + assert_blocks(expected(), actual, f'Block {i} is not equal') + + + return full_block, empty_block, upper_half_block, lower_half_block, quarter_block, block_as_str, block_test + + +class FontBaseTest(BaseTest): + + font_size = 5.0 + dpi = 72. + font_name = 'FiraCode-Medium.otf' + + def path_for_font(self, name): + if name not in self.font_path_cache: + with open(os.path.join(self.tdir, name), 'wb') as f: + self.font_path_cache[name] = f.name + f.write(testing_font_data(name)) + return self.font_path_cache[name] def setUp(self): super().setUp() - self.test_ctx = setup_for_testing() - self.test_ctx.__enter__() - self.sprites, self.cell_width, self.cell_height = self.test_ctx.__enter__() - try: - self.assertEqual([k[0] for k in self.sprites], [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) - except Exception: - self.test_ctx.__exit__() - del self.test_ctx - raise + self.font_path_cache = {} self.tdir = tempfile.mkdtemp() + self.addCleanup(self.rmtree_ignoring_errors, self.tdir) + path = self.path_for_font(self.font_name) if self.font_name else '' + tc = setup_for_testing(size=self.font_size, dpi=self.dpi, main_face_path=path) + self.sprites, self.cell_width, self.cell_height = tc.__enter__() + self.addCleanup(tc.__exit__) + self.assertEqual([k[0] for k in self.sprites], [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) def tearDown(self): - self.test_ctx.__exit__() - del self.sprites, self.cell_width, self.cell_height, self.test_ctx - self.rmtree_ignoring_errors(self.tdir) + del self.sprites, self.cell_width, self.cell_height + self.font_path_cache = {} super().tearDown() + + +class Rendering(FontBaseTest): + def test_sprite_map(self): sprite_map_set_limits(10, 2) sprite_map_set_layout(5, 5) @@ -210,86 +297,17 @@ class Rendering(BaseTest): self.assertEqual(len(self.sprites) - prerendered, len(box_chars)) def test_scaled_box_drawing(self): - block_size = self.cell_width * self.cell_height * 4 - - def full_block(): - return b'\xff' * block_size - - def empty_block(): - return b'\0' * block_size - - def half_block(first=b'\xff', second=b'\0'): - frac = 0.5 - height = ceil(frac * self.cell_height) - rest = self.cell_height - height - return (first * (rest * self.cell_width * 4)) + (second * height * self.cell_width * 4) - - def quarter_block(): - frac = 0.5 - height = ceil(frac * self.cell_height) - width = ceil(frac * self.cell_width) - ans = array.array('I', b'\0' * block_size) - self.ae(len(ans), self.cell_width * self.cell_height) - for y in range(height): - pos = self.cell_width * y - for x in range(width): - ans[pos + x] = 0xffffffff - return ans.tobytes() - - def upper_half_block(): - return half_block() - - def lower_half_block(): - return half_block(b'\0', b'\xff') - - def block_as_str(a): - pixels = array.array('I', a) - def row(y): - pos = y * self.cell_width - return ' '.join(f'{int(pixels[pos + x] != 0)}' for x in range(self.cell_width)) - return '\n'.join(row(y) for y in range(self.cell_height)) - - def assert_blocks(a, b, msg=''): - if a != b: - assert_blocks.__msg = (msg or 'block not equal') + '\n' + block_as_str(a) + '\n\n' + block_as_str(b) - del a, b - raise AssertionError(assert_blocks.__msg) - s = self.create_screen(cols=8, lines=8, scrollback=0) - - s.reset() - before = len(self.sprites) - draw_multicell(s, '██', scale=1, subscale_n=1, subscale_d=2, vertical_align=0, width=1) - test_render_line(s.line(0)) - self.ae(len(self.sprites), before + 1) - assert_blocks(upper_half_block(), self.sprites[tuple(self.sprites)[before]]) - - s.reset() - before = len(self.sprites) - draw_multicell(s, '█', scale=1, subscale_n=1, subscale_d=2, vertical_align=0) - test_render_line(s.line(0)) - self.ae(len(self.sprites), before + 1) - assert_blocks(quarter_block(), self.sprites[tuple(self.sprites)[before]]) - - def block_test(a=empty_block, b=empty_block, c=empty_block, d=empty_block, scale=2, half_block=True, vertical_align=0): - s.reset() - before = len(self.sprites) - subscale_n = subscale_d = 0 - if half_block: - subscale_n, subscale_d = 1, 2 - draw_multicell(s, '█', scale=scale, subscale_n=subscale_n, subscale_d=subscale_d, vertical_align=vertical_align) - test_render_line(s.line(0)) - self.ae(len(self.sprites), before + 2) - test_render_line(s.line(1)) - self.ae(len(self.sprites), before + 4) - blocks = tuple(self.sprites)[before:] - for i, (expected, actual) in enumerate(zip((a(), b(), c(), d()), blocks)): - assert_blocks(expected, self.sprites[actual], f'The {i} block differs') - - block_test(full_block, full_block, full_block, full_block, half_block=False) - block_test(a=full_block) - block_test(c=full_block, vertical_align=1) - block_test(a=lower_half_block, c=upper_half_block, vertical_align=2) + full_block, empty_block, upper_half_block, lower_half_block, quarter_block, block_as_str, block_test = block_helpers( + s, self.sprites, self.cell_width, self.cell_height) + block_test(full_block) + block_test(full_block, full_block, full_block, full_block, scale=2) + block_test(full_block, empty_block, empty_block, empty_block, scale=2, subscale_n=1, subscale_d=2) + block_test(full_block, full_block, empty_block, empty_block, scale=2, subscale_n=1, subscale_d=2, text='██') + block_test(empty_block, empty_block, full_block, empty_block, scale=2, subscale_n=1, subscale_d=2, vertical_align=1) + block_test(quarter_block, scale=1, subscale_n=1, subscale_d=2) + block_test(upper_half_block, scale=1, subscale_n=1, subscale_d=2, text='██') + block_test(lower_half_block, scale=1, subscale_n=1, subscale_d=2, text='██', vertical_align=1) def test_font_rendering(self): render_string('ab\u0347\u0305你好|\U0001F601|\U0001F64f|\U0001F63a|') @@ -306,18 +324,8 @@ class Rendering(BaseTest): def test_shaping(self): - font_path_cache = {} - - def path_for_font(name): - if name not in font_path_cache: - with open(os.path.join(self.tdir, name), 'wb') as f: - font_path_cache[name] = f.name - data = read_kitty_resource(name, __name__.rpartition('.')[0]) - f.write(data) - return font_path_cache[name] - def ss(text, font=None): - path = path_for_font(font) if font else None + path = self.path_for_font(font) if font else None return shape_string(text, path=path) def groups(text, font=None):