diff --git a/README.md b/README.md index 14c26c4..e3ca52a 100644 --- a/README.md +++ b/README.md @@ -80,10 +80,8 @@ Prefer this over loosening the permissions on your home directory. ### First launch: unsigned builds -Releases are **not code-signed** (that requires a paid Apple developer -account and a Windows certificate). The binaries are fine; the OS just -can't verify who made them, so it warns on first launch. -And all installation packages are built exclusively by the GitHub free tier CI/CD +Releases are **not code-signed**, where signing requires a paid Apple developer +account and a Windows certificate. All installation packages are built exclusively by the GitHub CI/CD pipeline. - **macOS** — right-click the app and choose *Open*, or: diff --git a/packaging/macos/make-dmg.sh b/packaging/macos/make-dmg.sh index 2698c1f..c886349 100755 --- a/packaging/macos/make-dmg.sh +++ b/packaging/macos/make-dmg.sh @@ -62,6 +62,7 @@ cat > "build/${BUNDLE}/Contents/Info.plist" <CFBundleTypeExtensions txt + json log t diff --git a/src/internal/app/app.go b/src/internal/app/app.go index bf63537..473f2ad 100644 --- a/src/internal/app/app.go +++ b/src/internal/app/app.go @@ -7,6 +7,7 @@ package editorapp import ( "image" + "strconv" "time" gioapp "gioui.org/app" @@ -139,6 +140,7 @@ func (a *App) Run(win *gioapp.Window) error { // doesn't have to wait for a frame to be scheduled before it's processed. func (a *App) layout(gtx layout.Context) { size := gtx.Constraints.Max + a.syncGuiFont(a.state.GuiFont()) a.syncMetrics(gtx) a.handleInput(gtx) @@ -157,6 +159,36 @@ func (a *App) layout(gtx layout.Context) { render.Frame(gtx, a.fonts, snap) } +func (a *App) syncGuiFont(guiFont string) { + size, ok := guiFontSize(guiFont) + if !ok || a.fonts.Size == unit.Sp(size) { + return + } + a.fonts.Size = unit.Sp(size) + a.fonts.Metrics = render.Metrics{} +} + +func guiFontSize(guiFont string) (float32, bool) { + for i := 0; i+2 < len(guiFont); i++ { + if guiFont[i] != ':' || (guiFont[i+1] != 'h' && guiFont[i+1] != 'H') { + continue + } + start := i + 2 + end := start + for end < len(guiFont) && ((guiFont[end] >= '0' && guiFont[end] <= '9') || guiFont[end] == '.') { + end++ + } + if end == start { + continue + } + parsed, err := strconv.ParseFloat(guiFont[start:end], 32) + if err == nil && parsed > 0 { + return float32(parsed), true + } + } + return 0, false +} + // syncMetrics (re-)measures the cell grid whenever the pixel density the // window is being rendered at changes. // diff --git a/src/internal/app/metricsync_test.go b/src/internal/app/metricsync_test.go index 5d654df..f278b90 100644 --- a/src/internal/app/metricsync_test.go +++ b/src/internal/app/metricsync_test.go @@ -119,3 +119,35 @@ func TestGridResizesAfterScaleChange(t *testing.T) { windowPx, cols1x, cols2x) } } + +func TestGuiFontSizeInvalidatesMetrics(t *testing.T) { + a := newMeasuredApp() + var ops op.Ops + a.syncMetrics(frameAt(&ops, 1)) + before := a.fonts.Metrics + + a.syncGuiFont("Hack Nerd Font Mono:h18:b") + if got, want := a.fonts.Size, unit.Sp(18); got != want { + t.Fatalf("font size = %v, want %v", got, want) + } + if a.fonts.Metrics.CellWidth != 0 || a.fonts.Metrics.CellHeight != 0 { + t.Fatalf("metrics were not invalidated: %+v", a.fonts.Metrics) + } + + a.syncMetrics(frameAt(&ops, 1)) + if a.fonts.Metrics.CellWidth <= before.CellWidth || a.fonts.Metrics.CellHeight <= before.CellHeight { + t.Errorf("cells did not grow after guifont update: before=%+v after=%+v", before, a.fonts.Metrics) + } +} + +func TestGuiFontSizeIgnoresInvalidValues(t *testing.T) { + a := newMeasuredApp() + want := a.fonts.Size + + for _, guiFont := range []string{"", "Hack Nerd Font Mono", "Hack Nerd Font Mono:h0:b", "Hack Nerd Font Mono:hnope:b"} { + a.syncGuiFont(guiFont) + if a.fonts.Size != want { + t.Errorf("syncGuiFont(%q) changed size to %v, want %v", guiFont, a.fonts.Size, want) + } + } +} diff --git a/src/internal/app/openfile_darwin.m b/src/internal/app/openfile_darwin.c similarity index 87% rename from src/internal/app/openfile_darwin.m rename to src/internal/app/openfile_darwin.c index 7012ca3..4d44e5f 100644 --- a/src/internal/app/openfile_darwin.m +++ b/src/internal/app/openfile_darwin.c @@ -1,7 +1,11 @@ // Apple Event handler for Finder's "open document" ('odoc') event. // -// This lives in a real .m file rather than in the cgo preamble of -// openfile_darwin.go. A preamble is textually prepended to *every* C +// This file contains Objective-C and is compiled as such by the package's +// -x objective-c CFLAGS. Keeping the .c extension prevents cgo from adding +// another -lobjc when Gio already supplies the Objective-C runtime. +// +// This lives in a separate translation unit rather than in the cgo preamble +// of openfile_darwin.go. A preamble is textually prepended to *every* C // translation unit cgo generates for that package, so any function or ObjC // class *defined* (not merely declared) there is compiled more than once and // the link fails with duplicate symbols: @@ -11,7 +15,7 @@ // // That is guaranteed to happen once the same file also uses //export, because // cgo then emits an extra translation unit for the exported thunks. The rule -// is: preambles declare, .m/.c files define. +// is: preambles declare, separate translation units define. #import @@ -80,4 +84,4 @@ void snv_install_open_file_handler(void) { forEventClass:kCoreEventClass andEventID:kAEOpenDocuments]; }); -} +} \ No newline at end of file diff --git a/src/internal/app/openfile_darwin.go b/src/internal/app/openfile_darwin.go index ff43041..23e5c3c 100644 --- a/src/internal/app/openfile_darwin.go +++ b/src/internal/app/openfile_darwin.go @@ -6,7 +6,7 @@ package editorapp #cgo CFLAGS: -x objective-c -fmodules -fobjc-arc #cgo LDFLAGS: -framework Cocoa -// Declarations only. The implementation lives in openfile_darwin.m -- +// Declarations only. The implementation lives in openfile_darwin.c -- // this preamble is prepended to every translation unit cgo generates for // the package, so defining the class or function here would compile them // more than once and fail the link with duplicate symbols. diff --git a/src/internal/uistate/options_test.go b/src/internal/uistate/options_test.go new file mode 100644 index 0000000..453c7c4 --- /dev/null +++ b/src/internal/uistate/options_test.go @@ -0,0 +1,26 @@ +package uistate + +import "testing" + +func TestOptionSetStoresGuiFont(t *testing.T) { + state := New() + state.Apply([][]interface{}{ + {"option_set", []interface{}{"guifont", "Hack Nerd Font Mono:h17:b"}}, + }) + + if got, want := state.GuiFont(), "Hack Nerd Font Mono:h17:b"; got != want { + t.Fatalf("GuiFont = %q, want %q", got, want) + } +} + +func TestOptionSetIgnoresOtherOptionsAndMalformedRows(t *testing.T) { + state := New() + state.Apply([][]interface{}{ + {"option_set", []interface{}{"guifont", "monospace:h14"}}, + {"option_set", []interface{}{"linespace", 2}, "invalid"}, + }) + + if got, want := state.GuiFont(), "monospace:h14"; got != want { + t.Fatalf("GuiFont = %q, want %q", got, want) + } +} diff --git a/src/internal/uistate/state.go b/src/internal/uistate/state.go index f5fe43f..7d2045d 100644 --- a/src/internal/uistate/state.go +++ b/src/internal/uistate/state.go @@ -20,7 +20,8 @@ type State struct { hl *HighlightTable cursor Cursor - title string + title string + guiFont string mode string modeIdx int @@ -88,6 +89,8 @@ func (s *State) Apply(batch [][]interface{}) bool { s.applyModeChange(args) case "set_title": s.applySetTitle(args) + case "option_set": + s.applyOptionSet(args) case "busy_start": s.busy = true case "busy_stop": @@ -155,6 +158,27 @@ func (s *State) Snapshot() Snapshot { } } +// GuiFont returns the latest guifont value reported by Nvim. +func (s *State) GuiFont() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.guiFont +} + +func (s *State) applyOptionSet(args []interface{}) { + for _, arg := range args { + row, ok := arg.([]interface{}) + if !ok || len(row) < 2 { + continue + } + name, nameOK := row[0].(string) + value, valueOK := row[1].(string) + if name == "guifont" && nameOK && valueOK { + s.guiFont = value + } + } +} + func (s *State) applySetTitle(args []interface{}) { if len(args) == 0 { return diff --git a/src/test/integration/nvimproc_test.go b/src/test/integration/nvimproc_test.go index e2172ed..552a853 100644 --- a/src/test/integration/nvimproc_test.go +++ b/src/test/integration/nvimproc_test.go @@ -130,6 +130,37 @@ func TestSpawnAttachesAndRendersFileContent(t *testing.T) { waitForLine(t, proc, s, "hello integration test") } +func TestGuiFontOptionChangeReachesUIState(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "guifont.txt") + if err := os.WriteFile(file, []byte("font test\n"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + proc := spawnIsolated(t, file, 40, 10) + state := uistate.New() + const want = "monospace:h19" + if err := proc.Nvim.Command("set guifont=" + want); err != nil { + t.Fatalf("set guifont: %v", err) + } + + deadline := time.After(drainTimeout) + for state.GuiFont() != want { + select { + case batch, ok := <-proc.Redraw: + if !ok { + t.Fatal("Nvim redraw stream closed before guifont update arrived") + } + needsFrame := state.Apply(batch) + if state.GuiFont() == want && !needsFrame { + t.Fatal("guifont update did not request a frame") + } + case <-deadline: + t.Fatalf("timed out after %s waiting for guifont %q; got %q", drainTimeout, want, state.GuiFont()) + } + } +} + func TestInputIsReflectedInBothOurGridAndRealNvimBuffer(t *testing.T) { dir := t.TempDir() file := filepath.Join(dir, "edit.txt") diff --git a/src/test/unit/keymap_test.go b/src/test/unit/keymap_test.go index 89b522e..40d308c 100644 --- a/src/test/unit/keymap_test.go +++ b/src/test/unit/keymap_test.go @@ -79,6 +79,9 @@ func TestEncodeKeyModifierCombinations(t *testing.T) { }{ {"ctrl+letter", key.Event{Name: "A", Modifiers: key.ModCtrl, State: key.Press}, ""}, {"ctrl+digit", key.Event{Name: "1", Modifiers: key.ModCtrl, State: key.Press}, ""}, + {"ctrl+equal", key.Event{Name: "=", Modifiers: key.ModCtrl, State: key.Press}, ""}, + {"ctrl+minus", key.Event{Name: "-", Modifiers: key.ModCtrl, State: key.Press}, ""}, + {"ctrl+zero", key.Event{Name: "0", Modifiers: key.ModCtrl, State: key.Press}, ""}, {"command (mac cmd) + letter", key.Event{Name: "A", Modifiers: key.ModCommand, State: key.Press}, ""}, {"super (win/linux logo) + letter", key.Event{Name: "A", Modifiers: key.ModSuper, State: key.Press}, ""}, {"alt + letter", key.Event{Name: "A", Modifiers: key.ModAlt, State: key.Press}, ""},