Refactor font selection code to share more between fontconfig and CoreText

This commit is contained in:
Kovid Goyal 2024-04-25 21:40:19 +05:30
parent 6d7c54bcb2
commit f5ae6fe152
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
8 changed files with 207 additions and 169 deletions

View file

@ -1,6 +1,6 @@
import termios
from ctypes import Array, c_ubyte
from typing import Any, Callable, Dict, Iterator, List, Literal, NewType, NotRequired, Optional, Tuple, TypedDict, Union, overload
from typing import Any, Callable, Dict, Iterator, List, Literal, NewType, Optional, Tuple, TypedDict, Union, overload
from kitty.boss import Boss
from kitty.fonts import FontFeature, VariableData
@ -8,7 +8,7 @@ from kitty.fonts.render import FontObject
from kitty.marks import MarkerFunc
from kitty.options.types import Options
from kitty.types import LayerShellConfig, SignalInfo
from kitty.typing import EdgeLiteral
from kitty.typing import EdgeLiteral, NotRequired
# Constants {{{
GLFW_LAYER_SHELL_NONE: int
@ -447,6 +447,10 @@ class CoreTextFont(TypedDict):
slant: float
traits: int
# The following two are used by C code to get a face from the pattern
named_style: NotRequired[int]
axes: NotRequired[List[float]]
class CTFace:
def __init__(self, descriptor: Optional[CoreTextFont] = None, path: str = ''): ...

View file

@ -1,5 +1,5 @@
from enum import Enum, IntEnum, auto
from typing import Dict, NamedTuple, Tuple, TypedDict, Union
from typing import Callable, Dict, NamedTuple, Tuple, TypedDict, Union
from kitty.typing import CoreTextFont, FontConfigPattern
@ -99,3 +99,18 @@ class FontSpec(NamedTuple):
return self.system == 'auto'
class Score(NamedTuple):
variable_score: int
style_score: int
monospace_score: int
width_score: int
weight_distance_from_medium: float = 0
Descriptor = Union[FontConfigPattern, CoreTextFont]
Scorer = Callable[[Descriptor], Score]
def family_name_to_key(family: str) -> str:
import re
return re.sub(r'\s+', ' ', family.lower())

View file

@ -1,28 +1,41 @@
#!/usr/bin/env python
# License: GPLv3 Copyright: 2024, Kovid Goyal <kovid at kovidgoyal.net>
from typing import TYPE_CHECKING, Dict, Union
from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Sequence, Union
from kitty.constants import is_macos
from kitty.options.types import Options
from . import VariableData
from . import Descriptor, FontSpec, Scorer, VariableData, family_name_to_key
if TYPE_CHECKING:
from kitty.fast_data_types import CoreTextFont, CTFace, FontConfigPattern
from kitty.fast_data_types import CTFace
from kitty.fast_data_types import Face as FT_Face
Descriptor = Union[FontConfigPattern, CoreTextFont]
FontCollectionMapType = Literal['family_map', 'ps_map', 'full_map']
FontMap = Dict[FontCollectionMapType, Dict[str, List[Descriptor]]]
def Face(descriptor: Descriptor) -> Union[FT_Face, CTFace]:
pass
def all_fonts_map(monospaced: bool) -> FontMap: ...
def create_scorer(bold: bool = False, italic: bool = False, monospaced: bool = True, prefer_variable: bool = False) -> Scorer: ...
def find_best_match(
family: str, bold: bool = False, italic: bool = False, monospaced: bool = True, ignore_face: Optional[Descriptor] = None
) -> Descriptor: ...
def find_last_resort_text_font(bold: bool = False, italic: bool = False, monospaced: bool = True) -> Descriptor: ...
else:
Descriptor = object
FontCollectionMapType = FontMap = None
if is_macos:
from kitty.fast_data_types import CTFace as Face
from .core_text import all_fonts_map, create_scorer, find_best_match, find_last_resort_text_font
else:
from kitty.fast_data_types import Face
from .fontconfig import all_fonts_map, create_scorer, find_best_match, find_last_resort_text_font
cache_for_variable_data_by_path: Dict[str, VariableData] = {}
attr_map = {(False, False): 'font_family', (True, False): 'bold_font', (False, True): 'italic_font', (True, True): 'bold_italic_font'}
def get_variable_data_for_descriptor(d: Descriptor) -> VariableData:
@ -32,3 +45,118 @@ def get_variable_data_for_descriptor(d: Descriptor) -> VariableData:
if ans is None:
ans = cache_for_variable_data_by_path[d['path']] = Face(descriptor=d).get_variable_data()
return ans
def find_best_match_in_candidates(
candidates: Sequence[Descriptor], scorer: Scorer, is_medium_face: bool, ignore_face: Optional[Descriptor] = None
) -> Optional[Descriptor]:
if len(candidates) == 1 and not is_medium_face and candidates[0].get('family') == candidates[0].get('full_name'):
# IBM Plex Mono does this, where the full name of the regular font
# face is the same as its family name
return None
candidates = sorted(candidates, key=scorer)
for x in candidates:
if ignore_face is None or x != ignore_face:
return x
return None
def get_fine_grained_font(
spec: FontSpec, bold: bool = False, italic: bool = False, medium_font_spec: FontSpec = FontSpec(),
resolved_medium_font: Optional[Descriptor] = None, monospaced: bool = True
) -> Descriptor:
font_map = all_fonts_map(monospaced)
is_medium_face = resolved_medium_font is None
prefer_variable = bool(spec.axes) or bool(spec.style)
scorer = create_scorer(bold, italic, monospaced, prefer_variable=prefer_variable)
if spec.postscript_name:
q = find_best_match_in_candidates(font_map['ps_map'].get(family_name_to_key(spec.postscript_name), []), scorer, is_medium_face)
if q:
return q
if spec.full_name:
q = find_best_match_in_candidates(font_map['full_map'].get(family_name_to_key(spec.full_name), []), scorer, is_medium_face)
if q:
return q
if spec.family:
candidates = font_map['family_map'].get(family_name_to_key(spec.family), [])
if spec.style:
qs = spec.style.lower()
candidates = [x for x in candidates if x['style'].lower() == qs]
q = find_best_match_in_candidates(candidates, scorer, is_medium_face)
if q:
return q
return find_last_resort_text_font(bold, italic, monospaced)
def apply_variation_to_pattern(pat: Descriptor, spec: FontSpec) -> Descriptor:
if not pat['variable']:
return pat
vd = Face(descriptor=pat).get_variable_data()
if spec.style:
q = spec.style.lower()
for i, ns in enumerate(vd['named_styles']):
if ns.get('psname') and ns['psname'].lower() == q:
pat['named_style'] = i
break
else:
for i, ns in enumerate(vd['named_styles']):
if ns['name'].lower() == q:
pat['named_style'] = i
break
tag_map, name_map = {}, {}
axes = [ax['default'] for ax in vd['axes']]
for i, ax in enumerate(vd['axes']):
tag_map[ax['tag']] = i
if ax['strid']:
name_map[ax['strid'].lower()] = i
changed = False
for axspec in spec.axes:
qname = axspec[0]
axis = tag_map.get(qname)
if axis is None:
axis = name_map.get(qname.lower())
if axis is not None:
axes[axis] = axspec[1]
changed = True
if changed:
pat['axes'] = axes
return pat
def find_bold_italic_variant(medium: Descriptor, bold: bool, italic: bool) -> Optional[Descriptor]:
raise NotImplementedError('TODO: Implement me')
def get_font_from_spec(
spec: FontSpec, bold: bool = False, italic: bool = False, medium_font_spec: FontSpec = FontSpec(),
resolved_medium_font: Optional[Descriptor] = None
) -> Descriptor:
if not spec.is_system:
return apply_variation_to_pattern(get_fine_grained_font(spec, bold, italic, medium_font_spec, resolved_medium_font), spec)
family = spec.system
if family == 'auto':
if bold or italic:
assert resolved_medium_font is not None
family = resolved_medium_font['family']
if resolved_medium_font['variable']:
v = find_bold_italic_variant(resolved_medium_font, bold, italic)
if v is not None:
return v
else:
family = 'monospace'
return find_best_match(family, bold, italic, ignore_face=resolved_medium_font)
def get_font_files(opts: Options) -> Dict[str, Descriptor]:
ans: Dict[str, Descriptor] = {}
medium_font = get_font_from_spec(opts.font_family)
kd = {(False, False): 'medium', (True, False): 'bold', (False, True): 'italic', (True, True): 'bi'}
for (bold, italic), attr in attr_map.items():
if bold or italic:
font = get_font_from_spec(getattr(opts, attr), bold, italic, medium_font_spec=opts.font_family, resolved_medium_font=medium_font)
else:
font = medium_font
key = kd[(bold, italic)]
ans[key] = font
return ans

View file

@ -11,7 +11,7 @@ from kitty.options.types import Options
from kitty.typing import CoreTextFont
from kitty.utils import log_error
from . import ListedFont
from . import Descriptor, ListedFont, Score, Scorer
attr_map = {(False, False): 'font_family',
(True, False): 'bold_font',
@ -50,13 +50,11 @@ def list_fonts() -> Generator[ListedFont, None, None]:
'is_variable': fd['variable'], 'descriptor': fd}
def find_best_match(
family: str, bold: bool = False, italic: bool = False, monospaced: bool = True, ignore_face: Optional[CoreTextFont] = None
) -> CoreTextFont:
q = re.sub(r'\s+', ' ', family.lower())
font_map = all_fonts_map(monospaced)
def create_scorer(bold: bool = False, italic: bool = False, monospaced: bool = True, prefer_variable: bool = False) -> Scorer:
def score(candidate: CoreTextFont) -> Tuple[int, int, int, float]:
def score(candidate: Descriptor) -> Score:
assert candidate['descriptor_type'] == 'core_text'
variable_score = 0 if prefer_variable and candidate['variable'] else 1
style_match = 1 if candidate['bold'] == bold and candidate[
'italic'
] == italic else 0
@ -65,13 +63,30 @@ def find_best_match(
# prefer semi-bold to bold to heavy, less bold means less chance of
# overflow
weight_distance_from_medium = abs(candidate['weight'])
return style_match, monospace_match, 1 if is_regular_width else 0, 1 - weight_distance_from_medium
return Score(variable_score, 1 - style_match, 1 - monospace_match, 1 - is_regular_width, weight_distance_from_medium)
return score
def find_last_resort_text_font(bold: bool = False, italic: bool = False, monospaced: bool = True) -> CoreTextFont:
font_map = all_fonts_map(monospaced)
candidates = font_map['family_map']['menlo']
scorer = create_scorer(bold, italic, monospaced)
return sorted(candidates, key=scorer)[0]
def find_best_match(
family: str, bold: bool = False, italic: bool = False, monospaced: bool = True, ignore_face: Optional[CoreTextFont] = None
) -> CoreTextFont:
q = re.sub(r'\s+', ' ', family.lower())
font_map = all_fonts_map(monospaced)
scorer = create_scorer(bold, italic, monospaced)
# First look for an exact match
for selector in ('ps_map', 'full_map'):
candidates = font_map[selector].get(q)
if candidates:
possible = sorted(candidates, key=score)[-1]
possible = sorted(candidates, key=scorer)[0]
if possible != ignore_face:
return possible
@ -81,7 +96,7 @@ def find_best_match(
log_error(f'The font {family} was not found, falling back to Menlo')
q = 'menlo'
candidates = font_map['family_map'][q]
return sorted(candidates, key=score)[-1]
return sorted(candidates, key=scorer)[0]
def get_font_from_spec(

View file

@ -1,9 +1,8 @@
#!/usr/bin/env python
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
import re
from functools import lru_cache
from typing import Callable, Dict, Generator, List, Literal, NamedTuple, Optional, Tuple, cast
from typing import Dict, Generator, List, Literal, Optional, Tuple, cast
from kitty.fast_data_types import (
FC_DUAL,
@ -13,20 +12,12 @@ from kitty.fast_data_types import (
FC_WEIGHT_BOLD,
FC_WEIGHT_REGULAR,
FC_WIDTH_NORMAL,
Face,
fc_list,
)
from kitty.fast_data_types import fc_match as fc_match_impl
from kitty.options.types import Options
from kitty.typing import FontConfigPattern
from . import FontSpec, ListedFont
attr_map = {(False, False): 'font_family',
(True, False): 'bold_font',
(False, True): 'italic_font',
(True, True): 'bold_italic_font'}
from . import Descriptor, ListedFont, Score, Scorer, family_name_to_key
FontCollectionMapType = Literal['family_map', 'ps_map', 'full_map']
FontMap = Dict[FontCollectionMapType, Dict[str, List[FontConfigPattern]]]
@ -46,7 +37,7 @@ def create_font_map(all_fonts: Tuple[FontConfigPattern, ...]) -> FontMap:
return ans
@lru_cache()
@lru_cache(maxsize=2)
def all_fonts_map(monospaced: bool = True) -> FontMap:
if monospaced:
ans = fc_list(spacing=FC_DUAL) + fc_list(spacing=FC_MONO)
@ -73,64 +64,50 @@ def list_fonts(only_variable: bool = False) -> Generator[ListedFont, None, None]
}
def family_name_to_key(family: str) -> str:
return re.sub(r'\s+', ' ', family.lower())
@lru_cache()
def fc_match(family: str, bold: bool, italic: bool, spacing: int = FC_MONO) -> FontConfigPattern:
return fc_match_impl(family, bold, italic, spacing)
class Score(NamedTuple):
variable_score: int
style_score: int
monospace_score: int
width_score: int
Scorer = Callable[[FontConfigPattern], Score]
def create_scorer(bold: bool = False, italic: bool = False, monospaced: bool = True, prefer_variable: bool = False) -> Scorer:
def score(candidate: FontConfigPattern) -> Score:
def score(candidate: Descriptor) -> Score:
assert candidate['descriptor_type'] == 'fontconfig'
variable_score = 0 if prefer_variable and candidate['variable'] else 1
bold_score = abs((FC_WEIGHT_BOLD if bold else FC_WEIGHT_REGULAR) - candidate.get('weight', 0))
italic_score = abs((FC_SLANT_ITALIC if italic else FC_SLANT_ROMAN) - candidate.get('slant', 0))
bold_score = abs((FC_WEIGHT_BOLD if bold else FC_WEIGHT_REGULAR) - candidate['weight'])
italic_score = abs((FC_SLANT_ITALIC if italic else FC_SLANT_ROMAN) - candidate['slant'])
monospace_match = 0
if monospaced:
monospace_match = 0 if candidate.get('spacing') == 'MONO' else 1
width_score = abs(candidate.get('width', FC_WIDTH_NORMAL) - FC_WIDTH_NORMAL)
width_score = abs(candidate['width'] - FC_WIDTH_NORMAL)
return Score(variable_score, bold_score + italic_score, monospace_match, width_score)
return score
def find_best_match_in_candidates(
candidates: List[FontConfigPattern], scorer: Scorer, is_medium_face: bool
) -> Optional[FontConfigPattern]:
if not candidates:
return None
if len(candidates) == 1 and not is_medium_face and candidates[0].get('family') == candidates[0].get('full_name'):
# IBM Plex Mono does this, where the full name of the regular font
# face is the same as its family name
return None
candidates.sort(key=scorer)
return candidates[0]
def find_last_resort_text_font(bold: bool = False, italic: bool = False, monospaced: bool = True) -> FontConfigPattern:
# Use fc-match with a generic family
family = 'monospace' if monospaced else 'sans-serif'
return fc_match(family, bold, italic)
def find_best_match(family: str, bold: bool = False, italic: bool = False, monospaced: bool = True) -> FontConfigPattern:
def find_best_match(
family: str, bold: bool = False, italic: bool = False, monospaced: bool = True,
ignore_face: Optional[FontConfigPattern] = None
) -> FontConfigPattern:
from .common import find_best_match_in_candidates
q = family_name_to_key(family)
font_map = all_fonts_map(monospaced)
scorer = create_scorer(bold, italic, monospaced)
is_medium_face = not bold and not italic
# First look for an exact match
exact_match = (
find_best_match_in_candidates(font_map['ps_map'].get(q, []), scorer, is_medium_face) or
find_best_match_in_candidates(font_map['full_map'].get(q, []), scorer, is_medium_face) or
find_best_match_in_candidates(font_map['family_map'].get(q, []), scorer, is_medium_face)
find_best_match_in_candidates(font_map['ps_map'].get(q, []), scorer, is_medium_face, ignore_face=ignore_face) or
find_best_match_in_candidates(font_map['full_map'].get(q, []), scorer, is_medium_face, ignore_face=ignore_face) or
find_best_match_in_candidates(font_map['family_map'].get(q, []), scorer, is_medium_face, ignore_face=ignore_face)
)
if exact_match:
assert exact_match['descriptor_type'] == 'fontconfig'
return exact_match
# Use fc-match to see if we can find a monospaced font that matches family
@ -155,104 +132,7 @@ def find_best_match(family: str, bold: bool = False, italic: bool = False, monos
if family_name_candidates and len(family_name_candidates) > 1:
candidates = family_name_candidates
return sorted(candidates, key=scorer)[0]
# Use fc-match with a generic family
family = 'monospace' if monospaced else 'sans-serif'
return fc_match(family, bold, italic)
def get_fine_grained_font(
spec: FontSpec, bold: bool = False, italic: bool = False, medium_font_spec: FontSpec = FontSpec(),
resolved_medium_font: Optional[FontConfigPattern] = None, monospaced: bool = True
) -> FontConfigPattern:
font_map = all_fonts_map(monospaced)
is_medium_face = resolved_medium_font is None
prefer_variable = bool(spec.axes) or bool(spec.style)
if resolved_medium_font and resolved_medium_font['variable']:
prefer_variable = True
scorer = create_scorer(bold, italic, monospaced, prefer_variable=prefer_variable)
if spec.postscript_name:
q = find_best_match_in_candidates(font_map['ps_map'].get(family_name_to_key(spec.postscript_name), []), scorer, is_medium_face)
if q:
return q
if spec.full_name:
q = find_best_match_in_candidates(font_map['full_map'].get(family_name_to_key(spec.full_name), []), scorer, is_medium_face)
if q:
return q
if spec.family:
candidates = font_map['family_map'].get(family_name_to_key(spec.family), [])
if spec.style:
qs = spec.style.lower()
candidates = [x for x in candidates if x['style'].lower() == qs]
q = find_best_match_in_candidates(candidates, scorer, is_medium_face)
if q:
return q
# Use fc-match with a generic family
family = 'monospace' if monospaced else 'sans-serif'
return fc_match(family, bold, italic)
def apply_variation_to_pattern(pat: FontConfigPattern, spec: FontSpec) -> FontConfigPattern:
if not pat['variable']:
return pat
vd = Face(descriptor=pat).get_variable_data()
if spec.style:
q = spec.style.lower()
for i, ns in enumerate(vd['named_styles']):
if ns.get('psname') and ns['psname'].lower() == q:
pat['named_style'] = i
break
else:
for i, ns in enumerate(vd['named_styles']):
if ns['name'].lower() == q:
pat['named_style'] = i
break
tag_map, name_map = {}, {}
axes = [ax['default'] for ax in vd['axes']]
for i, ax in enumerate(vd['axes']):
tag_map[ax['tag']] = i
if ax['strid']:
name_map[ax['strid'].lower()] = i
changed = False
for axspec in spec.axes:
qname = axspec[0]
axis = tag_map.get(qname)
if axis is None:
axis = name_map.get(qname.lower())
if axis is not None:
axes[axis] = axspec[1]
changed = True
if changed:
pat['axes'] = axes
return pat
def get_font_from_spec(
spec: FontSpec, bold: bool = False, italic: bool = False, medium_font_spec: FontSpec = FontSpec(),
resolved_medium_font: Optional[FontConfigPattern] = None
) -> FontConfigPattern:
if not spec.is_system:
return apply_variation_to_pattern(get_fine_grained_font(spec, bold, italic, medium_font_spec, resolved_medium_font), spec)
family = spec.system
if family == 'auto' and (bold or italic):
assert resolved_medium_font is not None
family = resolved_medium_font['family']
return find_best_match(family, bold, italic)
def get_font_files(opts: Options) -> Dict[str, FontConfigPattern]:
ans: Dict[str, FontConfigPattern] = {}
medium_font = get_font_from_spec(opts.font_family)
kd = {(False, False): 'medium', (True, False): 'bold', (False, True): 'italic', (True, True): 'bi'}
for (bold, italic), attr in attr_map.items():
if bold or italic:
font = get_font_from_spec(getattr(opts, attr), bold, italic, medium_font_spec=opts.font_family, resolved_medium_font=medium_font)
else:
font = medium_font
key = kd[(bold, italic)]
ans[key] = font
return ans
return find_last_resort_text_font(bold, italic, monospaced)
def font_for_family(family: str) -> Tuple[FontConfigPattern, bool, bool]:

View file

@ -29,23 +29,17 @@ from kitty.types import _T
from kitty.typing import CoreTextFont, FontConfigPattern
from kitty.utils import log_error
from .common import get_font_files
if is_macos:
from .core_text import font_for_family as font_for_family_macos
from .core_text import get_font_files as get_font_files_coretext
else:
from .fontconfig import font_for_family as font_for_family_fontconfig
from .fontconfig import 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)

View file

@ -21,3 +21,4 @@ PowerlineStyle = str
MatchType = str
Protocol = object
OptionsProtocol = object
NotRequired = object

View file

@ -4,6 +4,7 @@ from socket import socket as Socket
from subprocess import CompletedProcess as CompletedProcess
from subprocess import Popen as PopenType
from typing import Literal
from typing import NotRequired as NotRequired
from typing import Protocol as Protocol
from typing import TypedDict as TypedDict
@ -61,7 +62,7 @@ __all__ = (
'EdgeLiteral', 'MatchType', 'GRT_a', 'GRT_f', 'GRT_t', 'GRT_o', 'GRT_m', 'GRT_d',
'GraphicsCommandType', 'HandlerType', 'AbstractEventLoop', 'AddressFamily', 'Socket', 'CompletedProcess',
'PopenType', 'Protocol', 'TypedDict', 'MarkType', 'ImageManagerType', 'Debug', 'LoopType', 'MouseEvent',
'TermManagerType', 'BossType', 'ChildType', 'BadLineType', 'MouseButton',
'TermManagerType', 'BossType', 'ChildType', 'BadLineType', 'MouseButton', 'NotRequired',
'KeyActionType', 'KeyMap', 'KittyCommonOpts', 'AliasMap', 'CoreTextFont', 'WindowSystemMouseEvent',
'FontConfigPattern', 'ScreenType', 'StartupCtx', 'KeyEventType', 'LayoutType', 'PowerlineStyle',
'RemoteCommandType', 'SessionType', 'SessionTab', 'SpecialWindowInstance', 'TabType', 'ScreenSize', 'WindowType'