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
26 changes: 21 additions & 5 deletions cmd/roost/font.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
}
}

Expand Down
25 changes: 25 additions & 0 deletions cmd/roost/font_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,31 @@ 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: 5}
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 != "" {
Expand Down
21 changes: 17 additions & 4 deletions cmd/roost/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
}
}
Expand Down
34 changes: 27 additions & 7 deletions cmd/roost/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand All @@ -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),
Expand All @@ -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
Expand Down
40 changes: 37 additions & 3 deletions docs/reference/fonts.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,44 @@ 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 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 | 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.

### Opting out of the cell padding

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
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:
Expand All @@ -48,7 +82,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.
86 changes: 86 additions & 0 deletions internal/config/adjust.go
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading