Skip to content

Add cell metric adjusters and font-thicken for text tuning - #12

Merged
charliek merged 2 commits into
mainfrom
feat/cell-adjusters
May 2, 2026
Merged

Add cell metric adjusters and font-thicken for text tuning#12
charliek merged 2 commits into
mainfrom
feat/cell-adjusters

Conversation

@charliek

@charliek charliek commented May 2, 2026

Copy link
Copy Markdown
Owner

Summary

Follow-up to #11. Manual testing on macOS surfaced two remaining tuning 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 because Cairo doesn't apply Apple Core Text stem darkening. Both are exactly what Ghostty's font-tuning knobs are designed for. This PR exposes the subset that's actually about text appearance; cursor / underline / strikethrough / box-thickness adjusters are TUI alignment and stay deferred for a separate PR.

Four new config keys, all default no-op (existing configs unchanged):

Key Syntax Effect
adjust_cell_height 2, 2px, 10%, negatives ok Adds/subtracts line spacing. Glyphs auto-center in the enlarged cell.
adjust_cell_width same Letter spacing.
adjust_font_baseline same Vertical glyph fine-tune after adjust_cell_height auto-center.
font_thicken true / false Double-draws each glyph at +0.5 px X. Approximates Apple Core Text stem darkening.

Plus the documented preset for matching cmux's look on macOS:

font_family = Menlo
font_size = 11
adjust_cell_height = 2px
font_thicken = true

Implementation notes

  • New internal/config.Adjust type carries the parsed value (Mode + Value) so the application site (measureCells) can apply it once the natural metric is known. Apply clamps to a minimum of 1 px so a runaway negative can't crash geometry; Delta returns just the signed offset for glyph y-shift math.
  • measureCells now applies the cell adjusters and computes glyphYOffset = (cellH - naturalH)/2 + AdjustFontBaseline.Delta(naturalH). Auto-center half ensures glyphs stay vertically centered when cell height grows; adjust_font_baseline is the user's bias on top.
  • render.go glyph-paint sites pick up glyphYOffset; backgrounds, cursor rect, and selection rects stay anchored to the cell grid (intentional — only glyphs shift).
  • font_thicken is implemented via a showGlyphLayout helper that wraps both pangocairo.ShowLayout call sites. When enabled, the glyph is painted again at +0.5px X. Document as approximation, not perfect parity with Apple's algorithm.

Test plan

  • ./build/build.sh, go test ./..., golangci-lint run — all green locally.
  • No-config baseline unchanged. Launch with no new config keys; rendering identical to current main.
  • adjust_cell_height = 4px — rows visibly taller; glyphs vertically centered (not stuck to top).
  • adjust_cell_height = 10% — proportional to font size.
  • Negative adjust_cell_height = -2px — rows tighter; clamp prevents negative cells.
  • adjust_cell_width = 2px — letter spacing widens; bg fills extend; cursor block widens.
  • adjust_font_baseline = 2px with adjust_cell_height = 4px — glyphs shift further down on top of the auto-center.
  • font_thicken = true — strokes visibly thicker; toggle off → reverts.
  • The cmux-target preset — eyeball alongside cmux for a final read.
  • Invalid syntaxadjust_cell_height = nonsense refuses to start with a clear line/value error.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added four font “cell tuning” knobs: adjust cell width, adjust cell height, adjust font baseline, and font thickening for darker stems; rendering now centers glyphs when cell height is increased.
  • Documentation

    • Expanded font reference with syntax, defaults (cell adjusts default to +2px), clamping rules, presets, and platform notes.
  • Tests

    • Added unit tests covering parsing, application, and config loading of the new tuning options and font thicken flag.

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) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 2, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 39ebca41-a722-400c-a087-46fb14db2f3e

📥 Commits

Reviewing files that changed from the base of the PR and between 0a55d55 and 32f2ec7.

📒 Files selected for processing (4)
  • cmd/roost/font_test.go
  • docs/reference/fonts.md
  • internal/config/config.go
  • internal/config/config_test.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • internal/config/config.go
  • docs/reference/fonts.md
  • internal/config/config_test.go

📝 Walkthrough

Walkthrough

Adds a cell-tuning system: parseable adjusters for cell width, height, and font baseline plus a glyph-thicken toggle; config/schema, parsing, font config, session measurement, rendering, tests, and docs updated to carry and apply these adjustments.

Changes

Cell Metric Tuning System

