Ich liebe Bitmap-Fonts. Nicht aus Nostalgie, sondern weil sie auf meinem Display schlicht besser aussehen: gestochen scharf, jedes Pixel von Hand gesetzt, kein Antialiasing-Matsch, keine Sub-Pixel-Lotterie. Bei der richtigen Größe ist eine gute Bitmap-Schrift in einem Terminal so ruhig und lesbar, dass keine Vector-Font dagegen anstinken kann — die muss bei kleinen Pixelgrößen immer raten, wo die Kante hin soll, und rät selten so gut wie ein Mensch, der den Glyph Pixel für Pixel hingelegt hat. Meine Lieblingsschrift ist Cozette : eine 13px-Bitmap im Geist von Tamzen und Creep, kompakt, mit großem Unicode-Block.
Das Problem: mein liebster Terminal-Emulator will sie nicht.
Warum überhaupt Kitty
Es gäbe Emulatoren, die Bitmaps problemlos schlucken — st, urxvt, xterm. Aber ich bin bei
Kitty
hängengeblieben, und das aus guten Gründen. Es ist GPU-gerendert und entsprechend schnell, auch bei wütendem cat über eine 10-MB-Logdatei. Es zeigt Bilder direkt im Terminal (das icat-Kitten, mein yazi-Preview lebt davon). Es hat ein ganzes Ökosystem aus Kittens — der ssh-Kitten, der das Terminfo mitnimmt; hyperlinked_grep; unicode_input. Es kann Sessions und Layouts deklarativ aus einer Datei starten, was bei mir die ganzen Monitoring- und Chat-Fenster aufzieht. Es lässt sich per Remote-Control fernsteuern, und über --class kann ich jedem Fenster eine eigene Hyprland-Window-Rule verpassen. Mouse-Trail, anständige Ligaturen, ein vernünftiges Theme-System obendrauf. Kurz: Funktional gibt es für mich wenig Grund zu wechseln.
Bis auf diesen einen.
Der Maintainer sagt Nein
Bitmap-Font-Support steht in Kitty seit Jahren auf “won’t fix”. Der Maintainer hat in Issue #97 unmissverständlich klargemacht, dass er das nicht einbauen will — die Begründung dreht sich um HiDPI, Skalierung und Wartungsaufwand. Man muss das nicht teilen (ich tue es offensichtlich nicht), aber es ist seine Software und seine Entscheidung. Die praktische Konsequenz für mich ist nur: Wenn ich Cozette in Kitty will, muss ich es mir selbst einbauen — ohne auf einen Merge zu hoffen, der nie kommt.
Allow-bitmap-fonts.patch
aus dem kitty-bitmap-Paket. Genau den habe ich als Vorlage genommen — aber er ist alt, gegen eine längst veraltete Kitty-Version geschrieben, und ein Teil davon ist inzwischen überflüssig. Also habe ich nicht den Patch übernommen, sondern seine Idee neu gegen das aktuelle Kitty implementiert und in mein Nix-Overlay gegossen.Was Kitty schon kann — und was nicht
Beim Hineinlesen in freetype.c und fontconfig.c wurde schnell klar: Die C-Schicht kann Bitmaps längst. Sie wählt feste Strikes über FT_Select_Size, rendert sie als FT_PIXEL_MODE_MONO, und fontconfig.c hat sogar ein fertiges allow_bitmapped_fonts-Gate. Der Render-Hack, den der alte AUR-Patch noch nachrüsten musste, ist also mittlerweile upstream. So weit, so gut.
Es fehlen zwei voneinander unabhängige Stellen — und beide sitzen davor und danach, nicht im Rendering selbst:
- Das Matching opted nie ein. Der Python-Matcher in
kitty/fonts/fontconfig.pylistet für die primärefont_familynur skalierbare Faces auf; das Bitmap-Gate wird für genau diesen Pfad nie gesetzt. Cozette taucht also gar nicht erst als Kandidat auf. - Die Metriken kollabieren. Die Zellhöhe und Baseline werden in
freetype.causfont-units × size->metrics.y_scaleberechnet — das ist der Pfad für skalierbare Fonts. Für einen Bitmap-Strike ist dieser Scale 0, also wird die Zellhöhe 0, und Kitty stirbt mit dem fatalenLine height too small: 0. Genau diesen Metrik-Teil machte der alte AUR-Patch zusätzlich, und er fehlt bis heute upstream (auch in 0.47.x).
Der Patch, in Nix gegossen
Kitty ist bei mir ohnehin schon auf die stable-Nixpkgs gepinnt — die 0.47.x-Reihe leakt inotify-Watches über den Config-Watcher (kitten __watch_conf__), bis fs.inotify.max_user_watches erschöpft ist. An genau diesen Pin hänge ich den Bitmap-Patch als overrideAttrs/postPatch. Beide Korrekturen sind reine substituteInPlace-Ersetzungen mit --replace-fail — das ist Absicht: Verschiebt upstream die Zeilen, bricht der Build laut ab, statt still einen kaputten Emulator zu liefern.
1kitty = stable.kitty.overrideAttrs (old: {
2 postPatch = (old.postPatch or "") + ''
3 # 1) Matcher: Bitmaps für die primäre font_family als Kandidaten zulassen
4 substituteInPlace kitty/fonts/fontconfig.py \
5 --replace-fail "ans = fc_list(spacing=FC_DUAL) + fc_list(spacing=FC_MONO)" \
6 "ans = fc_list(spacing=FC_DUAL) + fc_list(spacing=FC_MONO) + fc_list(spacing=FC_MONO, allow_bitmapped_fonts=True)" \
7 --replace-fail "return fc_match_impl(family, bold, italic, spacing)" \
8 "return fc_match_impl(family, bold, italic, spacing, True)"
9 # 2) Metriken: für nicht-skalierbare Faces aus dem Strike statt aus font-units
10 substituteInPlace kitty/freetype.c \
11 --replace-fail "unsigned int ans = font_units_to_pixels_y(self, self->metrics.height);" \
12 "unsigned int ans = self->is_scalable ? (unsigned int)font_units_to_pixels_y(self, self->metrics.height) : (unsigned int)ceil((double)self->face->size->metrics.height / 64.0);" \
13 --replace-fail "ans.baseline = font_units_to_pixels_y(self, self->metrics.ascender);" \
14 "ans.baseline = self->is_scalable ? (unsigned int)font_units_to_pixels_y(self, self->metrics.ascender) : (unsigned int)ceil((double)self->face->size->metrics.ascender / 64.0);" \
15 --replace-fail "unsigned int baseline = font_units_to_pixels_y(self, self->metrics.ascender);" \
16 "unsigned int baseline = self->is_scalable ? (unsigned int)font_units_to_pixels_y(self, self->metrics.ascender) : (unsigned int)ceil((double)self->face->size->metrics.ascender / 64.0);"
17 '';
18});
Der Kern der Metrik-Korrektur ist das self->is_scalable ? … : …: Skalierbare Faces laufen weiter den alten Pfad, nicht-skalierbare nehmen die Pixel-Metriken des gewählten Strikes (size->metrics.height/ascender, im 26.6-Fixed-Point-Format, daher /64). Damit ist die Zellhöhe wieder echt, und Line height too small verschwindet.

