#!/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))
