From 0a55d5528027e3b35b93528f372f6514af92b569 Mon Sep 17 00:00:00 2001 From: Charlie Knudsen Date: Fri, 1 May 2026 21:02:51 -0500 Subject: [PATCH 1/2] Add cell metric adjusters and font-thicken for text tuning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manual testing of #11 on macOS surfaced two remaining gaps versus cmux/ghostty: line spacing is tight (we take Pango's natural cell height with no padding), and glyph strokes look slightly thinner on Mac (Cairo doesn't apply Apple Core Text stem darkening). Both are exactly what Ghostty's font-tuning knobs are designed for. This follow-up exposes the subset that's actually about text appearance and stops there — cursor / underline / strikethrough / box-thickness adjusters are TUI alignment and stay deferred. Four new config keys, all default no-op: adjust_cell_height Ghostty syntax: 2, 2px, 10%, negatives ok adjust_cell_width same adjust_font_baseline same; vertical glyph fine-tune on top of auto-center font_thicken bool; double-draws each glyph at +0.5px X to fatten strokes A new internal/config Adjust type carries the parsed value so the application site can apply it once the natural metric is known. Apply clamps to a minimum of 1 px; Delta returns just the signed offset, useful for the glyph-y-shift math. Wiring: - measureCells applies AdjustCellWidth/Height to natural cellW/cellH, computes glyphYOffset = (cellH - naturalH)/2 + AdjustFontBaseline. The auto-center half ensures glyphs stay vertically centered when cell height grows; AdjustFontBaseline is the user's bias on top. - render.go's two glyph-paint sites pick up glyphYOffset; backgrounds, cursor rect, and selection rects stay anchored to the cell grid. - font_thicken routes both glyph paints through showGlyphLayout, which conditionally repaints the glyph at +0.5px X. Documentation: docs/reference/fonts.md gains a Cell tuning section with the four knobs, syntax, and a "Targeting the cmux look on macOS" preset (font_family = Menlo, font_size = 11, adjust_cell_height = 2px, font_thicken = true) drawn directly from the testing comparison. Tests cover the parser (px/percent/negative/empty/malformed), the config-side wiring, BuildFontConfig pass-through, and Adjust.Apply clamping. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/roost/font.go | 26 ++++++-- cmd/roost/font_test.go | 23 +++++++ cmd/roost/render.go | 21 ++++-- cmd/roost/session.go | 34 ++++++++-- docs/reference/fonts.md | 30 ++++++++- internal/config/adjust.go | 86 +++++++++++++++++++++++++ internal/config/adjust_test.go | 114 +++++++++++++++++++++++++++++++++ internal/config/config.go | 43 +++++++++++++ internal/config/config_test.go | 75 ++++++++++++++++++++++ 9 files changed, 434 insertions(+), 18 deletions(-) create mode 100644 internal/config/adjust.go create mode 100644 internal/config/adjust_test.go diff --git a/cmd/roost/font.go b/cmd/roost/font.go index 66756996..33936eae 100644 --- a/cmd/roost/font.go +++ b/cmd/roost/font.go @@ -23,6 +23,18 @@ type FontConfig struct { SizePt int // current point size; mutable per-tab via AdjustFontSize Features []string // OpenType feature tags (e.g. "-calt", "+ss01") Options pangoextra.FontOptions // Cairo hint/AA settings; user values override defaults + + // AdjustCellWidth, AdjustCellHeight, AdjustFontBaseline are + // Ghostty-style cell metric tweaks. Applied in measureCells. Empty + // (the default) is no-op so existing configs are unchanged. + AdjustCellWidth config.Adjust + AdjustCellHeight config.Adjust + AdjustFontBaseline config.Adjust + + // FontThicken triggers double-draw glyph rendering at a 0.5px + // horizontal offset. Approximates Apple Core Text stem darkening + // for non-Apple rendering pipelines (notably Cairo on macOS). + FontThicken bool } // BuildFontConfig assembles a FontConfig from the user config layered on @@ -39,11 +51,15 @@ func BuildFontConfig(cfg config.Config) FontConfig { opts.HintMetrics = v } return FontConfig{ - Family: cfg.FontFamily, - FamilyBold: cfg.FontFamilyBold, - SizePt: cfg.FontSizePt, - Features: append([]string(nil), cfg.FontFeatures...), - Options: opts, + Family: cfg.FontFamily, + FamilyBold: cfg.FontFamilyBold, + SizePt: cfg.FontSizePt, + Features: append([]string(nil), cfg.FontFeatures...), + Options: opts, + AdjustCellWidth: cfg.AdjustCellWidth, + AdjustCellHeight: cfg.AdjustCellHeight, + AdjustFontBaseline: cfg.AdjustFontBaseline, + FontThicken: cfg.FontThicken, } } diff --git a/cmd/roost/font_test.go b/cmd/roost/font_test.go index 11db390f..d9bf0ea7 100644 --- a/cmd/roost/font_test.go +++ b/cmd/roost/font_test.go @@ -91,6 +91,29 @@ func TestBuildFontConfigEmptyOverridesKeepDefaults(t *testing.T) { } } +func TestBuildFontConfigCarriesAdjusters(t *testing.T) { + cfg := config.Defaults() + cfg.AdjustCellWidth = config.Adjust{Mode: config.AdjustModePixels, Value: 2} + cfg.AdjustCellHeight = config.Adjust{Mode: config.AdjustModePercent, Value: 10} + cfg.AdjustFontBaseline = config.Adjust{Mode: config.AdjustModePixels, Value: -1} + cfg.FontThicken = true + + fc := BuildFontConfig(cfg) + + if fc.AdjustCellWidth != cfg.AdjustCellWidth { + t.Errorf("AdjustCellWidth not carried: %+v", fc.AdjustCellWidth) + } + if fc.AdjustCellHeight != cfg.AdjustCellHeight { + t.Errorf("AdjustCellHeight not carried: %+v", fc.AdjustCellHeight) + } + if fc.AdjustFontBaseline != cfg.AdjustFontBaseline { + t.Errorf("AdjustFontBaseline not carried: %+v", fc.AdjustFontBaseline) + } + if !fc.FontThicken { + t.Errorf("FontThicken not carried") + } +} + func TestJoinedFeaturesEmpty(t *testing.T) { fc := FontConfig{} if got := fc.JoinedFeatures(); got != "" { diff --git a/cmd/roost/render.go b/cmd/roost/render.go index 976c81e6..ba5de992 100644 --- a/cmd/roost/render.go +++ b/cmd/roost/render.go @@ -4,11 +4,26 @@ import ( "log/slog" "github.com/diamondburned/gotk4/pkg/cairo" + "github.com/diamondburned/gotk4/pkg/pango" "github.com/diamondburned/gotk4/pkg/pangocairo" "github.com/charliek/roost/internal/ghostty" ) +// showGlyphLayout positions the layout at (x, y) and paints it. When +// thicken is true the glyph is painted again at (x+0.5, y) — a +// poor-man's stem darkening that approximates Apple's Core Text +// behavior on rendering pipelines (notably Cairo on macOS) that don't +// apply the darkening natively. +func showGlyphLayout(cr *cairo.Context, layout *pango.Layout, x, y float64, thicken bool) { + cr.MoveTo(x, y) + pangocairo.ShowLayout(cr, layout) + if thicken { + cr.MoveTo(x+0.5, y) + pangocairo.ShowLayout(cr, layout) + } +} + // drawTerminal walks the session's render state and paints the cell // grid into the Cairo context. Called from GtkDrawingArea's draw // function on the main thread. @@ -95,8 +110,7 @@ func drawTerminal(cr *cairo.Context, s *Session) { textBuf = appendRune(textBuf, cell.Codepoint) layout.SetText(string(textBuf)) setRGB(cr, fg) - cr.MoveTo(x, y) - pangocairo.ShowLayout(cr, layout) + showGlyphLayout(cr, layout, x, y+float64(s.glyphYOffset), s.fontCfg.FontThicken) if cell.Bold { layout.SetFontDescription(s.font) } @@ -150,8 +164,7 @@ func drawTerminal(cr *cairo.Context, s *Session) { } layout.SetText(string(textBuf)) setRGB(cr, cursorText) - cr.MoveTo(x, y) - pangocairo.ShowLayout(cr, layout) + showGlyphLayout(cr, layout, x, y+float64(s.glyphYOffset), s.fontCfg.FontThicken) } } } diff --git a/cmd/roost/session.go b/cmd/roost/session.go index 52cd524b..82b8bdb6 100644 --- a/cmd/roost/session.go +++ b/cmd/roost/session.go @@ -44,8 +44,14 @@ type Session struct { defaultFontSize int // for ResetFontSize; immutable after construction cellW int cellH int - cols uint16 - rows uint16 + // glyphYOffset shifts glyph drawing inside the cell vertically. + // Auto-set when AdjustCellHeight grows the cell so glyphs stay + // vertically centered, plus AdjustFontBaseline as a fine-tune. + // Backgrounds, cursor rect, and selection rects ignore it — they + // stay anchored to the cell grid. + glyphYOffset int + cols uint16 + rows uint16 // theme is the source of truth for fg/bg/cursor/palette across this // session. It is what gets pushed into libghostty (SetTheme) and @@ -496,9 +502,9 @@ func (s *Session) pumpPTY() { } // measureCells uses Pango to size one monospace cell. Called once at -// construction; the renderer reads cellW/cellH on every draw. Stored -// as ints so cell origins land on integer pixel boundaries (text -// crispness). +// construction and again whenever the font changes (e.g. AdjustFontSize). +// The renderer reads cellW/cellH/glyphYOffset on every draw. Stored as +// ints so cell origins land on integer pixel boundaries (text crispness). func (s *Session) measureCells() { ctx := s.da.PangoContext() @@ -509,7 +515,6 @@ func (s *Session) measureCells() { if w < 1 { w = 8 } - s.cellW = w // Height: prefer the font's recommended line-height (distance // between baselines, includes any line-gap the font designer set), @@ -527,7 +532,22 @@ func (s *Session) measureCells() { if h < 1 { h = 16 } - s.cellH = h + + // Apply the user's cell-metric adjusters. Empty (the default) is a + // no-op so existing configs render identically. The natural metric + // is also the reference for the percent mode, so it must come + // before Apply. + naturalH := h + s.cellW = s.fontCfg.AdjustCellWidth.Apply(w) + s.cellH = s.fontCfg.AdjustCellHeight.Apply(h) + + // Auto-center glyphs when AdjustCellHeight grows the cell so they + // don't stick to the top with a big gap below. Extra height splits + // evenly above and below the natural glyph box. AdjustFontBaseline + // is an additional fine-tune on top, computed against the natural + // height so percent values are predictable across font sizes. + extraH := s.cellH - naturalH + s.glyphYOffset = extraH/2 + s.fontCfg.AdjustFontBaseline.Delta(naturalH) } // checkTitleAndPWD polls the terminal's OSC-set title and cwd after a diff --git a/docs/reference/fonts.md b/docs/reference/fonts.md index 71ae8715..af8486cc 100644 --- a/docs/reference/fonts.md +++ b/docs/reference/fonts.md @@ -23,6 +23,32 @@ font_feature = -calt Empty string and `default` both mean "use the platform default for this setting." +## Cell tuning + +Four additional knobs adjust the cell grid and glyph rendering. Useful when matching the look of another terminal (cmux, ghostty, iTerm) where the natural Pango/Cairo metrics feel a touch tight or thin. All take effect on the next launch. + +| Key | Value syntax | Effect | +|------------------------|-------------------------------------------|-----------------------------------------------------------------------------------------| +| `adjust_cell_height` | `2`, `2px`, `10%`, `-1`, `-5%` | Add or subtract from the natural cell height. Positive values add line spacing; glyphs auto-center in the enlarged cell. | +| `adjust_cell_width` | same syntax | Add or subtract from the natural cell width (letter spacing). | +| `adjust_font_baseline` | same syntax | Shift glyphs vertically inside the cell. A fine-tune *after* `adjust_cell_height` — leave it unset until you need to bias the glyph up or down. | +| `font_thicken` | `true` / `false` (default `false`) | Render each glyph twice with a 0.5 px horizontal offset, fattening strokes. Approximates Apple Core Text stem darkening for pipelines that don't apply it natively (notably Cairo on macOS). Not a perfect parity with Apple's algorithm. | + +A bare integer means pixels (`2` is the same as `2px`). A trailing `%` means a signed percentage of the natural metric. Negative values shrink. The cell metrics are clamped to a minimum of 1 px so a runaway negative can't crash the geometry. + +### Targeting the cmux look on macOS + +The default config aims at ghostty-style polish with modern programming fonts. To get closer to cmux/Terminal.app's look — Menlo at a smaller size with a touch more line spacing and Apple-like stem weight — try: + +```conf +font_family = Menlo +font_size = 11 +adjust_cell_height = 2px +font_thicken = true +``` + +Eyeball alongside cmux and adjust `adjust_cell_height` and `font_size` to taste. + ## Tuning for crisp text The defaults aim at the cmux/ghostty look: cell-snapped metrics, grayscale AA, light-or-no hinting depending on platform. Tweak from there: @@ -48,7 +74,7 @@ The defaults aim at the cmux/ghostty look: cell-snapped metrics, grayscale AA, l ## Limitations - **Italic family is not configurable yet.** `font_family_italic` is reserved. -- **Ghostty's `adjust-cell-*` metric tweaks are not exposed.** Cell width / height / cursor thickness adjustments come later if needed. +- **Cursor / underline / strikethrough thickness adjusters are not exposed.** Only the cell, baseline, and stem-thicken knobs land here; the TUI-alignment family of `adjust_cursor_*`, `adjust_underline_*`, `adjust_strikethrough_*`, and `adjust_box_thickness` are deferred. - **Sidebar and tab-label fonts use GTK's UI font.** Only the terminal cell font is configurable. -- **`hint_metrics`, `hint_style`, and `antialias` require restart.** Only size responds to runtime hotkeys. +- **All restart-required except size hotkeys.** `Cmd-+/-/0` rescales live; every other knob (family, features, AA, hint, cell adjusters, font-thicken) takes effect on next launch. - **Cairo font option control is implemented via a small cgo wrapper** (`internal/pangoextra`) because gotk4's `pangocairo.ContextSetFontOptions` binding crashes. See [Architecture](architecture.md) for the package layout. diff --git a/internal/config/adjust.go b/internal/config/adjust.go new file mode 100644 index 00000000..11a470c0 --- /dev/null +++ b/internal/config/adjust.go @@ -0,0 +1,86 @@ +package config + +import ( + "fmt" + "math" + "strconv" + "strings" +) + +// AdjustMode discriminates how an Adjust applies to a natural metric. +type AdjustMode int + +const ( + // AdjustModeNone means "leave the natural metric unchanged." + AdjustModeNone AdjustMode = iota + // AdjustModePixels adds the signed value (in display pixels) to + // the natural metric. + AdjustModePixels + // AdjustModePercent adds the natural metric scaled by the signed + // percentage (e.g. 10 means +10%, -5 means -5%). + AdjustModePercent +) + +// Adjust is a Ghostty-style cell metric tweak: zero, an absolute pixel +// offset, or a percentage of the natural metric. Stored on Config so +// the application site (cmd/roost) can apply it once it knows the +// natural value. +type Adjust struct { + Mode AdjustMode + Value float64 +} + +// ParseAdjust parses Ghostty's adjust-* value syntax: +// +// - "" (empty) → no-op (AdjustModeNone) +// - "2" or "2px" or "-3px" → AdjustModePixels with the signed integer +// - "10%" or "-5.5%" → AdjustModePercent with the signed float +// +// Whitespace around the value is tolerated. Anything else is rejected +// with an error that quotes the input so the config error message +// points at the malformed value. +func ParseAdjust(s string) (Adjust, error) { + s = strings.TrimSpace(s) + if s == "" { + return Adjust{Mode: AdjustModeNone}, nil + } + if rest, ok := strings.CutSuffix(s, "%"); ok { + v, err := strconv.ParseFloat(strings.TrimSpace(rest), 64) + if err != nil { + return Adjust{}, fmt.Errorf("not a valid percent value: %q", s) + } + return Adjust{Mode: AdjustModePercent, Value: v}, nil + } + rest := strings.TrimSuffix(s, "px") + v, err := strconv.Atoi(strings.TrimSpace(rest)) + if err != nil { + return Adjust{}, fmt.Errorf("expected an integer (optionally suffixed with px) or a percentage, got %q", s) + } + return Adjust{Mode: AdjustModePixels, Value: float64(v)}, nil +} + +// Apply returns natural with the adjustment baked in, clamped to a +// minimum of 1 so a too-aggressive negative adjust can't produce a +// zero-or-negative metric (which would crash downstream geometry math). +func (a Adjust) Apply(natural int) int { + v := natural + a.Delta(natural) + if v < 1 { + v = 1 + } + return v +} + +// Delta returns just the signed offset that would be added to natural, +// without summing it in. Used at sites where the offset itself is the +// useful value (e.g. computing a glyph y-shift relative to the cell +// origin). For pixel mode the natural argument is ignored. +func (a Adjust) Delta(natural int) int { + switch a.Mode { + case AdjustModePixels: + return int(a.Value) + case AdjustModePercent: + return int(math.Round(float64(natural) * a.Value / 100.0)) + default: + return 0 + } +} diff --git a/internal/config/adjust_test.go b/internal/config/adjust_test.go new file mode 100644 index 00000000..477e46d7 --- /dev/null +++ b/internal/config/adjust_test.go @@ -0,0 +1,114 @@ +package config + +import ( + "strings" + "testing" +) + +func TestParseAdjustValid(t *testing.T) { + cases := []struct { + name string + in string + want Adjust + }{ + {"empty", "", Adjust{Mode: AdjustModeNone}}, + {"whitespace only", " ", Adjust{Mode: AdjustModeNone}}, + {"bare integer", "2", Adjust{Mode: AdjustModePixels, Value: 2}}, + {"px suffix", "2px", Adjust{Mode: AdjustModePixels, Value: 2}}, + {"negative bare", "-3", Adjust{Mode: AdjustModePixels, Value: -3}}, + {"negative px", "-3px", Adjust{Mode: AdjustModePixels, Value: -3}}, + {"zero px", "0px", Adjust{Mode: AdjustModePixels, Value: 0}}, + {"percent integer", "10%", Adjust{Mode: AdjustModePercent, Value: 10}}, + {"percent float", "12.5%", Adjust{Mode: AdjustModePercent, Value: 12.5}}, + {"percent negative", "-5%", Adjust{Mode: AdjustModePercent, Value: -5}}, + {"px with surrounding whitespace", " 4px ", Adjust{Mode: AdjustModePixels, Value: 4}}, + {"px with internal whitespace tolerated", "2 px", Adjust{Mode: AdjustModePixels, Value: 2}}, + {"percent with surrounding whitespace", " -2.5 %", Adjust{Mode: AdjustModePercent, Value: -2.5}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := ParseAdjust(tc.in) + if err != nil { + t.Fatalf("ParseAdjust(%q) err = %v", tc.in, err) + } + if got != tc.want { + t.Errorf("ParseAdjust(%q) = %+v, want %+v", tc.in, got, tc.want) + } + }) + } +} + +func TestParseAdjustInvalid(t *testing.T) { + cases := []string{ + "abc", + "2.5", // floats not allowed for pixel mode (px is integer) + "2.5px", // same + "px", // bare suffix + "%", // bare suffix + "two", // word + "++2", // double sign + "2pt", // wrong unit + "10 %xx", // trailing junk + } + for _, in := range cases { + t.Run(in, func(t *testing.T) { + _, err := ParseAdjust(in) + if err == nil { + t.Fatalf("ParseAdjust(%q) accepted, want error", in) + } + if !strings.Contains(err.Error(), in) && in != "" { + t.Errorf("ParseAdjust(%q) error %q should quote the input", in, err.Error()) + } + }) + } +} + +func TestAdjustApply(t *testing.T) { + cases := []struct { + name string + adj Adjust + natural int + want int + }{ + {"none keeps natural", Adjust{}, 16, 16}, + {"pixels add", Adjust{Mode: AdjustModePixels, Value: 4}, 16, 20}, + {"pixels subtract", Adjust{Mode: AdjustModePixels, Value: -3}, 16, 13}, + {"percent grow", Adjust{Mode: AdjustModePercent, Value: 25}, 16, 20}, + {"percent shrink", Adjust{Mode: AdjustModePercent, Value: -25}, 16, 12}, + {"percent rounds nearest", Adjust{Mode: AdjustModePercent, Value: 10}, 17, 19}, // 17 + 1.7 → round to 2 → 19 + {"clamp prevents zero", Adjust{Mode: AdjustModePixels, Value: -100}, 8, 1}, + {"clamp prevents negative", Adjust{Mode: AdjustModePercent, Value: -200}, 8, 1}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.adj.Apply(tc.natural); got != tc.want { + t.Errorf("Apply(%d) over %+v = %d, want %d", tc.natural, tc.adj, got, tc.want) + } + }) + } +} + +func TestAdjustDelta(t *testing.T) { + // Delta returns just the signed offset; ignores natural for pixel mode. + cases := []struct { + name string + adj Adjust + natural int + want int + }{ + {"none", Adjust{}, 16, 0}, + {"pixels positive", Adjust{Mode: AdjustModePixels, Value: 5}, 16, 5}, + {"pixels negative", Adjust{Mode: AdjustModePixels, Value: -2}, 16, -2}, + {"pixels ignores natural", Adjust{Mode: AdjustModePixels, Value: 3}, 9999, 3}, + {"percent of natural", Adjust{Mode: AdjustModePercent, Value: 50}, 10, 5}, + {"percent rounds", Adjust{Mode: AdjustModePercent, Value: 33}, 10, 3}, // 3.3 rounds to 3 + {"percent half rounds away from zero", Adjust{Mode: AdjustModePercent, Value: 25}, 10, 3}, // 2.5 → 3 (math.Round) + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.adj.Delta(tc.natural); got != tc.want { + t.Errorf("Delta(%d) over %+v = %d, want %d", tc.natural, tc.adj, got, tc.want) + } + }) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 4c055c2e..aeeb19c2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -50,6 +50,25 @@ type Config struct { HintStyle string Antialias string + // AdjustCellWidth, AdjustCellHeight, and AdjustFontBaseline are + // Ghostty-style cell metric tweaks. Empty (the default) is no-op. + // AdjustCellHeight closes the line-spacing gap with cmux/ghostty; + // AdjustCellWidth widens letter spacing; AdjustFontBaseline shifts + // glyphs vertically inside the (possibly enlarged) cell as a + // fine-tune on top of the auto-centering applied when AdjustCellHeight + // is positive. + AdjustCellWidth Adjust + AdjustCellHeight Adjust + AdjustFontBaseline Adjust + + // FontThicken approximates Apple's Core Text stem darkening for + // non-Apple rendering pipelines: each glyph is drawn twice with a + // 0.5px horizontal offset, fattening strokes by roughly half a + // pixel. Default false; opt in if grayscale rendering looks too + // thin (most relevant on macOS where Cairo doesn't apply Apple's + // stem darkening). + FontThicken bool + // Theme is the name of a bundled color theme (e.g. "roost-dark", // "Dracula+"). Names match the filenames under cmd/roost/themes/, // which mirror ghostty's themes/ directory exactly. Unknown names @@ -143,6 +162,30 @@ func (p Paths) Load() (Config, error) { return cfg, fmt.Errorf("config: %s:%d: antialias: %q not in {none, gray, subpixel, default}", p.ConfigFile(), lineNum, val) } cfg.Antialias = val + case "adjust_cell_width": + a, aerr := ParseAdjust(val) + if aerr != nil { + return cfg, fmt.Errorf("config: %s:%d: adjust_cell_width: %w", p.ConfigFile(), lineNum, aerr) + } + cfg.AdjustCellWidth = a + case "adjust_cell_height": + a, aerr := ParseAdjust(val) + if aerr != nil { + return cfg, fmt.Errorf("config: %s:%d: adjust_cell_height: %w", p.ConfigFile(), lineNum, aerr) + } + cfg.AdjustCellHeight = a + case "adjust_font_baseline": + a, aerr := ParseAdjust(val) + if aerr != nil { + return cfg, fmt.Errorf("config: %s:%d: adjust_font_baseline: %w", p.ConfigFile(), lineNum, aerr) + } + cfg.AdjustFontBaseline = a + case "font_thicken": + b, berr := strconv.ParseBool(val) + if berr != nil { + return cfg, fmt.Errorf("config: %s:%d: font_thicken: %w", p.ConfigFile(), lineNum, berr) + } + cfg.FontThicken = b case "keybind": kb, kerr := parseKeybind(val) if kerr != nil { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index ea06c258..3e00611c 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -262,6 +262,81 @@ func TestLoadAntialiasInvalid(t *testing.T) { } } +func TestLoadAdjustKeysValid(t *testing.T) { + p := writeConfig(t, ""+ + "adjust_cell_width = 2px\n"+ + "adjust_cell_height = 10%\n"+ + "adjust_font_baseline = -1\n") + cfg, err := p.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.AdjustCellWidth != (Adjust{Mode: AdjustModePixels, Value: 2}) { + t.Errorf("AdjustCellWidth: %+v", cfg.AdjustCellWidth) + } + if cfg.AdjustCellHeight != (Adjust{Mode: AdjustModePercent, Value: 10}) { + t.Errorf("AdjustCellHeight: %+v", cfg.AdjustCellHeight) + } + if cfg.AdjustFontBaseline != (Adjust{Mode: AdjustModePixels, Value: -1}) { + t.Errorf("AdjustFontBaseline: %+v", cfg.AdjustFontBaseline) + } +} + +func TestLoadAdjustEmptyKeepsNoOp(t *testing.T) { + // Defaults() leaves the Adjust fields zero-valued (AdjustModeNone). + // An empty value in the config file should round-trip the same way. + p := writeConfig(t, ""+ + "adjust_cell_width = \n"+ + "adjust_cell_height = \n"+ + "adjust_font_baseline = \n") + cfg, err := p.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.AdjustCellWidth.Mode != AdjustModeNone || + cfg.AdjustCellHeight.Mode != AdjustModeNone || + cfg.AdjustFontBaseline.Mode != AdjustModeNone { + t.Errorf("blank adjust values should remain no-op, got %+v", cfg) + } +} + +func TestLoadAdjustInvalid(t *testing.T) { + p := writeConfig(t, "adjust_cell_height = nonsense\n") + if _, err := p.Load(); err == nil { + t.Fatalf("expected error for invalid adjust_cell_height value") + } else if !strings.Contains(err.Error(), "adjust_cell_height") { + t.Errorf("error doesn't mention adjust_cell_height: %v", err) + } +} + +func TestLoadFontThicken(t *testing.T) { + cases := map[string]bool{ + "font_thicken = true\n": true, + "font_thicken = false\n": false, + "font_thicken = 1\n": true, + "font_thicken = 0\n": false, + } + for body, want := range cases { + t.Run(body, func(t *testing.T) { + p := writeConfig(t, body) + cfg, err := p.Load() + if err != nil { + t.Fatalf("Load %q: %v", body, err) + } + if cfg.FontThicken != want { + t.Errorf("Load %q: got FontThicken=%v want %v", body, cfg.FontThicken, want) + } + }) + } +} + +func TestLoadFontThickenInvalid(t *testing.T) { + p := writeConfig(t, "font_thicken = sometimes\n") + if _, err := p.Load(); err == nil { + t.Fatalf("expected error for invalid font_thicken value") + } +} + // TestLoadKeybindTrailingHashNotStripped pins the parser's behavior // when a `#` appears after the action — it is NOT treated as an inline // comment, so it ends up as part of the action string. Documentation From 32f2ec75bb4a6f574658899e82bb3fd4a8f23010 Mon Sep 17 00:00:00 2001 From: Charlie Knudsen Date: Fri, 1 May 2026 21:32:52 -0500 Subject: [PATCH 2/2] Make adjust_cell_width and adjust_cell_height default to 2px MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pango's natural cell metrics are tighter than mainstream terminals (cmux, ghostty, iTerm, Terminal.app) which all add a small amount of cell padding. Side-by-side testing on macOS confirmed +2px on both axes lands roost in the same visual zone with no font swap. Setting tasteful defaults here saves every user from discovering and tuning the same knobs. Opt out with `adjust_cell_* = 0` (or any other value) — the parser still treats an explicit blank value as "back to no-op" so the override semantics are intact. Knock-on changes: - TestLoadDefaultsWhenMissing pins the new adjuster defaults so they don't quietly drift. - TestLoadAdjustEmptyKeepsNoOp renamed to TestLoadAdjustEmptyOverrides- Default; the comment and assertion now reflect that an empty value in the config file overrides the non-zero default. - TestBuildFontConfigCarriesAdjusters now uses values that differ from the defaults (5 instead of 2) so the wiring assertion remains meaningful. Doc updates in fonts.md: - Cell tuning table gets a Default column showing the new 2px values for adjust_cell_width / adjust_cell_height. - Adds an "Opting out of the cell padding" example for users who want Pango's natural metrics back. - Reframes the cmux preset as a layered tweak on top of the new defaults (just font_family + font_size + font_thicken). - Notes that antialias = subpixel is a no-op on macOS (Apple removed system-wide subpixel AA in Mojave) but a safe cross-platform choice because Linux RGB-stripe panels do honor it. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/roost/font_test.go | 4 +++- docs/reference/fonts.md | 30 +++++++++++++++++++----------- internal/config/config.go | 15 ++++++++++++--- internal/config/config_test.go | 22 ++++++++++++++++++---- 4 files changed, 52 insertions(+), 19 deletions(-) diff --git a/cmd/roost/font_test.go b/cmd/roost/font_test.go index d9bf0ea7..6083acbf 100644 --- a/cmd/roost/font_test.go +++ b/cmd/roost/font_test.go @@ -92,8 +92,10 @@ func TestBuildFontConfigEmptyOverridesKeepDefaults(t *testing.T) { } func TestBuildFontConfigCarriesAdjusters(t *testing.T) { + // Use values that differ from Defaults() so the assertions prove + // the wiring carried the user's overrides, not just the defaults. cfg := config.Defaults() - cfg.AdjustCellWidth = config.Adjust{Mode: config.AdjustModePixels, Value: 2} + cfg.AdjustCellWidth = config.Adjust{Mode: config.AdjustModePixels, Value: 5} cfg.AdjustCellHeight = config.Adjust{Mode: config.AdjustModePercent, Value: 10} cfg.AdjustFontBaseline = config.Adjust{Mode: config.AdjustModePixels, Value: -1} cfg.FontThicken = true diff --git a/docs/reference/fonts.md b/docs/reference/fonts.md index af8486cc..32acf485 100644 --- a/docs/reference/fonts.md +++ b/docs/reference/fonts.md @@ -19,31 +19,39 @@ font_feature = -calt | `font_feature` | (none) | OpenType feature tag. Repeatable: each line appends one entry. Joined with commas at render time. | | `hint_metrics` | `on` | One of `on`, `off`, `default`. Snaps glyph advance widths to integer pixels. Keep `on` for monospace crispness — without it, cells look soft. | | `hint_style` | `none` (macOS) / `slight` (Linux) | One of `none`, `slight`, `medium`, `full`, `default`. macOS fonts are not designed for hinting; FreeType `slight` is the typical Linux setup. | -| `antialias` | `gray` | One of `none`, `gray`, `subpixel`, `default`. macOS uses grayscale natively; `subpixel` is meaningful on RGB-stripe LCDs (most desktop monitors on Linux). | +| `antialias` | `gray` | One of `none`, `gray`, `subpixel`, `default`. On Linux RGB-stripe panels `subpixel` gives sharper strokes; on macOS `subpixel` is effectively a no-op (Apple removed system-wide subpixel AA in Mojave) and falls back to grayscale, so setting `subpixel` explicitly is a safe cross-platform choice. | Empty string and `default` both mean "use the platform default for this setting." ## Cell tuning -Four additional knobs adjust the cell grid and glyph rendering. Useful when matching the look of another terminal (cmux, ghostty, iTerm) where the natural Pango/Cairo metrics feel a touch tight or thin. All take effect on the next launch. +Four knobs adjust the cell grid and glyph rendering. The defaults already give roost a polished out-of-the-box look (Pango's natural cell metrics are tighter than mainstream terminals); these knobs let you fine-tune from there. All take effect on the next launch. -| Key | Value syntax | Effect | -|------------------------|-------------------------------------------|-----------------------------------------------------------------------------------------| -| `adjust_cell_height` | `2`, `2px`, `10%`, `-1`, `-5%` | Add or subtract from the natural cell height. Positive values add line spacing; glyphs auto-center in the enlarged cell. | -| `adjust_cell_width` | same syntax | Add or subtract from the natural cell width (letter spacing). | -| `adjust_font_baseline` | same syntax | Shift glyphs vertically inside the cell. A fine-tune *after* `adjust_cell_height` — leave it unset until you need to bias the glyph up or down. | -| `font_thicken` | `true` / `false` (default `false`) | Render each glyph twice with a 0.5 px horizontal offset, fattening strokes. Approximates Apple Core Text stem darkening for pipelines that don't apply it natively (notably Cairo on macOS). Not a perfect parity with Apple's algorithm. | +| Key | Default | Value syntax | Effect | +|------------------------|---------|------------------------------------|-----------------------------------------------------------------------------------------| +| `adjust_cell_height` | `2px` | `2`, `2px`, `10%`, `-1`, `-5%` | Add or subtract from the natural cell height. Positive values add line spacing; glyphs auto-center in the enlarged cell. | +| `adjust_cell_width` | `2px` | same syntax | Add or subtract from the natural cell width (letter spacing). | +| `adjust_font_baseline` | (none) | same syntax | Shift glyphs vertically inside the cell. A fine-tune *after* `adjust_cell_height` — leave it unset until you need to bias the glyph up or down. | +| `font_thicken` | `false` | `true` / `false` | Render each glyph twice with a 0.5 px horizontal offset, fattening strokes. Approximates Apple Core Text stem darkening for pipelines that don't apply it natively (notably Cairo on macOS). Not a perfect parity with Apple's algorithm. | A bare integer means pixels (`2` is the same as `2px`). A trailing `%` means a signed percentage of the natural metric. Negative values shrink. The cell metrics are clamped to a minimum of 1 px so a runaway negative can't crash the geometry. -### Targeting the cmux look on macOS +### Opting out of the cell padding -The default config aims at ghostty-style polish with modern programming fonts. To get closer to cmux/Terminal.app's look — Menlo at a smaller size with a touch more line spacing and Apple-like stem weight — try: +The cell padding defaults can be reverted to Pango's natural metrics by setting them to `0`: + +```conf +adjust_cell_width = 0 +adjust_cell_height = 0 +``` + +### Going for a cmux / Terminal.app look on macOS + +cmux and Apple's Terminal.app both use Menlo at a smaller size with Apple-like stem weight. Layered on top of the defaults: ```conf font_family = Menlo font_size = 11 -adjust_cell_height = 2px font_thicken = true ``` diff --git a/internal/config/config.go b/internal/config/config.go index aeeb19c2..08271f1f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -92,11 +92,20 @@ type Keybind struct { } // Defaults returns the built-in Config used when no file exists. +// +// AdjustCellWidth and AdjustCellHeight default to +2 px because Pango's +// natural cell metrics are tighter than mainstream terminals (cmux, +// ghostty, iTerm, Terminal.app) which all add a small amount of cell +// padding. Setting tasteful defaults here saves every user from +// discovering and tuning the same knobs. Opt out with `adjust_cell_* = +// 0` (or any other value) in the config file. func Defaults() Config { return Config{ - FontFamily: "JetBrains Mono, Monaco, monospace", - FontSizePt: 12, - Theme: "roost-dark", + FontFamily: "JetBrains Mono, Monaco, monospace", + FontSizePt: 12, + AdjustCellWidth: Adjust{Mode: AdjustModePixels, Value: 2}, + AdjustCellHeight: Adjust{Mode: AdjustModePixels, Value: 2}, + Theme: "roost-dark", } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 3e00611c..3307af12 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -31,6 +31,18 @@ func TestLoadDefaultsWhenMissing(t *testing.T) { if len(cfg.Keybinds) != 0 { t.Errorf("expected no keybinds when file missing, got %+v", cfg.Keybinds) } + // Cell adjusters default to +2px each — this gives roost a polished + // look out of the box (Pango's natural cell metrics are tighter + // than other mainstream terminals). Pinning the defaults here so + // they don't quietly drift. + wantW := Adjust{Mode: AdjustModePixels, Value: 2} + wantH := Adjust{Mode: AdjustModePixels, Value: 2} + if cfg.AdjustCellWidth != wantW { + t.Errorf("default AdjustCellWidth: got %+v want %+v", cfg.AdjustCellWidth, wantW) + } + if cfg.AdjustCellHeight != wantH { + t.Errorf("default AdjustCellHeight: got %+v want %+v", cfg.AdjustCellHeight, wantH) + } } func TestLoadThemeKey(t *testing.T) { @@ -282,9 +294,11 @@ func TestLoadAdjustKeysValid(t *testing.T) { } } -func TestLoadAdjustEmptyKeepsNoOp(t *testing.T) { - // Defaults() leaves the Adjust fields zero-valued (AdjustModeNone). - // An empty value in the config file should round-trip the same way. +func TestLoadAdjustEmptyOverridesDefault(t *testing.T) { + // Defaults() sets AdjustCellWidth/Height to +2px. Writing an + // explicit blank value lets the user opt out — ParseAdjust("") + // returns AdjustModeNone and the case branch unconditionally + // assigns it, so `adjust_cell_width =` wins over the default. p := writeConfig(t, ""+ "adjust_cell_width = \n"+ "adjust_cell_height = \n"+ @@ -296,7 +310,7 @@ func TestLoadAdjustEmptyKeepsNoOp(t *testing.T) { if cfg.AdjustCellWidth.Mode != AdjustModeNone || cfg.AdjustCellHeight.Mode != AdjustModeNone || cfg.AdjustFontBaseline.Mode != AdjustModeNone { - t.Errorf("blank adjust values should remain no-op, got %+v", cfg) + t.Errorf("blank adjust values should override default to no-op, got %+v", cfg) } }