Cozette ist nicht gleich Cozette
Damit baute Kitty die Bitmaps — aber es wählte den falschen Strike. Cozette liefert nämlich viele Faces, die alle die Family “Cozette” melden: die 13px-Regular (cozette.otb), einen 26px-HiDpi-Strike, eine Crossed-Seven-Variante und .bdf-Duplikate von allem. Sind die alle sichtbar, greift der Matcher willkürlich zu — bei mir gern den 26px-Crossed-Seven. Die Lösung sitzt in der System-fontconfig: alles außer cozette.otb per <rejectfont> wegwerfen, dann löst “Cozette” deterministisch auf den 13px-Regular-Strike auf.
1<selectfont>
2 <rejectfont>
3 <glob>*cozette_hidpi.*</glob>
4 <glob>*cozettecrossedseven*</glob>
5 <glob>*cozette.bdf</glob>
6 </rejectfont>
7</selectfont>
Die skalierbaren CozetteVector*-Faces tragen einen anderen Family-Namen und bleiben unangetastet; die Konsolen-psfu lebt außerhalb von fontconfig und ist ohnehin nicht betroffen. Ein chirurgischer Schnitt, kein Kahlschlag.

Was bleibt
Drei kleine Eingriffe, die zusammen ein hartnäckiges “won’t fix” aushebeln: ein Matcher-Opt-in, eine Metrik-Korrektur, ein fontconfig-Filter. Nichts davon ist ein Fork — es ist ein an die gepinnte Version gekoppelter Patch, der bei der nächsten Kitty-Version absichtlich laut zerbricht, damit ich ihn bewusst neu bewerte, statt blind weiterzuschleppen.
Und das Ergebnis ist genau das, wofür ich angefangen habe: mein liebster Emulator mit all seinen Features — Geschwindigkeit, In-Terminal-Bilder, Sessions, die ganzen Kittens — und darin meine Lieblingsschrift, Pixel für Pixel scharf. Manchmal ist die beste Antwort auf ein “Nein” eben ein substituteInPlace.
Der Patch lebt in fr0st
, meinem NixOS-Flake — im stable-packages-Overlay neben dem Versions-Pin, der ihn erst nötig macht.
