kitty/kitty_tests/fonts.py
Strykar fc23ef57ec
fonts: make the color emoji regression test environment-independent
The coverage threshold (0.5 in fbd4e5ad1, lowered to 0.3 in d4106ef2d to
get CI green) is environment-dependent: a correctly sized emoji covers
~0.84 of its cell on one box but ~0.39 in CI, while the buggy render is
~0.28, so the margin is thin and font/cell dependent.

Render the same font two ways at one size instead: via its fontconfig
descriptor (which carries the size-fixup matrix) and via its file path
(which does not). They must come out the same size; the bug shrinks the
descriptor one. Only the matrix differs, so the check no longer depends
on the environment or the emoji artwork.
2026-06-14 21:00:12 +05:30

1315 lines
34 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python
# License: GPL v3 Copyright: 2017, Kovid Goyal <kovid at kovidgoyal.net>
import array
import os
import tempfile
import unittest
from collections.abc import Iterable
from functools import lru_cache, partial
from itertools import repeat
from math import ceil
from kitty.constants import is_macos, read_kitty_resource
from kitty.fast_data_types import (
DECAWM,
ParsedFontFeature,
get_fallback_font,
set_allow_use_of_box_fonts,
sprite_idx_to_pos,
sprite_map_set_layout,
sprite_map_set_limits,
test_render_line,
test_sprite_position_increment,
wcwidth,
)
from kitty.fonts import family_name_to_key
from kitty.fonts.common import FontSpec, all_fonts_map, face_from_descriptor, get_font_files, get_named_style, spec_for_face
from kitty.fonts.render import coalesce_symbol_maps, create_face, render_string, setup_for_testing, shape_string
from kitty.options.types import Options
from . import BaseTest, draw_multicell
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):
self.set_options({'font_features': {'LiberationMono': (ParsedFontFeature('-dlig'),)}})
opts = Options()
fonts_map = all_fonts_map(True)
names = set(fonts_map['family_map']) | set(fonts_map['variable_map'])
del fonts_map
def s(family: str, *expected: str, alternate=None) -> None:
opts.font_family = parse_font_spec(family)
ff = get_font_files(opts)
actual = tuple(face_from_descriptor(ff[x]).postscript_name() for x in ('medium', 'bold', 'italic', 'bi')) # type: ignore
del ff
for x in actual:
if '/' in x: # Old FreeType failed to generate postscript name for a variable font probably
return
with self.subTest(spec=family):
try:
self.ae(expected, actual)
except AssertionError:
if alternate:
self.ae(alternate, actual)
else:
raise
def both(family: str, *expected: str, alternate=None) -> None:
for family in (family, f'family="{family}"'):
s(family, *expected, alternate=alternate)
def has(family, allow_missing_in_ci=False):
ans = family_name_to_key(family) in names
if self.is_ci and not allow_missing_in_ci and not ans:
raise AssertionError(f'The family: {family} is not available')
return ans
def t(family, psprefix, bold='Bold', italic='Italic', bi='', reg='Regular', allow_missing_in_ci=False, alternate=None):
if has(family, allow_missing_in_ci=allow_missing_in_ci):
bi = bi or bold + italic
if reg:
reg = '-' + reg
both(family, f'{psprefix}{reg}', f'{psprefix}-{bold}', f'{psprefix}-{italic}', f'{psprefix}-{bi}', alternate=alternate)
t('Source Code Pro', 'SourceCodePro', 'Semibold', 'It')
t('sourcecodeVf', 'SourceCodeVF', 'Semibold')
# The Arch ttf-fira-code package excludes the variable fonts for some reason
t('fira code', 'FiraCodeRoman', 'SemiBold', 'Regular', 'SemiBold', alternate=(
'FiraCode-Regular', 'FiraCode-SemiBold', 'FiraCode-Retina', 'FiraCode-SemiBold'))
t('hack', 'Hack')
# some ubuntu systems (such as the build VM) have only the regular and
# bold faces of DejaVu Sans Mono installed.
# t('DejaVu Sans Mono', 'DejaVuSansMono', reg='', italic='Oblique')
t('ubuntu mono', 'UbuntuMono')
t('liberation mono', 'LiberationMono', reg='')
t('ibm plex mono', 'IBMPlexMono', 'SmBld', reg='')
t('iosevka fixed', 'Iosevka-Fixed', 'Semibold', reg='', bi='Semibold-Italic', allow_missing_in_ci=True)
t('iosevka term', 'Iosevka-Term', 'Semibold', reg='', bi='Semibold-Italic', allow_missing_in_ci=True)
t('fantasque sans mono', 'FantasqueSansMono')
t('jetbrains mono', 'JetBrainsMono', 'SemiBold')
t('consolas', 'Consolas', reg='', allow_missing_in_ci=True)
if has('cascadia code'):
if is_macos:
both('cascadia code', 'CascadiaCode-Regular', 'CascadiaCode-Regular_SemiBold', 'CascadiaCode-Italic', 'CascadiaCode-Italic_SemiBold-Italic')
else:
both('cascadia code', 'CascadiaCodeRoman-Regular', 'CascadiaCodeRoman-SemiBold', 'CascadiaCode-Italic', 'CascadiaCode-SemiBoldItalic')
if has('cascadia mono'):
if is_macos:
both('cascadia mono', 'CascadiaMono-Regular', 'CascadiaMono-Regular_SemiBold', 'CascadiaMono-Italic', 'CascadiaMono-Italic_SemiBold-Italic')
else:
both('cascadia mono', 'CascadiaMonoRoman-Regular', 'CascadiaMonoRoman-SemiBold', 'CascadiaMono-Italic', 'CascadiaMono-SemiBoldItalic')
if has('operator mono', allow_missing_in_ci=True):
both('operator mono', 'OperatorMono-Medium', 'OperatorMono-Bold', 'OperatorMono-MediumItalic', 'OperatorMono-BoldItalic')
# Test variable font selection
if has('SourceCodeVF'):
opts = Options()
opts.font_family = parse_font_spec('family="SourceCodeVF" variable_name="SourceCodeUpright" style="Bold"')
ff = get_font_files(opts)
face = face_from_descriptor(ff['medium'])
self.ae(get_named_style(face)['name'], 'Bold')
face = face_from_descriptor(ff['italic'])
self.ae(get_named_style(face)['name'], 'Bold Italic')
face = face_from_descriptor(ff['bold'])
self.ae(get_named_style(face)['name'], 'Black')
face = face_from_descriptor(ff['bi'])
self.ae(get_named_style(face)['name'], 'Black Italic')
opts.font_family = parse_font_spec('family=SourceCodeVF variable_name=SourceCodeUpright wght=470')
opts.italic_font = parse_font_spec('family=SourceCodeVF variable_name=SourceCodeItalic style=Black')
ff = get_font_files(opts)
self.assertFalse(get_named_style(ff['medium']))
self.ae(get_named_style(ff['italic'])['name'], 'Black Italic')
if has('cascadia code'):
opts = Options()
opts.font_family = parse_font_spec('family="cascadia code"')
opts.italic_font = parse_font_spec('family="cascadia code" variable_name= style="Light Italic"')
ff = get_font_files(opts)
def t(x, **kw):
if 'spec' in kw:
fs = FontSpec.from_setting('family="Cascadia Code" ' + kw['spec'])._replace(created_from_string='')
else:
kw['family'] = 'Cascadia Code'
fs = FontSpec(**kw)
face = face_from_descriptor(ff[x])
self.ae(fs.as_setting, spec_for_face('Cascadia Code', face).as_setting)
t('medium', variable_name='CascadiaCodeRoman', style='Regular')
t('italic', variable_name='', style='Light Italic')
opts = Options()
opts.font_family = parse_font_spec('family="cascadia code" variable_name=CascadiaCodeRoman wght=455')
opts.italic_font = parse_font_spec('family="cascadia code" variable_name= wght=405')
opts.bold_font = parse_font_spec('family="cascadia code" variable_name=CascadiaCodeRoman wght=603')
ff = get_font_files(opts)
t('medium', spec='variable_name=CascadiaCodeRoman wght=455')
t('italic', spec='variable_name= wght=405')
t('bold', spec='variable_name=CascadiaCodeRoman wght=603')
t('bi', spec='variable_name= wght=603')
# Test font features
if has('liberation mono'):
opts = Options()
opts.font_family = parse_font_spec('family="liberation mono"')
ff = get_font_files(opts)
self.ae(face_from_descriptor(ff['medium']).applied_features(), {'dlig': '-dlig'})
self.ae(face_from_descriptor(ff['bold']).applied_features(), {})
opts.font_family = parse_font_spec('family="liberation mono" features="dlig test=3"')
ff = get_font_files(opts)
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 test_synthetic_italic_matrix(self):
# A roman-only font that find_best_match finds (e.g. Fira Code, which ships
# no italic face) must get fontconfig's synthetic-italic FC_MATRIX
# (90-synthetic.conf) attached, so its italic renders slanted rather than
# upright; real-italic faces must not. The shear value is fontconfig's, not
# ours, so assert the invariant (a non-identity matrix is present), not the
# exact tuple, for cross-config stability.
if is_macos:
self.skipTest('synthetic-italic FC_MATRIX is a fontconfig feature')
from kitty.fonts.fontconfig import FC_MONO, fc_match
names = set(all_fonts_map(True)['family_map']) | set(all_fonts_map(True)['variable_map'])
if family_name_to_key('fira code') not in names:
self.skipTest('Fira Code not installed')
# Probe fc_match directly so we can tell "environment lacks the rule" (skip)
# from "code did not attach the matrix" (fail).
if fc_match('Fira Code', False, True, FC_MONO).get('matrix') is None:
self.skipTest('fontconfig 90-synthetic.conf not active; no synthetic-italic matrix')
opts = Options()
opts.font_family = parse_font_spec('Fira Code')
ff = get_font_files(opts)
self.assertIsNone(ff['medium'].get('matrix')) # upright stays upright
mi = ff['italic'].get('matrix')
self.assertIsNotNone(mi) # roman, no italic -> sheared
self.assertNotEqual(mi[1], 0.0) # actually slanted, not identity
# Faces are built from a size-specialized descriptor at render time; the
# matrix must survive specialize_font_descriptor or the glyphs render
# upright despite the descriptor above being correct.
from kitty.fast_data_types import specialize_font_descriptor
sd = specialize_font_descriptor(dict(ff['italic']), 12.0, 96.0, 96.0)
self.ae(sd.get('matrix'), mi)
if family_name_to_key('liberation mono') in names: # real-italic control
opts.font_family = parse_font_spec('Liberation Mono')
self.assertIsNone(get_font_files(opts)['italic'].get('matrix'))
def block_helpers(s, sprites, cell_width, cell_height):
mr = {}
actual = b''
block_size = cell_width * 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', 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=''):
nonlocal mr, actual, full_block, half_block, quarter_block, block_test, empty_block
if a != b:
del mr, actual, full_block, half_block, quarter_block, block_test, empty_block
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[sprite_idx_to_pos(line.sprite_at(x), setup_for_testing.xnum, setup_for_testing.ynum)])
return ans
def block_test(*expected, **kw):
nonlocal mr, actual
mr = multiline_render(kw.pop('text', ''), **kw)
try:
z = zip(expected, mr, strict=True)
except TypeError:
z = zip(expected, mr)
for i, (expected, actual) in enumerate(z):
assert_blocks(expected(), actual, f'Block {i} expected != actual')
return full_block, empty_block, upper_half_block, lower_half_block, quarter_block, block_as_str, block_test
class FontBaseTest(BaseTest):
font_size = 16.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.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], list(range(11)))
def tearDown(self):
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, 3)
sprite_map_set_layout(5, 4) # 4 because of underline_exclusion row
self.ae(test_sprite_position_increment(), (0, 0, 0))
self.ae(test_sprite_position_increment(), (1, 0, 0))
self.ae(test_sprite_position_increment(), (0, 1, 0))
self.ae(test_sprite_position_increment(), (1, 1, 0))
self.ae(test_sprite_position_increment(), (0, 0, 1))
self.ae(test_sprite_position_increment(), (1, 0, 1))
self.ae(test_sprite_position_increment(), (0, 1, 1))
self.ae(test_sprite_position_increment(), (1, 1, 1))
self.ae(test_sprite_position_increment(), (0, 0, 2))
self.ae(test_sprite_position_increment(), (1, 0, 2))
def test_box_drawing(self):
s = self.create_screen(cols=len(box_chars) + 1, lines=1, scrollback=0)
prerendered = len(self.sprites)
s.draw(''.join(box_chars))
line = s.line(0)
test_render_line(line)
self.assertEqual(len(self.sprites) - prerendered, len(box_chars))
def test_scaled_box_drawing(self):
self.scaled_drawing_test()
def test_scaled_font_drawing(self):
set_allow_use_of_box_fonts(False)
try:
self.scaled_drawing_test()
finally:
set_allow_use_of_box_fonts(True)
def scaled_drawing_test(self):
s = self.create_screen(cols=8, lines=8, scrollback=0)
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(empty_block, full_block, empty_block, empty_block, scale=2, subscale_n=1, subscale_d=2, horizontal_align=1)
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|')
text = 'He\u0347\u0305llo\u0341, w\u0302or\u0306l\u0354d!'
# macOS has no fonts capable of rendering combining chars
if is_macos:
text = text.encode('ascii', 'ignore').decode('ascii')
cells = render_string(text)[-1]
self.ae(len(cells), len(text.encode('ascii', 'ignore')))
text = '你好,世界'
sz = sum(map(lambda x: wcwidth(ord(x)), text))
cells = render_string(text)[-1]
self.ae(len(cells), sz)
@unittest.skipIf(is_macos, 'COLRv1 is only supported on Linux')
def test_rendering_colrv1(self):
f = create_face(self.path_for_font('twemoji_smiley-cff2_colr_1.otf'))
f.set_size(64, 96, 96)
for char in '😁😇😈':
_, w, h = f.render_codepoint(ord(char))
self.assertGreater(w, 64)
self.assertGreater(h, 64)
def test_color_emoji_not_shrunk(self):
# Regression test for https://github.com/kovidgoyal/kitty/issues/10144.
# fontconfig gives fixed-size color faces (e.g. Noto Color Emoji) a
# pixel-size fixup encoded as FC_MATRIX. That scale must not reach the
# cairo font matrix used for color glyphs; applying it shrinks color emoji
# to a dot (ee937bdd1b). Render the same font two ways at the same size:
# from its fontconfig descriptor, which carries the fixup matrix, and from
# its file path, which does not. A correct build renders both at the same
# size; the bug shrinks the descriptor one. Comparing the two is
# environment-independent since only the matrix differs.
if is_macos:
self.skipTest('FC_MATRIX is a fontconfig feature, not used on macOS')
from kitty.fonts.fontconfig import fc_match
desc = dict(fc_match('emoji', False, False, 0))
if not (desc.get('color') and desc.get('matrix')):
self.skipTest('no fixed-size color emoji font with a fontconfig fixup matrix')
with_matrix = face_from_descriptor(desc)
with_matrix.set_size(64, 96, 96)
without_matrix = create_face(desc['path'])
without_matrix.set_size(64, 96, 96)
_, mw, mh = with_matrix.render_codepoint(0x1F40D)
_, rw, rh = without_matrix.render_codepoint(0x1F40D)
self.assertGreater(mh, 0.5 * rh, f'color emoji shrunk by FC_MATRIX: {mh}px vs {rh}px (#10144)')
def test_shaping(self):
def ss(text, font=None):
path = self.path_for_font(font) if font else None
return shape_string(text, path=path)
def groups(text, font=None):
return [x[:2] for x in ss(text, font)]
for font in ('FiraCode-Medium.otf', 'CascadiaCode-Regular.otf', 'iosevka-regular.ttf'):
g = partial(groups, font=font)
self.ae(g('abcd'), [(1, 1) for i in range(4)])
self.ae(g('A===B!=C'), [(1, 1), (3, 3), (1, 1), (2, 2), (1, 1)])
self.ae(g('A=>>B!=C'), [(1, 1), (3, 3), (1, 1), (2, 2), (1, 1)])
self.ae(g('->'), [(2, 2)])
self.ae(g('<-'), [(2, 2)])
self.ae(g('==>'), [(3, 3)])
self.ae(g('<=='), [(3, 3)])
self.ae(g('a->b'), [(1, 1), (2, 2), (1, 1)])
self.ae(g('a<-b'), [(1, 1), (2, 2), (1, 1)])
self.ae(g('a==>b'), [(1, 1), (3, 3), (1, 1)])
self.ae(g('a<==b'), [(1, 1), (3, 3), (1, 1)])
if 'iosevka' in font:
self.ae(g('--->'), [(4, 4)])
self.ae(g('-' * 12 + '>'), [(13, 13)])
self.ae(g('<~~~'), [(4, 4)])
self.ae(g('a<~~~b'), [(1, 1), (4, 4), (1, 1)])
else:
self.ae(g('----'), [(4, 4)])
self.ae(g('F--a--'), [(1, 1), (2, 2), (1, 1), (2, 2)])
self.ae(g('===--<>=='), [(3, 3), (2, 2), (2, 2), (2, 2)])
self.ae(g('==!=<>==<><><>'), [(4, 4), (2, 2), (2, 2), (2, 2), (2, 2), (2, 2)])
self.ae(g('-' * 18), [(18, 18)])
self.ae(g('<==>'), [(4, 4)])
self.ae(g('<!--'), [(4, 4)])
self.ae(g('a<==>b'), [(1, 1), (4, 4), (1, 1)])
self.ae(g('a<!--b'), [(1, 1), (4, 4), (1, 1)])
self.ae(g('a>\u2060<b'), [(1, 1), (1, 2), (1, 1), (1, 1)])
comfy = partial(groups, font='ComfyCode-Regular.ttf')
comfy_cases = {
'->': ('a->b', 2),
'<-': ('a<-b', 2),
'==>': ('a==>b', 3),
'<==': ('a<==b', 3),
}
for text, (wrapped, ligature_width) in comfy_cases.items():
baseline = comfy(text)
self.assertIn(baseline, ([(ligature_width, ligature_width)], [(1, 1) for i in range(ligature_width)]))
if baseline == [(ligature_width, ligature_width)]:
self.ae(comfy(wrapped), [(1, 1), (ligature_width, ligature_width), (1, 1)])
else:
self.ae(comfy(wrapped), [(1, 1) for i in range(len(wrapped))])
colon_glyph = ss('9:30', font='FiraCode-Medium.otf')[1][2]
self.assertNotEqual(colon_glyph, ss(':', font='FiraCode-Medium.otf')[0][2])
self.ae(colon_glyph, 1031)
self.ae(groups('9:30', font='FiraCode-Medium.otf'), [(1, 1), (1, 1), (1, 1), (1, 1)])
self.ae(groups('#_(', font='FiraCode-Medium.otf'), [(3, 3)])
self.ae(groups('a#_(b', font='FiraCode-Medium.otf'), [(1, 1), (3, 3), (1, 1)])
self.ae(groups('<*>>', font='FiraCode-Medium.otf'), [(3, 3), (1, 1)])
self.ae(groups('a<*>>b', font='FiraCode-Medium.otf'), [(1, 1), (3, 3), (1, 1), (1, 1)])
self.ae(groups('|\U0001F601|\U0001F64f|\U0001F63a|'), [(1, 1), (2, 1), (1, 1), (2, 1), (1, 1), (2, 1), (1, 1)])
self.ae(groups('He\u0347\u0305llo\u0337,', font='LiberationMono-Regular.ttf'),
[(1, 1), (1, 3), (1, 1), (1, 1), (1, 2), (1, 1)])
self.ae(groups('i\u0332\u0308', font='LiberationMono-Regular.ttf'), [(1, 2)])
self.ae(groups('u\u0332 u\u0332\u0301', font='LiberationMono-Regular.ttf'), [(1, 2), (1, 1), (1, 2)])
def test_emoji_presentation(self):
s = self.create_screen()
s.draw('\u2716\u2716\ufe0f')
self.ae((s.cursor.x, s.cursor.y), (3, 0))
s.draw('\u2716\u2716')
self.ae((s.cursor.x, s.cursor.y), (5, 0))
s.draw('\ufe0f')
self.ae((s.cursor.x, s.cursor.y), (2, 1))
self.ae(str(s.line(0)), '\u2716\u2716\ufe0f\u2716')
self.ae(str(s.line(1)), '\u2716\ufe0f')
s.draw('\u2716' * 3)
self.ae((s.cursor.x, s.cursor.y), (5, 1))
self.ae(str(s.line(1)), '\u2716\ufe0f\u2716\u2716\u2716')
self.ae((s.cursor.x, s.cursor.y), (5, 1))
s.reset_mode(DECAWM)
s.draw('\ufe0f')
s.set_mode(DECAWM)
self.ae((s.cursor.x, s.cursor.y), (5, 1))
self.ae(str(s.line(1)), '\u2716\ufe0f\u2716\u2716\ufe0f')
s.cursor.y = s.lines - 1
s.draw('\u2716' * s.columns)
self.ae((s.cursor.x, s.cursor.y), (5, 4))
s.draw('\ufe0f')
self.ae((s.cursor.x, s.cursor.y), (2, 4))
self.ae(str(s.line(s.cursor.y)), '\u2716\ufe0f')
@unittest.skipUnless(is_macos, 'Only macOS has a Last Resort font')
def test_fallback_font_not_last_resort(self):
# Ensure that the LastResort font is not reported as a fallback font on
# macOS. See https://github.com/kovidgoyal/kitty/issues/799
with self.assertRaises(ValueError, msg='No fallback font found'):
get_fallback_font('\U0010FFFF', False, False)
def test_coalesce_symbol_maps(self):
q = {(2, 3): 'a', (4, 6): 'b', (5, 5): 'b', (7, 7): 'b', (9, 9): 'b', (1, 1): 'a'}
self.ae(coalesce_symbol_maps(q), {(1, 3): 'a', (4, 7): 'b', (9, 9): 'b'})
q = {(1, 4): 'a', (2, 3): 'b'}
self.ae(coalesce_symbol_maps(q), {(1, 1): 'a', (2, 3): 'b', (4, 4): 'a'})
q = {(2, 3): 'b', (1, 4): 'a'}
self.ae(coalesce_symbol_maps(q), {(1, 4): 'a'})
q = {(1, 4): 'a', (2, 5): 'b'}
self.ae(coalesce_symbol_maps(q), {(1, 1): 'a', (2, 5): 'b'})
q = {(2, 5): 'b', (1, 4): 'a'}
self.ae(coalesce_symbol_maps(q), {(1, 4): 'a', (5, 5): 'b'})
q = {(1, 4): 'a', (2, 5): 'a'}
self.ae(coalesce_symbol_maps(q), {(1, 5): 'a'})
q = {(1, 4): 'a', (4, 5): 'b'}
self.ae(coalesce_symbol_maps(q), {(1, 3): 'a', (4, 5): 'b'})
q = {(4, 5): 'b', (1, 4): 'a'}
self.ae(coalesce_symbol_maps(q), {(1, 4): 'a', (5, 5): 'b'})
q = {(0, 30): 'a', (10, 10): 'b', (11, 11): 'b', (2, 2): 'c', (1, 1): 'c'}
self.ae(coalesce_symbol_maps(q), {
(0, 0): 'a', (1, 2): 'c', (3, 9): 'a', (10, 11): 'b', (12, 30): 'a'})
def test_chars(chars: str = '', sz: int = 128) -> None:
# kitty +runpy "from kitty.fonts.box_drawing import test_chars; test_chars('XXX')"
from kitty.fast_data_types import concat_cells, render_box_char, set_send_sprite_to_gpu
from kitty.fonts.render import display_bitmap, setup_for_testing
if not chars:
import sys
chars = sys.argv[-1]
def as_ord(x: str) -> int:
if x.lower().startswith('u+'):
return int(x[2:], 16)
return ord(x)
if '...' in chars:
start, end = chars.partition('...')[::2]
chars = ''.join(map(chr, range(as_ord(start), as_ord(end)+1)))
with setup_for_testing('monospace', sz) as (_, width, height):
try:
for ch in chars:
nb = render_box_char(as_ord(ch), width, height)
rgb_data = concat_cells(width, height, False, (nb,))
display_bitmap(rgb_data, width, height)
print()
finally:
set_send_sprite_to_gpu(None)
def test_drawing(sz: int = 48, family: str = 'monospace', start: int = 0x2500, num_rows: int = 10, num_cols: int = 16) -> None:
from kitty.fast_data_types import concat_cells, render_box_char, set_send_sprite_to_gpu
from .render import display_bitmap, setup_for_testing
with setup_for_testing(family, sz) as (_, width, height):
space = bytearray(width * height)
def join_cells(cells: Iterable[bytes]) -> bytes:
cells = tuple(bytes(x) for x in cells)
return concat_cells(width, height, False, cells)
def render_chr(ch: str) -> bytearray:
if ch in box_chars:
return bytearray(render_box_char(ord(ch), width, height))
return space
pos = start
rows = []
space_row = join_cells(repeat(space, 32))
try:
for r in range(num_rows):
row = []
for i in range(num_cols):
row.append(render_chr(chr(pos)))
row.append(space)
pos += 1
rows.append(join_cells(row))
rows.append(space_row)
rgb_data = b''.join(rows)
width *= 32
height *= len(rows)
assert len(rgb_data) == width * height * 4, f'{len(rgb_data)} != {width * height * 4}'
display_bitmap(rgb_data, width, height)
finally:
set_send_sprite_to_gpu(None)
box_chars = { # {{{
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'\ue0b0',
'\ue0b1',
'\ue0b2',
'\ue0b3',
'\ue0b4',
'\ue0b5',
'\ue0b6',
'\ue0b7',
'\ue0b8',
'\ue0b9',
'\ue0ba',
'\ue0bb',
'\ue0bc',
'\ue0bd',
'\ue0be',
'\ue0bf',
'\ue0d6',
'\ue0d7',
'\uee00',
'\uee01',
'\uee02',
'\uee03',
'\uee04',
'\uee05',
'\uee06',
'\uee07',
'\uee08',
'\uee09',
'\uee0a',
'\uee0b',
'\uf5d0',
'\uf5d1',
'\uf5d2',
'\uf5d3',
'\uf5d4',
'\uf5d5',
'\uf5d6',
'\uf5d7',
'\uf5d8',
'\uf5d9',
'\uf5da',
'\uf5db',
'\uf5dc',
'\uf5dd',
'\uf5de',
'\uf5df',
'\uf5e0',
'\uf5e1',
'\uf5e2',
'\uf5e3',
'\uf5e4',
'\uf5e5',
'\uf5e6',
'\uf5e7',
'\uf5e8',
'\uf5e9',
'\uf5ea',
'\uf5eb',
'\uf5ec',
'\uf5ed',
'\uf5ee',
'\uf5ef',
'\uf5f0',
'\uf5f1',
'\uf5f2',
'\uf5f3',
'\uf5f4',
'\uf5f5',
'\uf5f6',
'\uf5f7',
'\uf5f8',
'\uf5f9',
'\uf5fa',
'\uf5fb',
'\uf5fc',
'\uf5fd',
'\uf5fe',
'\uf5ff',
'\uf600',
'\uf601',
'\uf602',
'\uf603',
'\uf604',
'\uf605',
'\uf606',
'\uf607',
'\uf608',
'\uf609',
'\uf60a',
'\uf60b',
'\uf60c',
'\uf60d',
'🬀',
'🬁',
'🬂',
'🬃',
'🬄',
'🬅',
'🬆',
'🬇',
'🬈',
'🬉',
'🬊',
'🬋',
'🬌',
'🬍',
'🬎',
'🬏',
'🬐',
'🬑',
'🬒',
'🬓',
'🬔',
'🬕',
'🬖',
'🬗',
'🬘',
'🬙',
'🬚',
'🬛',
'🬜',
'🬝',
'🬞',
'🬟',
'🬠',
'🬡',
'🬢',
'🬣',
'🬤',
'🬥',
'🬦',
'🬧',
'🬨',
'🬩',
'🬪',
'🬫',
'🬬',
'🬭',
'🬮',
'🬯',
'🬰',
'🬱',
'🬲',
'🬳',
'🬴',
'🬵',
'🬶',
'🬷',
'🬸',
'🬹',
'🬺',
'🬻',
'🬼',
'🬽',
'🬾',
'🬿',
'🭀',
'🭁',
'🭂',
'🭃',
'🭄',
'🭅',
'🭆',
'🭇',
'🭈',
'🭉',
'🭊',
'🭋',
'🭌',
'🭍',
'🭎',
'🭏',
'🭐',
'🭑',
'🭒',
'🭓',
'🭔',
'🭕',
'🭖',
'🭗',
'🭘',
'🭙',
'🭚',
'🭛',
'🭜',
'🭝',
'🭞',
'🭟',
'🭠',
'🭡',
'🭢',
'🭣',
'🭤',
'🭥',
'🭦',
'🭧',
'🭨',
'🭩',
'🭪',
'🭫',
'🭬',
'🭭',
'🭮',
'🭯',
'🭰',
'🭱',
'🭲',
'🭳',
'🭴',
'🭵',
'🭶',
'🭷',
'🭸',
'🭹',
'🭺',
'🭻',
'🭼',
'🭽',
'🭾',
'🭿',
'🮀',
'🮁',
'🮂',
'🮃',
'🮄',
'🮅',
'🮆',
'🮇',
'🮈',
'🮉',
'🮊',
'🮋',
'🮌',
'🮍',
'🮎',
'🮏',
'🮐',
'🮑',
'🮒',
'\U0001fb93',
'🮔',
'🮕',
'🮖',
'🮗',
'🮘',
'🮙',
'🮚',
'🮛',
'🮜',
'🮝',
'🮞',
'🮟',
'🮠',
'🮡',
'🮢',
'🮣',
'🮤',
'🮥',
'🮦',
'🮧',
'🮨',
'🮩',
'🮪',
'🮫',
'🮬',
'🮭',
'🮮',
'\U0001fbe6', '\U0001fbe7',
} # }}}
for ch in range(0x1cd00, 0x1cde5+1): # octants
box_chars.add(chr(ch))
for ch in range(0x1fbce, 0x1fbf0): # blocks, diagonals, circles (legacy computing)
box_chars.add(chr(ch))
for ch in range(0x1cc1b, 0x1cc40): # box drawing variants, separated quadrants, circle arcs (supplement)
box_chars.add(chr(ch))
for ch in range(0x1ce16, 0x1ce1a): # box drawings light vertical T-junctions (supplement)
box_chars.add(chr(ch))
for ch in range(0x1ce51, 0x1ceb0): # separated block sextants, sixteenth blocks, quarter parts (supplement)
box_chars.add(chr(ch))