1#!/usr/local/bin/python3.8
2# vim:fileencoding=utf-8
3# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
4
5import ctypes
6import sys
7from functools import partial
8from math import ceil, cos, floor, pi
9from typing import (
10    Any, Callable, Dict, Generator, List, Optional, Tuple, Union, cast
11)
12
13from kitty.constants import is_macos
14from kitty.fast_data_types import (
15    Screen, create_test_font_group, get_fallback_font, set_font_data,
16    set_options, set_send_sprite_to_gpu, sprite_map_set_limits,
17    test_render_line, test_shape
18)
19from kitty.fonts.box_drawing import (
20    BufType, render_box_char, render_missing_glyph
21)
22from kitty.options.types import Options, defaults
23from kitty.typing import CoreTextFont, FontConfigPattern
24from kitty.utils import log_error
25
26if is_macos:
27    from .core_text import get_font_files as get_font_files_coretext, font_for_family as font_for_family_macos, find_font_features
28else:
29    from .fontconfig import get_font_files as get_font_files_fontconfig, font_for_family as font_for_family_fontconfig, find_font_features
30
31FontObject = Union[CoreTextFont, FontConfigPattern]
32current_faces: List[Tuple[FontObject, bool, bool]] = []
33
34
35def get_font_files(opts: Options) -> Dict[str, Any]:
36    if is_macos:
37        return get_font_files_coretext(opts)
38    return get_font_files_fontconfig(opts)
39
40
41def font_for_family(family: str) -> Tuple[FontObject, bool, bool]:
42    if is_macos:
43        return font_for_family_macos(family)
44    return font_for_family_fontconfig(family)
45
46
47Range = Tuple[Tuple[int, int], str]
48
49
50def merge_ranges(a: Range, b: Range, priority_map: Dict[Tuple[int, int], int]) -> Generator[Range, None, None]:
51    a_start, a_end = a[0]
52    b_start, b_end = b[0]
53    a_val, b_val = a[1], b[1]
54    a_prio, b_prio = priority_map[a[0]], priority_map[b[0]]
55    if b_start > a_end:
56        if b_start == a_end + 1 and a_val == b_val:
57            # ranges can be coalesced
58            r = ((a_start, b_end), a_val)
59            priority_map[r[0]] = max(a_prio, b_prio)
60            yield r
61            return
62        # disjoint ranges
63        yield a
64        yield b
65        return
66    if a_val == b_val:
67        # mergeable ranges
68        r = ((a_start, max(a_end, b_end)), a_val)
69        priority_map[r[0]] = max(a_prio, b_prio)
70        yield r
71        return
72    before_range = mid_range = after_range = None
73    before_range_prio = mid_range_prio = after_range_prio = 0
74    if b_start > a_start:
75        before_range = ((a_start, b_start - 1), a_val)
76        before_range_prio = a_prio
77    mid_end = min(a_end, b_end)
78    if mid_end >= b_start:
79        # overlap range
80        mid_range = ((b_start, mid_end), a_val if priority_map[a[0]] >= priority_map[b[0]] else b_val)
81        mid_range_prio = max(a_prio, b_prio)
82    # after range
83    if mid_end is a_end:
84        if b_end > a_end:
85            after_range = ((a_end + 1, b_end), b_val)
86            after_range_prio = b_prio
87    else:
88        if a_end > b_end:
89            after_range = ((b_end + 1, a_end), a_val)
90            after_range_prio = a_prio
91    # check if the before, mid and after ranges can be coalesced
92    ranges: List[Range] = []
93    priorities: List[int] = []
94    for rq, prio in ((before_range, before_range_prio), (mid_range, mid_range_prio), (after_range, after_range_prio)):
95        if rq is None:
96            continue
97        r = rq
98        if ranges:
99            x = ranges[-1]
100            if x[0][1] + 1 == r[0][0] and x[1] == r[1]:
101                ranges[-1] = ((x[0][0], r[0][1]), x[1])
102                priorities[-1] = max(priorities[-1], prio)
103            else:
104                ranges.append(r)
105                priorities.append(prio)
106        else:
107            ranges.append(r)
108            priorities.append(prio)
109    for r, p in zip(ranges, priorities):
110        priority_map[r[0]] = p
111    yield from ranges
112
113
114def coalesce_symbol_maps(maps: Dict[Tuple[int, int], str]) -> Dict[Tuple[int, int], str]:
115    if not maps:
116        return maps
117    priority_map = {r: i for i, r in enumerate(maps.keys())}
118    ranges = tuple((r, maps[r]) for r in sorted(maps))
119    ans = [ranges[0]]
120
121    for i in range(1, len(ranges)):
122        r = ranges[i]
123        new_ranges = merge_ranges(ans[-1], r, priority_map)
124        if ans:
125            del ans[-1]
126        if not ans:
127            ans = list(new_ranges)
128        else:
129            for r in new_ranges:
130                prev = ans[-1]
131                if prev[0][1] + 1 == r[0][0] and prev[1] == r[1]:
132                    ans[-1] = (prev[0][0], r[0][1]), prev[1]
133                else:
134                    ans.append(r)
135    return dict(ans)
136
137
138def create_symbol_map(opts: Options) -> Tuple[Tuple[int, int, int], ...]:
139    val = coalesce_symbol_maps(opts.symbol_map)
140    family_map: Dict[str, int] = {}
141    count = 0
142    for family in val.values():
143        if family not in family_map:
144            font, bold, italic = font_for_family(family)
145            family_map[family] = count
146            count += 1
147            current_faces.append((font, bold, italic))
148    sm = tuple((a, b, family_map[f]) for (a, b), f in val.items())
149    return sm
150
151
152def descriptor_for_idx(idx: int) -> Tuple[FontObject, bool, bool]:
153    return current_faces[idx]
154
155
156def dump_faces(ftypes: List[str], indices: Dict[str, int]) -> None:
157    def face_str(f: Tuple[FontObject, bool, bool]) -> str:
158        fo = f[0]
159        if 'index' in fo:
160            return '{}:{}'.format(fo['path'], cast('FontConfigPattern', fo)['index'])
161        fo = cast('CoreTextFont', fo)
162        return fo['path']
163
164    log_error('Preloaded font faces:')
165    log_error('normal face:', face_str(current_faces[0]))
166    for ftype in ftypes:
167        if indices[ftype]:
168            log_error(ftype, 'face:', face_str(current_faces[indices[ftype]]))
169    si_faces = current_faces[max(indices.values())+1:]
170    if si_faces:
171        log_error('Symbol map faces:')
172        for face in si_faces:
173            log_error(face_str(face))
174
175
176def set_font_family(opts: Optional[Options] = None, override_font_size: Optional[float] = None, debug_font_matching: bool = False) -> None:
177    global current_faces
178    opts = opts or defaults
179    sz = override_font_size or opts.font_size
180    font_map = get_font_files(opts)
181    current_faces = [(font_map['medium'], False, False)]
182    ftypes = 'bold italic bi'.split()
183    indices = {k: 0 for k in ftypes}
184    for k in ftypes:
185        if k in font_map:
186            indices[k] = len(current_faces)
187            current_faces.append((font_map[k], 'b' in k, 'i' in k))
188    before = len(current_faces)
189    sm = create_symbol_map(opts)
190    num_symbol_fonts = len(current_faces) - before
191    font_features = {}
192    for face, _, _ in current_faces:
193        font_features[face['postscript_name']] = find_font_features(face['postscript_name'])
194    font_features.update(opts.font_features)
195    if debug_font_matching:
196        dump_faces(ftypes, indices)
197    set_font_data(
198        render_box_drawing, prerender_function, descriptor_for_idx,
199        indices['bold'], indices['italic'], indices['bi'], num_symbol_fonts,
200        sm, sz, font_features
201    )
202
203
204UnderlineCallback = Callable[[ctypes.Array, int, int, int, int], None]
205
206
207def add_line(buf: ctypes.Array, cell_width: int, position: int, thickness: int, cell_height: int) -> None:
208    y = position - thickness // 2
209    while thickness > 0 and -1 < y < cell_height:
210        thickness -= 1
211        ctypes.memset(ctypes.addressof(buf) + (cell_width * y), 255, cell_width)
212        y += 1
213
214
215def add_dline(buf: ctypes.Array, cell_width: int, position: int, thickness: int, cell_height: int) -> None:
216    a = min(position - thickness, cell_height - 1)
217    b = min(position, cell_height - 1)
218    top, bottom = min(a, b), max(a, b)
219    deficit = 2 - (bottom - top)
220    if deficit > 0:
221        if bottom + deficit < cell_height:
222            bottom += deficit
223        elif bottom < cell_height - 1:
224            bottom += 1
225            if deficit > 1:
226                top -= deficit - 1
227        else:
228            top -= deficit
229    top = max(0, min(top, cell_height - 1))
230    bottom = max(0, min(bottom, cell_height - 1))
231    for y in {top, bottom}:
232        ctypes.memset(ctypes.addressof(buf) + (cell_width * y), 255, cell_width)
233
234
235def add_curl(buf: ctypes.Array, cell_width: int, position: int, thickness: int, cell_height: int) -> None:
236    max_x, max_y = cell_width - 1, cell_height - 1
237    xfactor = 2.0 * pi / max_x
238    thickness = max(1, thickness)
239    if thickness < 3:
240        half_height = thickness
241        thickness -= 1
242    elif thickness == 3:
243        half_height = thickness = 2
244    else:
245        half_height = thickness // 2
246        thickness -= 2
247
248    def add_intensity(x: int, y: int, val: int) -> None:
249        y += position
250        y = min(y, max_y)
251        idx = cell_width * y + x
252        buf[idx] = min(255, buf[idx] + val)
253
254    # Ensure curve doesn't exceed cell boundary at the bottom
255    position += half_height * 2
256    if position + half_height > max_y:
257        position = max_y - half_height
258
259    # Use the Wu antialias algorithm to draw the curve
260    # cosine waves always have slope <= 1 so are never steep
261    for x in range(cell_width):
262        y = half_height * cos(x * xfactor)
263        y1, y2 = floor(y - thickness), ceil(y)
264        i1 = int(255 * abs(y - floor(y)))
265        add_intensity(x, y1, 255 - i1)  # upper bound
266        add_intensity(x, y2, i1)  # lower bound
267        # fill between upper and lower bound
268        for t in range(1, thickness + 1):
269            add_intensity(x, y1 + t, 255)
270
271
272def render_special(
273    underline: int = 0,
274    strikethrough: bool = False,
275    missing: bool = False,
276    cell_width: int = 0, cell_height: int = 0,
277    baseline: int = 0,
278    underline_position: int = 0,
279    underline_thickness: int = 0,
280    strikethrough_position: int = 0,
281    strikethrough_thickness: int = 0,
282    dpi_x: float = 96.,
283    dpi_y: float = 96.,
284) -> ctypes.Array:
285    underline_position = min(underline_position, cell_height - underline_thickness)
286    CharTexture = ctypes.c_ubyte * (cell_width * cell_height)
287
288    if missing:
289        buf = bytearray(cell_width * cell_height)
290        render_missing_glyph(buf, cell_width, cell_height)
291        return CharTexture.from_buffer(buf)
292
293    ans = CharTexture()
294
295    def dl(f: UnderlineCallback, *a: Any) -> None:
296        try:
297            f(ans, cell_width, *a)
298        except Exception as e:
299            log_error('Failed to render {} at cell_width={} and cell_height={} with error: {}'.format(
300                f.__name__, cell_width, cell_height, e))
301
302    if underline:
303        t = underline_thickness
304        if underline > 1:
305            t = max(1, min(cell_height - underline_position - 1, t))
306        dl([add_line, add_line, add_dline, add_curl][underline], underline_position, t, cell_height)
307    if strikethrough:
308        dl(add_line, strikethrough_position, strikethrough_thickness, cell_height)
309
310    return ans
311
312
313def render_cursor(
314    which: int,
315    cursor_beam_thickness: float,
316    cursor_underline_thickness: float,
317    cell_width: int = 0,
318    cell_height: int = 0,
319    dpi_x: float = 0,
320    dpi_y: float = 0
321) -> ctypes.Array:
322    CharTexture = ctypes.c_ubyte * (cell_width * cell_height)
323    ans = CharTexture()
324
325    def vert(edge: str, width_pt: float = 1) -> None:
326        width = max(1, min(int(round(width_pt * dpi_x / 72.0)), cell_width))
327        left = 0 if edge == 'left' else max(0, cell_width - width)
328        for y in range(cell_height):
329            offset = y * cell_width + left
330            for x in range(offset, offset + width):
331                ans[x] = 255
332
333    def horz(edge: str, height_pt: float = 1) -> None:
334        height = max(1, min(int(round(height_pt * dpi_y / 72.0)), cell_height))
335        top = 0 if edge == 'top' else max(0, cell_height - height)
336        for y in range(top, top + height):
337            offset = y * cell_width
338            for x in range(cell_width):
339                ans[offset + x] = 255
340
341    if which == 1:  # beam
342        vert('left', cursor_beam_thickness)
343    elif which == 2:  # underline
344        horz('bottom', cursor_underline_thickness)
345    elif which == 3:  # hollow
346        vert('left')
347        vert('right')
348        horz('top')
349        horz('bottom')
350    return ans
351
352
353def prerender_function(
354    cell_width: int,
355    cell_height: int,
356    baseline: int,
357    underline_position: int,
358    underline_thickness: int,
359    strikethrough_position: int,
360    strikethrough_thickness: int,
361    cursor_beam_thickness: float,
362    cursor_underline_thickness: float,
363    dpi_x: float,
364    dpi_y: float
365) -> Tuple[Union[int, ctypes.Array], ...]:
366    # Pre-render the special underline, strikethrough and missing and cursor cells
367    f = partial(
368        render_special, cell_width=cell_width, cell_height=cell_height, baseline=baseline,
369        underline_position=underline_position, underline_thickness=underline_thickness,
370        strikethrough_position=strikethrough_position, strikethrough_thickness=strikethrough_thickness,
371        dpi_x=dpi_x, dpi_y=dpi_y
372    )
373    c = partial(
374        render_cursor, cursor_beam_thickness=cursor_beam_thickness,
375        cursor_underline_thickness=cursor_underline_thickness, cell_width=cell_width,
376        cell_height=cell_height, dpi_x=dpi_x, dpi_y=dpi_y)
377    cells = f(1), f(2), f(3), f(0, True), f(missing=True), c(1), c(2), c(3)
378    return tuple(map(ctypes.addressof, cells)) + (cells,)
379
380
381def render_box_drawing(codepoint: int, cell_width: int, cell_height: int, dpi: float) -> Tuple[int, ctypes.Array]:
382    CharTexture = ctypes.c_ubyte * (cell_width * cell_height)
383    buf = CharTexture()
384    render_box_char(
385        chr(codepoint), cast(BufType, buf), cell_width, cell_height, dpi
386    )
387    return ctypes.addressof(buf), buf
388
389
390class setup_for_testing:
391
392    def __init__(self, family: str = 'monospace', size: float = 11.0, dpi: float = 96.0):
393        self.family, self.size, self.dpi = family, size, dpi
394
395    def __enter__(self) -> Tuple[Dict[Tuple[int, int, int], bytes], int, int]:
396        opts = defaults._replace(font_family=self.family, font_size=self.size)
397        set_options(opts)
398        sprites = {}
399
400        def send_to_gpu(x: int, y: int, z: int, data: bytes) -> None:
401            sprites[(x, y, z)] = data
402
403        sprite_map_set_limits(100000, 100)
404        set_send_sprite_to_gpu(send_to_gpu)
405        try:
406            set_font_family(opts)
407            cell_width, cell_height = create_test_font_group(self.size, self.dpi, self.dpi)
408            return sprites, cell_width, cell_height
409        except Exception:
410            set_send_sprite_to_gpu(None)
411            raise
412
413    def __exit__(self, *args: Any) -> None:
414        set_send_sprite_to_gpu(None)
415
416
417def render_string(text: str, family: str = 'monospace', size: float = 11.0, dpi: float = 96.0) -> Tuple[int, int, List[bytes]]:
418    with setup_for_testing(family, size, dpi) as (sprites, cell_width, cell_height):
419        s = Screen(None, 1, len(text)*2)
420        line = s.line(0)
421        s.draw(text)
422        test_render_line(line)
423    cells = []
424    found_content = False
425    for i in reversed(range(s.columns)):
426        sp = list(line.sprite_at(i))
427        sp[2] &= 0xfff
428        tsp = sp[0], sp[1], sp[2]
429        if tsp == (0, 0, 0) and not found_content:
430            continue
431        found_content = True
432        cells.append(sprites[tsp])
433    return cell_width, cell_height, list(reversed(cells))
434
435
436def shape_string(
437    text: str = "abcd", family: str = 'monospace', size: float = 11.0, dpi: float = 96.0, path: Optional[str] = None
438) -> List[Tuple[int, int, int, Tuple[int, ...]]]:
439    with setup_for_testing(family, size, dpi) as (sprites, cell_width, cell_height):
440        s = Screen(None, 1, len(text)*2)
441        line = s.line(0)
442        s.draw(text)
443        return test_shape(line, path)
444
445
446def display_bitmap(rgb_data: bytes, width: int, height: int) -> None:
447    from tempfile import NamedTemporaryFile
448    from kittens.icat.main import detect_support, show
449    if not hasattr(display_bitmap, 'detected') and not detect_support():
450        raise SystemExit('Your terminal does not support the graphics protocol')
451    setattr(display_bitmap, 'detected', True)
452    with NamedTemporaryFile(suffix='.rgba', delete=False) as f:
453        f.write(rgb_data)
454    assert len(rgb_data) == 4 * width * height
455    show(f.name, width, height, 0, 32, align='left')
456
457
458def test_render_string(
459        text: str = 'Hello, world!',
460        family: str = 'monospace',
461        size: float = 64.0,
462        dpi: float = 96.0
463) -> None:
464    from kitty.fast_data_types import concat_cells, current_fonts
465
466    cell_width, cell_height, cells = render_string(text, family, size, dpi)
467    rgb_data = concat_cells(cell_width, cell_height, True, tuple(cells))
468    cf = current_fonts()
469    fonts = [cf['medium'].display_name()]
470    fonts.extend(f.display_name() for f in cf['fallback'])
471    msg = 'Rendered string {} below, with fonts: {}\n'.format(text, ', '.join(fonts))
472    try:
473        print(msg)
474    except UnicodeEncodeError:
475        sys.stdout.buffer.write(msg.encode('utf-8') + b'\n')
476    display_bitmap(rgb_data, cell_width * len(cells), cell_height)
477    print('\n')
478
479
480def test_fallback_font(qtext: Optional[str] = None, bold: bool = False, italic: bool = False) -> None:
481    with setup_for_testing():
482        if qtext:
483            trials = [qtext]
484        else:
485            trials = ['你', 'He\u0347\u0305', '\U0001F929']
486        for text in trials:
487            f = get_fallback_font(text, bold, italic)
488            try:
489                print(text, f)
490            except UnicodeEncodeError:
491                sys.stdout.buffer.write((text + ' %s\n' % f).encode('utf-8'))
492
493
494def showcase() -> None:
495    f = 'monospace' if is_macos else 'Liberation Mono'
496    test_render_string('He\u0347\u0305llo\u0337, w\u0302or\u0306l\u0354d!', family=f)
497    test_render_string('你好,世界', family=f)
498    test_render_string('│��│��│��│', family=f)
499    test_render_string('A=>>B!=C', family='Fira Code')
500