Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions native/cmd/lich-native/appearance_han_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
41 changes: 35 additions & 6 deletions native/cmd/lich-native/appearance_prefs.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"log"
"math"
"strconv"
"unicode"

"gioui.org/app"
"gioui.org/font"
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand All @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions native/cmd/lich-native/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions native/cmd/lich-native/tray.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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)})))
Expand Down
41 changes: 35 additions & 6 deletions native/fonts/fonts.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (
"embed"
"fmt"
"os"
"sync"
"weak"

"gioui.org/font"
"gioui.org/font/opentype"
Expand Down Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions native/fonts/fonts_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package fonts

import (
"runtime"
"testing"

"gioui.org/font"
Expand Down Expand Up @@ -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")
}
}
8 changes: 5 additions & 3 deletions native/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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 => ../
4 changes: 2 additions & 2 deletions native/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
Loading