From b675a977888e7936d1e5081b34ad9e62703ac18b Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 23 Oct 2026 18:27:03 -0300 Subject: [PATCH 1/2] perf(fonts): parse each font face once while it is held The window asked for the same font file several times: the interface and the terminal set in one family, and the bold face fontconfig answers for both semibold and bold. Each ParseFace parsed the file's tables and every glyph outline again, about 30 MB for a Nerd Font face. Parsed faces are now kept by location under a weak pointer and shared while something holds them, so a family let go (a font picker's sample, the interface font before a change) is still freed. With ZedMono Nerd Font Mono for the interface, the terminal and the editor, the interface and terminal fonts drop from 246 MB to 175 MB of live heap. --- native/fonts/fonts.go | 41 ++++++++++++++++++++++++++++++++------ native/fonts/fonts_test.go | 31 ++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/native/fonts/fonts.go b/native/fonts/fonts.go index 036289a32..dbc460a41 100644 --- a/native/fonts/fonts.go +++ b/native/fonts/fonts.go @@ -10,6 +10,8 @@ import ( "embed" "fmt" "os" + "sync" + "weak" "gioui.org/font" "gioui.org/font/opentype" @@ -90,24 +92,51 @@ func Load(family string) ([]font.FontFace, error) { // ParseFace parses the one face loc names, under the family and style the face // gives itself. Only that face: a collection holds several (a CJK one about // ten), and opentype.ParseCollection parses them all, hundreds of megabytes -// allocated to keep one. +// allocated to keep one. A face still held from an earlier call is shared +// rather than parsed again (parsedFaces). func ParseFace(loc Location) (font.FontFace, error) { - data, err := os.ReadFile(loc.Path) + parsed, err := parseFont(loc) if err != nil { return font.FontFace{}, err } + return font.FontFace{Font: opentype.DescriptionToFont(parsed.Describe()), Face: collectionFace{parsed}}, nil +} + +// parsedFaces are the faces ParseFace parsed, kept only while something holds +// them. A parse is the file's tables and every glyph outline, about 30 MB for a +// Nerd Font face, and the window asks for the same files several times: the +// interface and the terminal family when they are one family, and the bold +// face fontconfig answers for both semibold and bold. Held weakly so a family +// that is let go, a font picker's sample or the interface font before a +// change, is freed with its last face. +var parsedFaces = struct { + sync.Mutex + byLocation map[Location]weak.Pointer[gotext.Font] +}{byLocation: map[Location]weak.Pointer[gotext.Font]{}} + +func parseFont(loc Location) (*gotext.Font, error) { + parsedFaces.Lock() + defer parsedFaces.Unlock() + if parsed := parsedFaces.byLocation[loc].Value(); parsed != nil { + return parsed, nil + } + data, err := os.ReadFile(loc.Path) + if err != nil { + return nil, err + } loaders, err := ot.NewLoaders(bytes.NewReader(data)) if err != nil { - return font.FontFace{}, fmt.Errorf("parse %s: %w", loc.Path, err) + return nil, fmt.Errorf("parse %s: %w", loc.Path, err) } if loc.Index < 0 || loc.Index >= len(loaders) { - return font.FontFace{}, fmt.Errorf("face index %d is not one of the %d in %s", loc.Index, len(loaders), loc.Path) + return nil, fmt.Errorf("face index %d is not one of the %d in %s", loc.Index, len(loaders), loc.Path) } parsed, err := gotext.NewFont(loaders[loc.Index]) if err != nil { - return font.FontFace{}, fmt.Errorf("parse %s: %w", loc.Path, err) + return nil, fmt.Errorf("parse %s: %w", loc.Path, err) } - return font.FontFace{Font: opentype.DescriptionToFont(parsed.Describe()), Face: collectionFace{parsed}}, nil + parsedFaces.byLocation[loc] = weak.Make(parsed) + return parsed, nil } // collectionFace is a font.Face over one parsed face of a collection; like diff --git a/native/fonts/fonts_test.go b/native/fonts/fonts_test.go index d3d645f38..39db951fa 100644 --- a/native/fonts/fonts_test.go +++ b/native/fonts/fonts_test.go @@ -1,6 +1,7 @@ package fonts import ( + "runtime" "testing" "gioui.org/font" @@ -65,3 +66,33 @@ func TestParseFaceReadsTheFaceLocated(t *testing.T) { t.Errorf("face index 64 of %s was read", loc.Path) } } + +// The bold face fontconfig answers for both semibold and bold, and the +// interface and the terminal sharing a family, ask for one file several times: +// each ask while a face of it is held gets that face, not a parse of its own, +// and a face nobody holds any more is freed rather than kept for a next ask. +func TestParseFaceSharesAFaceWhileItIsHeld(t *testing.T) { + loc, err := Locate(font.Font{Typeface: SystemMono}) + if err != nil { + t.Fatal(err) + } + first, err := ParseFace(loc) + if err != nil { + t.Fatal(err) + } + again, err := ParseFace(loc) + if err != nil { + t.Fatal(err) + } + if first.Face.(collectionFace).font != again.Face.(collectionFace).font { + t.Fatal("a second ParseFace of a held face parsed the file again") + } + first, again = font.FontFace{}, font.FontFace{} + runtime.GC() + parsedFaces.Lock() + held := parsedFaces.byLocation[loc].Value() + parsedFaces.Unlock() + if held != nil { + t.Fatal("a face nobody holds was kept") + } +} From 300723afc61ddf6d7c7b1466010eac6f45fc59cd Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 23 Oct 2026 18:27:03 -0300 Subject: [PATCH 2/2] perf(native): load the CJK faces when CJK text first comes up An interface in a language other than Chinese, Japanese or Korean loaded a CJK font at start for the Han it might draw in a branch or a title, 56 MB with wqy-zenhei, whether any came up or not. The interface's shaper now leaves the CJK faces out until it is asked to draw Han, kana or Hangul: the fork's text.WithOutsideCollection reports a rune none of its faces draws, the window marks it, and on the next frame builds the shaper again with the faces, picked by the same rules. Until then the system font the shaper falls back to draws it. A CJK interface language loads them at start as before. Bumps lichdotdev/gio to 16fb1737 (branch lich), which carries WithOutsideCollection and merges upstream's main. With no CJK on screen the interface and terminal fonts take 119 MB of live heap, down from 175 MB. --- CHANGELOG.md | 4 ++ native/cmd/lich-native/appearance_han_test.go | 37 +++++++++++++++++ native/cmd/lich-native/appearance_prefs.go | 41 ++++++++++++++++--- native/cmd/lich-native/main.go | 2 + native/cmd/lich-native/tray.go | 6 ++- native/go.mod | 8 ++-- native/go.sum | 4 +- 7 files changed, 89 insertions(+), 13 deletions(-) create mode 100644 native/cmd/lich-native/appearance_han_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 57c4f56fa..210318c35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- The native window holds less memory for its fonts. A font file asked for more than once, such as the interface and the terminal set in the same family or the bold face that also stands in for semibold, was parsed again each time, about 30MB for a Nerd Font face; it is now parsed once and shared while in use. An interface in a language other than Chinese, Japanese or Korean no longer loads a CJK font at start, tens of megabytes: it does on the first CJK text on screen, drawn meanwhile with a system font. With one Nerd Font for the interface, the terminal and the editor and no CJK on screen, the fonts take 119MB where they took 246MB. + ## [0.78.1] - 2026-10-23 ### Changed diff --git a/native/cmd/lich-native/appearance_han_test.go b/native/cmd/lich-native/appearance_han_test.go new file mode 100644 index 000000000..6eb24bddb --- /dev/null +++ b/native/cmd/lich-native/appearance_han_test.go @@ -0,0 +1,37 @@ +package main + +import ( + "testing" + + "gioui.org/app" + "gioui.org/text" + "gioui.org/widget/material" + "github.com/lichdotdev/lich/native/fonts" + "github.com/lichdotdev/lich/native/ui" + "golang.org/x/image/math/fixed" +) + +// An interface in a language that is not CJK leaves the CJK faces, tens of +// megabytes, out of its shaper until CJK text comes up on screen, and takes +// them in on the frame after it does. +func TestCJKFacesJoinTheInterfaceOnceCJKTextComesUp(t *testing.T) { + u := &window{win: new(app.Window), th: material.NewTheme(), theme: &ui.Theme{}} + if err := u.setInterfaceFonts(interfaceFonts{mono: fonts.SystemMono}); err != nil { + t.Fatal(err) + } + draw := func(s string) { + u.theme.Shaper.LayoutString(text.Parameters{PxPerEm: fixed.I(16), MaxWidth: 1 << 20}, s) + for _, ok := u.theme.Shaper.NextGlyph(); ok; _, ok = u.theme.Shaper.NextGlyph() { + } + } + draw("main · 3 sessions") + u.followLanguage() + if u.uiFonts.han { + t.Fatal("the CJK faces were loaded with no CJK text on screen") + } + draw("feat/中文") + u.followLanguage() + if !u.uiFonts.han { + t.Fatal("CJK text came up and the CJK faces were not loaded on the next frame") + } +} diff --git a/native/cmd/lich-native/appearance_prefs.go b/native/cmd/lich-native/appearance_prefs.go index 47dbd1d5e..bb2382e64 100644 --- a/native/cmd/lich-native/appearance_prefs.go +++ b/native/cmd/lich-native/appearance_prefs.go @@ -7,6 +7,7 @@ import ( "log" "math" "strconv" + "unicode" "gioui.org/app" "gioui.org/font" @@ -200,11 +201,14 @@ func (u *window) setCodeFont(family string, size unit.Sp) error { const onestFamily = "Onest" // interfaceFonts are the interface's two faces as stored, its sans ("" for -// System) and its monospace, the code's family, and the language its CJK -// faces are picked for. +// System) and its monospace, the code's family, the language its CJK faces +// are picked for, and whether the shaper holds them: from the start for a +// CJK language, and for any other once Han, kana or Hangul has come up on +// screen (noteOutsideFaces), since a CJK face is tens of megabytes. type interfaceFonts struct { sans, mono string language i18n.Locale + han bool } // setInterfaceFonts builds the interface's shaper again when its sans or its @@ -214,6 +218,7 @@ type interfaceFonts struct { // other two cannot draw, in the current interface language's own shapes. func (u *window) setInterfaceFonts(want interfaceFonts) error { want.language = i18n.Current() + want.han = u.cjkSeen.Load() || isCJKLanguage(want.language) if u.uiFonts != nil && *u.uiFonts == want { return nil } @@ -225,18 +230,42 @@ func (u *window) setInterfaceFonts(want interfaceFonts) error { if err != nil { return err } - han, err := hanFaces(want.language) - if err != nil { - return err + var han []font.FontFace + if want.han { + if han, err = hanFaces(want.language); err != nil { + return err + } } collection := append(append(append(gofont.Collection(), sans...), mono...), han...) - shaper := text.NewShaper(text.WithCollection(collection)) + shaper := text.NewShaper(text.WithCollection(collection), text.WithOutsideCollection(u.noteOutsideFaces)) u.th.Shaper, u.theme.Shaper = shaper, shaper u.theme.Sans, u.theme.Mono = sansTypeface, mono[0].Font.Typeface u.uiFonts = &want return nil } +// noteOutsideFaces hears of a rune the interface's faces lack, which the +// shaper meanwhile draws with a system font if one has it. The first Han, +// kana or Hangul has followLanguage build the shaper again with the CJK +// faces; it runs inside a layout, which a new shaper must not replace, so it +// only marks it and asks for a frame. +func (u *window) noteOutsideFaces(r rune) { + if !unicode.In(r, unicode.Han, unicode.Hiragana, unicode.Katakana, unicode.Hangul) { + return + } + if u.cjkSeen.CompareAndSwap(false, true) { + u.win.Invalidate() + } +} + +func isCJKLanguage(l i18n.Locale) bool { + switch l { + case i18n.ZhCN, i18n.JA, i18n.KO: + return true + } + return false +} + // reloadInterfaceFonts builds the interface's shaper again from the same // stored fonts, for a CJK font downloaded since. func (u *window) reloadInterfaceFonts() error { diff --git a/native/cmd/lich-native/main.go b/native/cmd/lich-native/main.go index 8efc077ed..89f507e15 100644 --- a/native/cmd/lich-native/main.go +++ b/native/cmd/lich-native/main.go @@ -174,6 +174,8 @@ type window struct { // fontsLanguage is the language the interface's CJK faces were last // picked for. fontsLanguage i18n.Locale + // cjkSeen is set once the interface has had CJK to draw (noteOutsideFaces). + cjkSeen atomic.Bool // trayLanguage is the language the tray's words were last sent in, unset // until the tray is up. trayLanguage atomic.Value diff --git a/native/cmd/lich-native/tray.go b/native/cmd/lich-native/tray.go index ead567c30..dd8e3b51b 100644 --- a/native/cmd/lich-native/tray.go +++ b/native/cmd/lich-native/tray.go @@ -43,7 +43,8 @@ func (u *window) attachTray() { // followLanguage runs each frame: what a table built once holds in the // interface language (the hotkeys' labels, the CJK faces) or hands the // backend (the tray's words) is renewed when Settings changed the language -// since the last frame. +// since the last frame, and the CJK faces join the interface's shaper on the +// frame after CJK text first came up. // Settings only sets the language, so the window notices rather than being // told. A send that fails is not retried until the language changes again. func (u *window) followLanguage() { @@ -52,7 +53,8 @@ func (u *window) followLanguage() { u.hotkeyLanguage = lang localizeHotkeys() } - if u.uiFonts != nil && lang != u.fontsLanguage { + cjkCame := u.uiFonts != nil && u.cjkSeen.Load() && !u.uiFonts.han + if u.uiFonts != nil && (lang != u.fontsLanguage || cjkCame) { u.fontsLanguage = lang if err := u.setInterfaceFonts(*u.uiFonts); err != nil { u.toaster.Show(fontFailure(i18n.T("settings.appearancePrefs.uiFontFailed", i18n.Args{"family": systemOr(u.uiFonts.sans, fonts.SystemSans), "error": backend.ErrorText(err)}))) diff --git a/native/go.mod b/native/go.mod index e9beb4c2b..140f4e8d2 100644 --- a/native/go.mod +++ b/native/go.mod @@ -31,8 +31,10 @@ require ( // deletion with Ctrl off macOS, Wayland fixes for EGL vsync, clipboard reads, // pointer.Leave and the cursor (cursor-shape-v1), an undecorated window that // moves, resizes and maximizes on Wayland and X11, app.TrafficLights (macOS's -// buttons on an undecorated window), and the Editor's scroll offset (the -// textarea's scrollbar). Every patch of ours lands on that branch. -replace gioui.org => github.com/lichdotdev/gio v0.0.0-20261022192911-12496229bb5d +// buttons on an undecorated window), the Editor's scroll offset (the +// textarea's scrollbar), and text.WithOutsideCollection (the runes a shaper's +// faces lack, which loads the CJK faces when CJK text first comes up). Every +// patch of ours lands on that branch. +replace gioui.org => github.com/lichdotdev/gio v0.0.0-20261023212558-16fb17377a9b replace github.com/lichdotdev/lich => ../ diff --git a/native/go.sum b/native/go.sum index e57068a02..2cc9b767a 100644 --- a/native/go.sum +++ b/native/go.sum @@ -25,8 +25,8 @@ github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= -github.com/lichdotdev/gio v0.0.0-20261022192911-12496229bb5d h1:L1XlAjanPzBJJyJgnNlGRUxyLOHJ/0SX3Yo3mXIMDDY= -github.com/lichdotdev/gio v0.0.0-20261022192911-12496229bb5d/go.mod h1:x8MAOooc/v4UUaB5o6BS/FpYdxnzh3LBvZlTBmYpnlQ= +github.com/lichdotdev/gio v0.0.0-20261023212558-16fb17377a9b h1:UVVnSdXx2AB28Dii7hxNpR7WbECu05BHCcvzpON3vDw= +github.com/lichdotdev/gio v0.0.0-20261023212558-16fb17377a9b/go.mod h1:x8MAOooc/v4UUaB5o6BS/FpYdxnzh3LBvZlTBmYpnlQ= github.com/lichdotdev/godemirror v0.8.1 h1:6Hgi0wrCOcpPhOPJFfID7l+vmR08RMYhPDChyzopBdM= github.com/lichdotdev/godemirror v0.8.1/go.mod h1:xCdFPbos7+4/bCymJIx7FnL4EpsuMpebRCbGM5Un4fg= github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=