From 304ecdd8c209d2e33b34d279ee5770913029a079 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 15 May 2024 17:35:20 +0530 Subject: [PATCH] Refactor scoring --- kitty/fonts/__init__.py | 26 ++++++++++------ kitty/fonts/common.py | 24 +++++---------- kitty/fonts/core_text.py | 57 +++++++++++++++++++++------------- kitty/fonts/fontconfig.py | 65 ++++++++++++++++++++++++--------------- kitty_tests/fonts.py | 3 ++ kitty_tests/main.py | 5 +++ 6 files changed, 110 insertions(+), 70 deletions(-) diff --git a/kitty/fonts/__init__.py b/kitty/fonts/__init__.py index fc81704b5..c44972c27 100644 --- a/kitty/fonts/__init__.py +++ b/kitty/fonts/__init__.py @@ -1,5 +1,5 @@ from enum import Enum, IntEnum, auto -from typing import TYPE_CHECKING, Callable, Dict, List, Literal, NamedTuple, Tuple, TypedDict, Union +from typing import TYPE_CHECKING, Dict, List, Literal, NamedTuple, Sequence, Tuple, TypedDict, TypeVar, Union from kitty.types import run_once from kitty.typing import CoreTextFont, FontConfigPattern @@ -154,15 +154,23 @@ class FontSpec(NamedTuple): return self.system == 'auto' -class Score(NamedTuple): - variable_score: int - style_score: Union[int, float] - monospace_score: int - width_score: int - - Descriptor = Union[FontConfigPattern, CoreTextFont] -Scorer = Callable[[Descriptor], Score] +DescriptorVar = TypeVar('DescriptorVar', FontConfigPattern, CoreTextFont, Descriptor) + +class Scorer: + + def __init__(self, bold: bool = False, italic: bool = False, monospaced: bool = True, prefer_variable: bool = False) -> None: + self.bold = bold + self.italic = italic + self.monospaced = monospaced + self.prefer_variable = prefer_variable + + def sorted_candidates(self, candidates: Sequence[DescriptorVar], dump: bool = False) -> List[DescriptorVar]: + raise NotImplementedError() + + def __repr__(self) -> str: + return f'{self.__class__.__name__}(bold={self.bold}, italic={self.italic}, monospaced={self.monospaced}, prefer_variable={self.prefer_variable})' + __str__ = __repr__ @run_once diff --git a/kitty/fonts/common.py b/kitty/fonts/common.py index 1856ce97f..23615cdc7 100644 --- a/kitty/fonts/common.py +++ b/kitty/fonts/common.py @@ -1,10 +1,10 @@ #!/usr/bin/env python # License: GPLv3 Copyright: 2024, Kovid Goyal -from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Sequence, Tuple, TypedDict, Union +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, TypedDict, Union from kitty.constants import is_macos -from kitty.fonts import Descriptor, DesignAxis, FontSpec, NamedStyle, Scorer, VariableData, family_name_to_key +from kitty.fonts import Descriptor, DescriptorVar, DesignAxis, FontSpec, NamedStyle, Scorer, VariableData, family_name_to_key from kitty.options.types import Options if TYPE_CHECKING: @@ -26,7 +26,6 @@ if TYPE_CHECKING: def is_variable(descriptor: Descriptor) -> bool: ... def set_named_style(name: str, font: Descriptor, vd: VariableData) -> bool: ... def set_axis_values(tag_map: Dict[str, float], font: Descriptor, vd: VariableData) -> bool: ... - def dump_sorted_candidates(bold: bool, italic: bool, candidates: List[Descriptor], scorer: Scorer) -> None: ... else: FontCollectionMapType = FontMap = None if is_macos: @@ -34,7 +33,6 @@ else: from kitty.fonts.core_text import ( all_fonts_map, create_scorer, - dump_sorted_candidates, find_best_match, find_last_resort_text_font, is_monospace, @@ -47,7 +45,6 @@ else: from kitty.fonts.fontconfig import ( all_fonts_map, create_scorer, - dump_sorted_candidates, find_best_match, find_last_resort_text_font, is_monospace, @@ -58,7 +55,6 @@ else: def face_from_descriptor(descriptor: Descriptor) -> Face: return Face(descriptor=descriptor) -dump_sorted_candidates 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'} @@ -83,16 +79,15 @@ def get_variable_data_for_face(d: Face) -> VariableData: def find_best_match_in_candidates( - candidates: Sequence[Descriptor], scorer: Scorer, is_medium_face: bool, ignore_face: Optional[Descriptor] = None -) -> Optional[Descriptor]: + candidates: List[DescriptorVar], scorer: Scorer, is_medium_face: bool, ignore_face: Optional[DescriptorVar] = None +) -> Optional[DescriptorVar]: 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 = sorted(candidates, key=scorer) - for x in candidates: + for x in scorer.sorted_candidates(candidates): if ignore_face is None or x != ignore_face: return x return None @@ -144,9 +139,8 @@ def find_bold_italic_variant(medium: Descriptor, bold: bool, italic: bool) -> De # weights in each, so we rely on the OS font matcher to give us the best # font file. monospaced = is_monospace(medium) - fonts = all_fonts_map(monospaced)['variable_map'][family_name_to_key(medium['family'])] - scorer = create_scorer(bold, italic, monospaced) - fonts.sort(key=scorer) + unsorted = all_fonts_map(monospaced)['variable_map'][family_name_to_key(medium['family'])] + fonts = create_scorer(bold, italic, monospaced).sorted_candidates(unsorted) vd = get_variable_data_for_descriptor(fonts[0]) ans = fonts[0].copy() # now we need to specialise all axes in ans @@ -179,9 +173,7 @@ def find_best_variable_face(spec: FontSpec, bold: bool, italic: bool, monospaced for x in vd['named_styles']: if x['name'].lower() == q: return font - scorer = create_scorer(bold, italic, monospaced) - candidates.sort(key=scorer) - return candidates[0] + return create_scorer(bold, italic, monospaced).sorted_candidates(candidates)[0] def get_fine_grained_font( diff --git a/kitty/fonts/core_text.py b/kitty/fonts/core_text.py index 8940a9ff2..b00fda87f 100644 --- a/kitty/fonts/core_text.py +++ b/kitty/fonts/core_text.py @@ -5,7 +5,7 @@ import itertools import operator from collections import defaultdict from functools import lru_cache -from typing import Dict, Generator, Iterable, List, Optional, Tuple +from typing import Dict, Generator, Iterable, List, NamedTuple, Optional, Sequence, Tuple from kitty.fast_data_types import coretext_all_fonts from kitty.fonts import FontSpec, family_name_to_key @@ -13,7 +13,7 @@ from kitty.options.types import Options from kitty.typing import CoreTextFont from kitty.utils import log_error -from . import Descriptor, ListedFont, Score, Scorer, VariableData +from . import Descriptor, DescriptorVar, ListedFont, Scorer, VariableData attr_map = {(False, False): 'font_family', (True, False): 'bold_font', @@ -74,20 +74,30 @@ def list_fonts() -> Generator[ListedFont, None, None]: 'is_variable': is_variable(fd), 'descriptor': fd, 'style': fd['style']} -def create_scorer(bold: bool = False, italic: bool = False, monospaced: bool = True, prefer_variable: bool = False) -> Scorer: +class Score(NamedTuple): + variable_score: int + style_score: float + monospace_score: int + width_score: int - def score(candidate: Descriptor) -> Score: + + +class CTScorer(Scorer): + medium_weight: float = 0. + bold_weight: float = 0.3 + + def score(self, candidate: Descriptor) -> Score: assert candidate['descriptor_type'] == 'core_text' - variable_score = 0 if prefer_variable and candidate['variation'] is not None else 1 + variable_score = 0 if self.prefer_variable and candidate['variation'] is not None else 1 bold_score = candidate['weight'] # -1 to 1 with 0 being normal if bold_score < 0: # thinner than normal, reject bold_score = 2.0 else: - if bold: + if self.bold: # prefer semibold=0.3 to full bold = 0.4 bold_score = abs(bold_score - 0.3) italic_score = candidate['slant'] # -1 to 1 with 0 being upright < 0 being backward slant, abs(slant) == 1 implies 30 deg rotation - if italic: + if self.italic: if italic_score < 0: italic_score = 2.0 else: @@ -96,22 +106,27 @@ def create_scorer(bold: bool = False, italic: bool = False, monospaced: bool = T is_regular_width = not candidate['expanded'] and not candidate['condensed'] return Score(variable_score, bold_score + italic_score, monospace_match, 0 if is_regular_width else 1) - return score + def sorted_candidates(self, candidates: Sequence[DescriptorVar], dump: bool = False) -> List[DescriptorVar]: + candidates = sorted(candidates, key=self.score) + if dump: + print(self) + for x in candidates: + assert x['descriptor_type'] == 'core_text' + print(x['postscript_name'], f'bold={x["bold"]}', f'italic={x["italic"]}', f'weight={x["weight"]:.2f}', f'slant={x["slant"]:.2f}') + print(self.score(x)) + print() + self.medium_weight, self.bold_weight = CTScorer.medium_weight, CTScorer.bold_weight + return candidates + + +def create_scorer(bold: bool = False, italic: bool = False, monospaced: bool = True, prefer_variable: bool = False) -> Scorer: + return CTScorer(bold, italic, monospaced, prefer_variable) 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 dump_sorted_candidates(bold: bool, italic: bool, candidates: List[CoreTextFont], scorer: Scorer) -> None: - print(f'{bold=} {italic=}') - for x in candidates: - print(x['postscript_name'], f'bold={x["bold"]}', f'italic={x["italic"]}', f'weight={x["weight"]:.2f}', f'slant={x["slant"]:.2f}') - print(scorer(x)) - print() + return create_scorer(bold, italic, monospaced).sorted_candidates(candidates)[0] def find_best_match( @@ -126,7 +141,8 @@ def find_best_match( for selector in ('ps_map', 'full_map'): candidates = font_map[selector].get(q) if candidates: - possible = sorted(candidates, key=scorer)[0] + candidates = scorer.sorted_candidates(candidates) + possible = candidates[0] if possible != ignore_face: return possible @@ -135,8 +151,7 @@ def find_best_match( if q not in font_map['family_map']: log_error(f'The font {family} was not found, falling back to Menlo') q = 'menlo' - candidates = font_map['family_map'][q] - candidates.sort(key=scorer) + candidates = scorer.sorted_candidates(font_map['family_map'][q]) return candidates[0] diff --git a/kitty/fonts/fontconfig.py b/kitty/fonts/fontconfig.py index 95dc9e099..a8eb32ef0 100644 --- a/kitty/fonts/fontconfig.py +++ b/kitty/fonts/fontconfig.py @@ -2,7 +2,7 @@ # License: GPL v3 Copyright: 2016, Kovid Goyal from functools import lru_cache -from typing import Dict, Generator, List, Literal, Optional, Tuple, cast +from typing import Dict, Generator, List, Literal, NamedTuple, Optional, Sequence, Tuple, cast from kitty.fast_data_types import ( FC_DUAL, @@ -19,7 +19,7 @@ from kitty.fast_data_types import ( from kitty.fast_data_types import fc_match as fc_match_impl from kitty.typing import FontConfigPattern -from . import Descriptor, ListedFont, Score, Scorer, VariableData, family_name_to_key +from . import Descriptor, DescriptorVar, ListedFont, Scorer, VariableData, family_name_to_key FontCollectionMapType = Literal['family_map', 'ps_map', 'full_map', 'variable_map'] FontMap = Dict[FontCollectionMapType, Dict[str, List[FontConfigPattern]]] @@ -81,28 +81,43 @@ def fc_match(family: str, bold: bool, italic: bool, spacing: int = FC_MONO) -> F return fc_match_impl(family, bold, italic, spacing) -def create_scorer(bold: bool = False, italic: bool = False, monospaced: bool = True, prefer_variable: bool = False) -> Scorer: +class Score(NamedTuple): + variable_score: int + style_score: int + monospace_score: int + width_score: int - def score(candidate: Descriptor) -> Score: + + +class FCScorer(Scorer): + + medium_weight: int = FC_WEIGHT_REGULAR + bold_weight: int = FC_WEIGHT_BOLD + + def score(self, 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['weight']) - italic_score = abs((FC_SLANT_ITALIC if italic else FC_SLANT_ROMAN) - candidate['slant']) + variable_score = 0 if self.prefer_variable and candidate['variable'] else 1 + bold_score = abs((self.bold_weight if self.bold else self.medium_weight) - candidate['weight']) + italic_score = abs((FC_SLANT_ITALIC if self.italic else FC_SLANT_ROMAN) - candidate['slant']) monospace_match = 0 - if monospaced: + if self.monospaced: monospace_match = 0 if candidate.get('spacing') == 'MONO' else 1 width_score = abs(candidate['width'] - FC_WIDTH_NORMAL) return Score(variable_score, bold_score + italic_score, monospace_match, width_score) - return score + def sorted_candidates(self, candidates: Sequence[DescriptorVar], dump: bool = False) -> List[DescriptorVar]: + candidates = sorted(candidates, key=self.score) + if dump: + print(self) + for x in candidates: + assert x['descriptor_type'] == 'fontconfig' + print(x['postscript_name'], f'weight={x["weight"]}', f'slant={x["slant"]}') + print() + return candidates -def dump_sorted_candidates(bold: bool, italic: bool, candidates: List[FontConfigPattern], scorer: Scorer) -> None: - print(f'{bold=} {italic=}') - for x in candidates: - print(x['postscript_name'], f'weight={x["weight"]}', f'slant={x["slant"]}') - print(scorer(x)) - print() +def create_scorer(bold: bool = False, italic: bool = False, monospaced: bool = True, prefer_variable: bool = False) -> Scorer: + return FCScorer(bold, italic, monospaced, prefer_variable) def find_last_resort_text_font(bold: bool = False, italic: bool = False, monospaced: bool = True) -> FontConfigPattern: @@ -121,14 +136,16 @@ def find_best_match( scorer = create_scorer(bold, italic, monospaced, prefer_variable=prefer_variable) 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, 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 + groups: Tuple[FontCollectionMapType, ...] = ('ps_map', 'full_map', 'family_map') + for which in groups: + m = font_map[which] + cq = m.get(q, []) + if cq: + exact_match = find_best_match_in_candidates(cq, scorer, is_medium_face, ignore_face=ignore_face) + if exact_match: + # dump_sorted_candidates(bold, italic, cq, scorer) + assert exact_match['descriptor_type'] == 'fontconfig' + return exact_match # Use fc-match to see if we can find a monospaced font that matches family # When aliases are defined, spacing can cause the incorrect font to be @@ -151,7 +168,7 @@ def find_best_match( family_name_candidates = font_map['family_map'].get(family_name_to_key(candidates[0]['family'])) if family_name_candidates and len(family_name_candidates) > 1: candidates = family_name_candidates - return sorted(candidates, key=scorer)[0] + return scorer.sorted_candidates(candidates)[0] return find_last_resort_text_font(bold, italic, monospaced) diff --git a/kitty_tests/fonts.py b/kitty_tests/fonts.py index a675bca73..d75b4aaa0 100644 --- a/kitty_tests/fonts.py +++ b/kitty_tests/fonts.py @@ -31,6 +31,7 @@ class Selection(BaseTest): has_source_code_vf = has('sourcecodeVf') has_fira_code = has('Fira Code') has_hack = has('Hack') + has_operator_mono = has('Operator Mono') del fonts_map, has def s(family: str, *expected: str) -> None: @@ -52,6 +53,8 @@ class Selection(BaseTest): both('fira code', 'FiraCodeRoman-Regular', 'FiraCodeRoman-SemiBold', 'FiraCodeRoman-Regular', 'FiraCodeRoman-SemiBold') if has_hack: both('hack', 'Hack-Regular', 'Hack-Bold', 'Hack-Italic', 'Hack-BoldItalic') + if has_operator_mono: + both('operator mono', 'Hack-Regular', 'Hack-Bold', 'Hack-Italic', 'Hack-BoldItalic') class Rendering(BaseTest): diff --git a/kitty_tests/main.py b/kitty_tests/main.py index 36ef07906..550af2e4b 100644 --- a/kitty_tests/main.py +++ b/kitty_tests/main.py @@ -308,9 +308,14 @@ def env_for_python_tests(report_env: bool = False) -> Iterator[None]: print('Python:', python_for_type_check()) from kitty.fast_data_types import has_avx2, has_sse4_2 print(f'Intrinsics: {has_avx2=} {has_sse4_2=}') + # we need fonts installed in the user home directory as well, so initialize + # fontconfig before nuking $HOME and friends + from kitty.fonts.common import all_fonts_map + all_fonts_map(True) with TemporaryDirectory() as tdir, env_vars( HOME=tdir, + KT_ORIGINAL_HOME=os.path.expanduser('~'), USERPROFILE=tdir, PATH=path, TERM='xterm-kitty',