Layer / File(s) Summary
Adjustment Model & Parsing
internal/config/adjust.go, internal/config/adjust_test.go
New Adjust type and AdjustMode (None/Pixels/Percent). ParseAdjust parses "", "[px]", and "%". Adjust.Apply(natural) and Adjust.Delta(natural) compute clamped adjusted values and deltas. Unit tests added.
Config Schema & Parsing
internal/config/config.go, internal/config/config_test.go
Config gains AdjustCellWidth, AdjustCellHeight, AdjustFontBaseline (Adjust) and FontThicken (bool). Defaults() sets width/height to +2px. Paths.Load() parses new keys (adjust_*, font_thicken) with file/line-wrapped errors. Tests for valid/empty/invalid cases added.
Font Configuration
cmd/roost/font.go, cmd/roost/font_test.go
FontConfig extended with AdjustCellWidth, AdjustCellHeight, AdjustFontBaseline, and FontThicken. BuildFontConfig copies these fields from Config. Test ensures adjusters and thicken flag are propagated.
Session Measurement
cmd/roost/session.go
Session gains glyphYOffset int. measureCells applies AdjustCellWidth/AdjustCellHeight to cell dimensions and computes glyphYOffset as half the extra height plus AdjustFontBaseline.Delta(naturalH) to vertically center glyphs.
Rendering Application
cmd/roost/render.go
Added showGlyphLayout(cr, layout, x, y, thicken) helper that renders a layout and, if thicken, re-renders shifted by +0.5px. Glyph rendering and cursor-glyph paths now call this helper with y + glyphYOffset and FontThicken.
Documentation
docs/reference/fonts.md
New "Cell tuning" section documenting the four config keys, syntax, defaults, clamping, restart semantics, presets, and examples; updated antialias/platform notes and limitations.

Sequence Diagram(s)

sequenceDiagram
    participant ConfigFile as Config File
    participant Paths as Paths.Load()
    participant ConfigObj as Config
    participant BuildFont as BuildFontConfig
    participant Session as measureCells
    participant Renderer as render loop

    ConfigFile->>Paths: read keys (adjust_*, font_thicken)
    Paths-->>ConfigObj: populate Adjust* & FontThicken
    ConfigObj->>BuildFont: used to build FontConfig
    BuildFont-->>Session: FontConfig (including adjusters)
    Session->>Session: compute cellW/cellH and glyphYOffset via Adjust.Apply/Delta
    Session->>Renderer: metrics & FontConfig
    Renderer->>Renderer: showGlyphLayout(..., y+glyphYOffset, FontThicken)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

Poem

🐰 I tuned my cells with gentle hops,

widths and heights in measured drops,
baselines nudged and stems made bold—
a rabbit's tweak, precise and old.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding cell metric adjusters and font-thicken as new text tuning features, matching the primary focus across all affected files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/cell-adjusters

Review rate limit: 4/5 reviews remaining, refill in 12 minutes.

Comment @coderabbitai help to get the list of available commands and usage tips.

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) <noreply@anthropic.com>
@charliek
charliek merged commit 8876ca3 into main May 2, 2026
5 checks passed
@charliek
charliek deleted the feat/cell-adjusters branch May 2, 2026 02:38
charliek added a commit that referenced this pull request May 23, 2026
* fix(linux): Linux IPC robustness sweep (closes #80)

Address the deferred CR items from the inline-core refactor (PR #78).
Reconciled against the merged code: #1 (parse-error reply), #10 (pending
HashSet), #13 (smoke-test ordering) were already done; the rest are fixed
here. Project is early-stage, so the bar is long-term correctness.

PTY supervisor (crates/roost-linux/src/daemon/pty.rs, tab_session.rs):
- #11: wait task now removes the session on child exit (sessions behind
  Arc<Mutex>), so writes to a dead PTY return NotFound instead of
  silently succeeding.
- #12: close() escalates SIGHUP -> SIGKILL after a grace period. The
  cloned portable-pty ChildKiller only sends SIGHUP, so a shell that
  traps/ignores it used to outlive close(); a reaped flag + pid watchdog
  force-kill it, mirroring the Mac teardown.
- #8: send_input/send_resize feed one per-tab serial channel drained by a
  single task, so keystrokes can't reorder (the old per-call tokio::spawn
  could under the multi-thread runtime).

Workspace state (crates/roost-linux/src/daemon/state.rs):
- #4: new project/tab position uses max(position)+1, not len()/count(),
  so a delete-then-create can't collide.
- #5: every mutator now publishes its event while still holding `inner`,
  so broadcast order matches commit order; persist runs after the drop.
  The events-resync Resync path is preserved.

Server framing (crates/roost-ipc/src/server.rs):
- #2: on an envelope decode failure, peel `id` from the raw JSON (string-
  or number-encoded) so the parse-error reply lands at the client's id,
  not 0.

Lifecycle (crates/roost-linux/src/main.rs, ipc.rs, app.rs, messages.rs):
- #7: IPC bind is now a synchronous startup requirement; bind failure
  aborts startup instead of leaving the UI socket-less.
- #6: a second launch dials the new `app.activate` op; the handler
  forwards to the GTK thread which raises the running window.
- #9: events.subscribe returns not-implemented (Linux + Mac) instead of a
  false {} ACK, so clients don't wait forever. Real streaming is deferred
  to its first consumer (roostctl watch).

Tests: write-after-exit -> NotFound, SIGHUP-ignoring child force-killed,
send_input ordering, position max+1 after delete, id-preserving parse
error, bind-failure surfaced, events.subscribe not-implemented. Rust
workspace + Mac build/clippy/tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(mac): IPC handler dispatch harness (#80 follow-up)

The Rust handler has tests/ipc_dispatch.rs; the Mac IPCHandlerImpl had
no equivalent, leaving its hand-written cross-cutting logic untested —
strict unknown-field rejection (decodeParams), ipcDim u16 validation,
mapWorkspace/mapPty error-code mapping, the not-implemented/unknown-op
paths, and result encoding. The two handlers must stay behaviorally
convergent over the shared wire contract, so this guards that.

Calls IPCHandlerImpl.handle(op:params:) directly — no socket. Exercises
only non-PTY-spawning ops to avoid the forkpty/swift-testing SIGTRAP that
disables the PTY paths elsewhere (tab.open stays on the manual pass; the
error-mapping ops reach the supervisor only on the lookup-fails path).

Seeds 8 tests: events.subscribe→not-implemented (the #9 path),
unknown-op, unknown-field strictness, project.rename→not-found,
tab.resize out-of-range→invalid-param, tab.resize missing→not-found,
identify profile echo, project.create→tab.list round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(linux): address CodeRabbit review on #80 sweep

Resolves 4 of CodeRabbit's 6 findings on PR #86 (the persistence-ordering
finding is pre-existing/orthogonal and tracked separately).

pty.rs:
- Major: spawn() acquired the master reader/writer AFTER spawn_command,
  so a try_clone_reader/take_writer failure returned with a live child
  and no wait task — an orphaned PTY. Acquire reader+writer BEFORE
  spawning, making spawn_command the last fallible step (no orphan).
- Major: the exit wait task removed the session by tab_id alone, which
  could evict a newer session that reused the same tab_id (close() frees
  the slot synchronously; pty_smoke reuses id 42). Gate the remove on
  Arc::ptr_eq of the per-spawn `reaped` identity so only the owning
  waiter deletes. (My prior "monotonic ids" comment over-assumed caller
  discipline; the supervisor can't rely on it.)
- Major: input and resize were unified at TabSession but re-split into
  the supervisor's two channels (select!), so mixed input/resize wasn't
  FIFO end-to-end. Merge them into one WriterCmd channel drained by a
  single ordered loop — genuine submission-order delivery.

ipc.rs + messages.rs:
- Minor: app.activate ACK'd any payload. Add an empty strict
  AppActivateParams and decode it so the op validates its envelope
  (rejects unknown fields) like every other op.

IPCHandlerTests.swift:
- Minor: replace `as? T != nil` (SwiftLint prefer_type_checking) with a
  meaningful empty-array assertion on a fresh project's tabs.

Rust clippy --all-targets clean; roost-ipc + roost-linux tests green
(A3 ordering + dup-spawn still pass under the new channel/guard). Mac
swift build + 140 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(linux): order state.json persistence by commit seq (CR #3)

Resolves the last CodeRabbit finding on PR #86. persist_async did
synchronous file I/O on the caller thread after dropping `inner`, so two
concurrent mutators could write state.json out of commit order — a slow
earlier commit could clobber a newer one, regressing restart durability.

Keep persistence synchronous (state_persist.rs and restart-reload depend
on it; an async writer thread would break that), but make it ordered:
- Inner.persist_seq: a monotonic commit counter bumped under the lock when
  a snapshot is taken, so the seq reflects commit order.
- snapshot_for_persist now returns (SnapshotFile, seq).
- persist(seq, snapshot) serializes on a new persist_guard mutex and drops
  any snapshot whose seq is <= the highest already written, so the newest
  committed snapshot always wins regardless of which thread races first.

Newest-wins verified by persist_drops_stale_out_of_order_writes. Rust
clippy --all-targets clean; roost-ipc + roost-linux suites green (incl.
state_persist).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style: rustfmt ipc.rs import block

The AppActivateParams import addition left the use block un-reflowed;
CI's rust-lint (cargo fmt --all -- --check) caught it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant