Dashed underline looks pretty good regardless of conditions, but the dotted underline only looks good/correct on certain font-sizes. This is due to the underline being rendered on a per cell/glyph basis (so one can not place a dot directly between two letters, say. Could be remedied by pulling the rendering of the underlines into the shader, but that is more work.
526 lines
18 KiB
Python
526 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
|
|
|
|
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
|
|
)
|
|
from kitty.fonts.box_drawing import (
|
|
BufType, 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:
|
|
y = 1 + position - thickness // 2
|
|
for i in range(y, min(y + thickness, cell_height)):
|
|
for j in range(0, cell_width, 2 * thickness):
|
|
buf[cell_width * i + j:cell_width * i + min(j + thickness, cell_width)] = [255] * min(thickness, cell_width - j)
|
|
|
|
|
|
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('Failed to render {} at cell_width={} and cell_height={} with error: {}'.format(
|
|
f.__name__, cell_width, cell_height, 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)
|
|
cells = f(1), f(2), f(3), f(4), f(5), f(0, True), f(missing=True), c(1), c(2), c(3)
|
|
return tuple(map(ctypes.addressof, cells)), cells
|
|
|
|
|
|
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((text + ' %s\n' % f).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')
|