#!/usr/bin/env python3 # License: GPL v3 Copyright: 2016, Kovid Goyal import ctypes import sys from functools import partial from math import ceil, cos, floor, pi from typing import ( TYPE_CHECKING, Any, Callable, Dict, Generator, List, Optional, Tuple, Union, cast ) from kitty.constants import is_macos from kitty.fast_data_types import ( Screen, create_test_font_group, get_fallback_font, set_font_data, set_options, set_send_sprite_to_gpu, sprite_map_set_limits, test_render_line, test_shape, NUM_UNDERLINE_STYLES ) from kitty.fonts.box_drawing import ( BufType, distribute_dots, render_box_char, render_missing_glyph ) from kitty.options.types import Options, defaults from kitty.typing import CoreTextFont, FontConfigPattern from kitty.utils import log_error if is_macos: from .core_text import ( find_font_features, font_for_family as font_for_family_macos, get_font_files as get_font_files_coretext ) else: from .fontconfig import ( find_font_features, font_for_family as font_for_family_fontconfig, get_font_files as get_font_files_fontconfig ) FontObject = Union[CoreTextFont, FontConfigPattern] current_faces: List[Tuple[FontObject, bool, bool]] = [] def get_font_files(opts: Options) -> Dict[str, Any]: if is_macos: return get_font_files_coretext(opts) return get_font_files_fontconfig(opts) def font_for_family(family: str) -> Tuple[FontObject, bool, bool]: if is_macos: return font_for_family_macos(family) return font_for_family_fontconfig(family) Range = Tuple[Tuple[int, int], str] def merge_ranges(a: Range, b: Range, priority_map: Dict[Tuple[int, int], int]) -> Generator[Range, None, None]: a_start, a_end = a[0] b_start, b_end = b[0] a_val, b_val = a[1], b[1] a_prio, b_prio = priority_map[a[0]], priority_map[b[0]] if b_start > a_end: if b_start == a_end + 1 and a_val == b_val: # ranges can be coalesced r = ((a_start, b_end), a_val) priority_map[r[0]] = max(a_prio, b_prio) yield r return # disjoint ranges yield a yield b return if a_val == b_val: # mergeable ranges r = ((a_start, max(a_end, b_end)), a_val) priority_map[r[0]] = max(a_prio, b_prio) yield r return before_range = mid_range = after_range = None before_range_prio = mid_range_prio = after_range_prio = 0 if b_start > a_start: before_range = ((a_start, b_start - 1), a_val) before_range_prio = a_prio mid_end = min(a_end, b_end) if mid_end >= b_start: # overlap range mid_range = ((b_start, mid_end), a_val if priority_map[a[0]] >= priority_map[b[0]] else b_val) mid_range_prio = max(a_prio, b_prio) # after range if mid_end is a_end: if b_end > a_end: after_range = ((a_end + 1, b_end), b_val) after_range_prio = b_prio else: if a_end > b_end: after_range = ((b_end + 1, a_end), a_val) after_range_prio = a_prio # check if the before, mid and after ranges can be coalesced ranges: List[Range] = [] priorities: List[int] = [] for rq, prio in ((before_range, before_range_prio), (mid_range, mid_range_prio), (after_range, after_range_prio)): if rq is None: continue r = rq if ranges: x = ranges[-1] if x[0][1] + 1 == r[0][0] and x[1] == r[1]: ranges[-1] = ((x[0][0], r[0][1]), x[1]) priorities[-1] = max(priorities[-1], prio) else: ranges.append(r) priorities.append(prio) else: ranges.append(r) priorities.append(prio) for r, p in zip(ranges, priorities): priority_map[r[0]] = p yield from ranges def coalesce_symbol_maps(maps: Dict[Tuple[int, int], str]) -> Dict[Tuple[int, int], str]: if not maps: return maps priority_map = {r: i for i, r in enumerate(maps.keys())} ranges = tuple((r, maps[r]) for r in sorted(maps)) ans = [ranges[0]] for i in range(1, len(ranges)): r = ranges[i] new_ranges = merge_ranges(ans[-1], r, priority_map) if ans: del ans[-1] if not ans: ans = list(new_ranges) else: for r in new_ranges: prev = ans[-1] if prev[0][1] + 1 == r[0][0] and prev[1] == r[1]: ans[-1] = (prev[0][0], r[0][1]), prev[1] else: ans.append(r) return dict(ans) def create_symbol_map(opts: Options) -> Tuple[Tuple[int, int, int], ...]: val = coalesce_symbol_maps(opts.symbol_map) family_map: Dict[str, int] = {} count = 0 for family in val.values(): if family not in family_map: font, bold, italic = font_for_family(family) family_map[family] = count count += 1 current_faces.append((font, bold, italic)) sm = tuple((a, b, family_map[f]) for (a, b), f in val.items()) return sm def descriptor_for_idx(idx: int) -> Tuple[FontObject, bool, bool]: return current_faces[idx] def dump_faces(ftypes: List[str], indices: Dict[str, int]) -> None: def face_str(f: Tuple[FontObject, bool, bool]) -> str: fo = f[0] if 'index' in fo: return '{}:{}'.format(fo['path'], cast('FontConfigPattern', fo)['index']) fo = cast('CoreTextFont', fo) return fo['path'] log_error('Preloaded font faces:') log_error('normal face:', face_str(current_faces[0])) for ftype in ftypes: if indices[ftype]: log_error(ftype, 'face:', face_str(current_faces[indices[ftype]])) si_faces = current_faces[max(indices.values())+1:] if si_faces: log_error('Symbol map faces:') for face in si_faces: log_error(face_str(face)) def set_font_family(opts: Optional[Options] = None, override_font_size: Optional[float] = None, debug_font_matching: bool = False) -> None: global current_faces opts = opts or defaults sz = override_font_size or opts.font_size font_map = get_font_files(opts) current_faces = [(font_map['medium'], False, False)] ftypes = 'bold italic bi'.split() indices = {k: 0 for k in ftypes} for k in ftypes: if k in font_map: indices[k] = len(current_faces) current_faces.append((font_map[k], 'b' in k, 'i' in k)) before = len(current_faces) sm = create_symbol_map(opts) num_symbol_fonts = len(current_faces) - before font_features = {} for face, _, _ in current_faces: font_features[face['postscript_name']] = find_font_features(face['postscript_name']) font_features.update(opts.font_features) if debug_font_matching: dump_faces(ftypes, indices) set_font_data( render_box_drawing, prerender_function, descriptor_for_idx, indices['bold'], indices['italic'], indices['bi'], num_symbol_fonts, sm, sz, font_features ) if TYPE_CHECKING: CBufType = ctypes.Array[ctypes.c_ubyte] else: CBufType = None UnderlineCallback = Callable[[CBufType, int, int, int, int], None] def add_line(buf: CBufType, cell_width: int, position: int, thickness: int, cell_height: int) -> None: y = position - thickness // 2 while thickness > 0 and -1 < y < cell_height: thickness -= 1 ctypes.memset(ctypes.addressof(buf) + (cell_width * y), 255, cell_width) y += 1 def add_dline(buf: CBufType, cell_width: int, position: int, thickness: int, cell_height: int) -> None: a = min(position - thickness, cell_height - 1) b = min(position, cell_height - 1) top, bottom = min(a, b), max(a, b) deficit = 2 - (bottom - top) if deficit > 0: if bottom + deficit < cell_height: bottom += deficit elif bottom < cell_height - 1: bottom += 1 if deficit > 1: top -= deficit - 1 else: top -= deficit top = max(0, min(top, cell_height - 1)) bottom = max(0, min(bottom, cell_height - 1)) for y in {top, bottom}: ctypes.memset(ctypes.addressof(buf) + (cell_width * y), 255, cell_width) def add_curl(buf: CBufType, cell_width: int, position: int, thickness: int, cell_height: int) -> None: max_x, max_y = cell_width - 1, cell_height - 1 xfactor = 2.0 * pi / max_x thickness = max(1, thickness) if thickness < 3: half_height = thickness thickness -= 1 elif thickness == 3: half_height = thickness = 2 else: half_height = thickness // 2 thickness -= 2 def add_intensity(x: int, y: int, val: int) -> None: y += position y = min(y, max_y) idx = cell_width * y + x buf[idx] = min(255, buf[idx] + val) # Ensure curve doesn't exceed cell boundary at the bottom position += half_height * 2 if position + half_height > max_y: position = max_y - half_height # Use the Wu antialias algorithm to draw the curve # cosine waves always have slope <= 1 so are never steep for x in range(cell_width): y = half_height * cos(x * xfactor) y1, y2 = floor(y - thickness), ceil(y) i1 = int(255 * abs(y - floor(y))) add_intensity(x, y1, 255 - i1) # upper bound add_intensity(x, y2, i1) # lower bound # fill between upper and lower bound for t in range(1, thickness + 1): add_intensity(x, y1 + t, 255) def add_dots(buf: CBufType, cell_width: int, position: int, thickness: int, cell_height: int) -> None: spacing, size = distribute_dots(cell_width, cell_width // (2 * thickness)) y = 1 + position - thickness // 2 for i in range(y, min(y + thickness, cell_height)): for j, s in enumerate(spacing): buf[cell_width * i + j * size + s: cell_width * i + (j + 1) * size + s] = [255] * size def add_dashes(buf: CBufType, cell_width: int, position: int, thickness: int, cell_height: int) -> None: halfspace_width = cell_width // 4 y = 1 + position - thickness // 2 for i in range(y, min(y + thickness, cell_height)): buf[cell_width * i:cell_width * i + (cell_width - 3 * halfspace_width)] = [255] * (cell_width - 3 * halfspace_width) buf[cell_width * i + 3 * halfspace_width:cell_width * (i + 1)] = [255] * (cell_width - 3 * halfspace_width) def render_special( underline: int = 0, strikethrough: bool = False, missing: bool = False, cell_width: int = 0, cell_height: int = 0, baseline: int = 0, underline_position: int = 0, underline_thickness: int = 0, strikethrough_position: int = 0, strikethrough_thickness: int = 0, dpi_x: float = 96., dpi_y: float = 96., ) -> CBufType: underline_position = min(underline_position, cell_height - underline_thickness) CharTexture = ctypes.c_ubyte * (cell_width * cell_height) if missing: buf = bytearray(cell_width * cell_height) render_missing_glyph(buf, cell_width, cell_height) return CharTexture.from_buffer(buf) ans = CharTexture() def dl(f: UnderlineCallback, *a: Any) -> None: try: f(ans, cell_width, *a) except Exception as e: log_error(f'Failed to render {f.__name__} at cell_width={cell_width} and cell_height={cell_height} with error: {e}') if underline: t = underline_thickness if underline > 1: t = max(1, min(cell_height - underline_position - 1, t)) dl([add_line, add_line, add_dline, add_curl, add_dots, add_dashes][underline], underline_position, t, cell_height) if strikethrough: dl(add_line, strikethrough_position, strikethrough_thickness, cell_height) return ans def render_cursor( which: int, cursor_beam_thickness: float, cursor_underline_thickness: float, cell_width: int = 0, cell_height: int = 0, dpi_x: float = 0, dpi_y: float = 0 ) -> CBufType: CharTexture = ctypes.c_ubyte * (cell_width * cell_height) ans = CharTexture() def vert(edge: str, width_pt: float = 1) -> None: width = max(1, min(int(round(width_pt * dpi_x / 72.0)), cell_width)) left = 0 if edge == 'left' else max(0, cell_width - width) for y in range(cell_height): offset = y * cell_width + left for x in range(offset, offset + width): ans[x] = 255 def horz(edge: str, height_pt: float = 1) -> None: height = max(1, min(int(round(height_pt * dpi_y / 72.0)), cell_height)) top = 0 if edge == 'top' else max(0, cell_height - height) for y in range(top, top + height): offset = y * cell_width for x in range(cell_width): ans[offset + x] = 255 if which == 1: # beam vert('left', cursor_beam_thickness) elif which == 2: # underline horz('bottom', cursor_underline_thickness) elif which == 3: # hollow vert('left') vert('right') horz('top') horz('bottom') return ans def prerender_function( cell_width: int, cell_height: int, baseline: int, underline_position: int, underline_thickness: int, strikethrough_position: int, strikethrough_thickness: int, cursor_beam_thickness: float, cursor_underline_thickness: float, dpi_x: float, dpi_y: float ) -> Tuple[Tuple[int, ...], Tuple[CBufType, ...]]: # Pre-render the special underline, strikethrough and missing and cursor cells f = partial( render_special, cell_width=cell_width, cell_height=cell_height, baseline=baseline, underline_position=underline_position, underline_thickness=underline_thickness, strikethrough_position=strikethrough_position, strikethrough_thickness=strikethrough_thickness, dpi_x=dpi_x, dpi_y=dpi_y ) c = partial( render_cursor, cursor_beam_thickness=cursor_beam_thickness, cursor_underline_thickness=cursor_underline_thickness, cell_width=cell_width, cell_height=cell_height, dpi_x=dpi_x, dpi_y=dpi_y) # If you change the mapping of these cells you will need to change # NUM_UNDERLINE_STYLES and BEAM_IDX in shader.c and STRIKE_SPRITE_INDEX in # window.py and MISSING_GLYPH in font.c cells = list(map(f, range(1, NUM_UNDERLINE_STYLES + 1))) # underline sprites cells.append(f(0, strikethrough=True)) # strikethrough sprite cells.append(f(missing=True)) # missing glyph cells.extend((c(1), c(2), c(3))) # cursor glyphs tcells = tuple(cells) return tuple(map(ctypes.addressof, tcells)), tcells def render_box_drawing(codepoint: int, cell_width: int, cell_height: int, dpi: float) -> Tuple[int, CBufType]: CharTexture = ctypes.c_ubyte * (cell_width * cell_height) buf = CharTexture() render_box_char( chr(codepoint), cast(BufType, buf), cell_width, cell_height, dpi ) return ctypes.addressof(buf), buf class setup_for_testing: def __init__(self, family: str = 'monospace', size: float = 11.0, dpi: float = 96.0): self.family, self.size, self.dpi = family, size, dpi def __enter__(self) -> Tuple[Dict[Tuple[int, int, int], bytes], int, int]: opts = defaults._replace(font_family=self.family, font_size=self.size) set_options(opts) sprites = {} def send_to_gpu(x: int, y: int, z: int, data: bytes) -> None: sprites[(x, y, z)] = data sprite_map_set_limits(100000, 100) set_send_sprite_to_gpu(send_to_gpu) try: set_font_family(opts) cell_width, cell_height = create_test_font_group(self.size, self.dpi, self.dpi) return sprites, cell_width, cell_height except Exception: set_send_sprite_to_gpu(None) raise def __exit__(self, *args: Any) -> None: set_send_sprite_to_gpu(None) def render_string(text: str, family: str = 'monospace', size: float = 11.0, dpi: float = 96.0) -> Tuple[int, int, List[bytes]]: with setup_for_testing(family, size, dpi) as (sprites, cell_width, cell_height): s = Screen(None, 1, len(text)*2) line = s.line(0) s.draw(text) test_render_line(line) cells = [] found_content = False for i in reversed(range(s.columns)): sp = list(line.sprite_at(i)) sp[2] &= 0xfff tsp = sp[0], sp[1], sp[2] if tsp == (0, 0, 0) and not found_content: continue found_content = True cells.append(sprites[tsp]) return cell_width, cell_height, list(reversed(cells)) def shape_string( text: str = "abcd", family: str = 'monospace', size: float = 11.0, dpi: float = 96.0, path: Optional[str] = None ) -> List[Tuple[int, int, int, Tuple[int, ...]]]: with setup_for_testing(family, size, dpi) as (sprites, cell_width, cell_height): s = Screen(None, 1, len(text)*2) line = s.line(0) s.draw(text) return test_shape(line, path) def display_bitmap(rgb_data: bytes, width: int, height: int) -> None: from tempfile import NamedTemporaryFile from kittens.icat.main import detect_support, show if not hasattr(display_bitmap, 'detected') and not detect_support(): raise SystemExit('Your terminal does not support the graphics protocol') setattr(display_bitmap, 'detected', True) with NamedTemporaryFile(suffix='.rgba', delete=False) as f: f.write(rgb_data) assert len(rgb_data) == 4 * width * height show(f.name, width, height, 0, 32, align='left') def test_render_string( text: str = 'Hello, world!', family: str = 'monospace', size: float = 64.0, dpi: float = 96.0 ) -> None: from kitty.fast_data_types import concat_cells, current_fonts cell_width, cell_height, cells = render_string(text, family, size, dpi) rgb_data = concat_cells(cell_width, cell_height, True, tuple(cells)) cf = current_fonts() fonts = [cf['medium'].display_name()] fonts.extend(f.display_name() for f in cf['fallback']) msg = 'Rendered string {} below, with fonts: {}\n'.format(text, ', '.join(fonts)) try: print(msg) except UnicodeEncodeError: sys.stdout.buffer.write(msg.encode('utf-8') + b'\n') display_bitmap(rgb_data, cell_width * len(cells), cell_height) print('\n') def test_fallback_font(qtext: Optional[str] = None, bold: bool = False, italic: bool = False) -> None: with setup_for_testing(): if qtext: trials = [qtext] else: trials = ['你', 'He\u0347\u0305', '\U0001F929'] for text in trials: f = get_fallback_font(text, bold, italic) try: print(text, f) except UnicodeEncodeError: sys.stdout.buffer.write(f'{text} {f}\n'.encode('utf-8')) def showcase() -> None: f = 'monospace' if is_macos else 'Liberation Mono' test_render_string('He\u0347\u0305llo\u0337, w\u0302or\u0306l\u0354d!', family=f) test_render_string('你好,世界', family=f) test_render_string('│😁│🙏│😺│', family=f) test_render_string('A=>>B!=C', family='Fira Code')