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
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
VERSION ?= dev
VERSION ?= v1.5.4
LDFLAGS := -trimpath -ldflags "-s -w -X main.version=$(VERSION)"

.PHONY: build build-server bundle dmg install clean
Expand Down
58 changes: 54 additions & 4 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const configTemplate = `# zurm configuration

[font]
family = "JetBrains Mono"
size = 15
size = 15 # points, clamped to 6–72
# file = "/Users/you/Library/Fonts/JetBrainsMonoNerdFont-Regular.ttf" # overrides embedded font
#
# Font fallback chain — tried in order for missing glyphs.
Expand All @@ -45,9 +45,9 @@ size = 15
# -o ~/Library/Fonts/NotoSansSymbols2-Regular.ttf

[window]
columns = 120
rows = 35
padding = 4 # pixels inside each pane edge
columns = 120 # min 1
rows = 35 # min 1
padding = 4 # pixels inside each pane edge (min 0)

[shell]
program = "" # empty = read from $SHELL, fallback /bin/zsh
Expand Down Expand Up @@ -191,6 +191,15 @@ md_badge_bg = "#FFCC00" # badge/label background (e.g. line count)
md_badge_fg = "#000000" # badge/label text color
`

// Renderable font-size bounds, in points. A size at or below zero collapses the
// glyph cell to 0×0, which divides by zero throughout the layout math; an absurd
// size wastes memory. These are the single source of truth for the valid range —
// enforced on every config load and by the interactive font resize.
const (
MinFontSize = 6.0
MaxFontSize = 72.0
)

type FontConfig struct {
Family string `toml:"family"`
Size float64 `toml:"size"`
Expand Down Expand Up @@ -505,6 +514,7 @@ func Load() (*Config, error) {
}

meta, err := toml.DecodeFile(path, &cfg)
normalize(&cfg)
if err != nil {
return &cfg, err
}
Expand All @@ -527,13 +537,53 @@ func LoadWithMeta() (*Config, toml.MetaData, error) {

cfg := Defaults
meta, err := toml.DecodeFile(path, &cfg)
normalize(&cfg)
if err != nil {
return &cfg, meta, fmt.Errorf("config: %w", err)
}
resolveShell(&cfg)
return &cfg, meta, nil
}

// normalize enforces config invariants after decode so every consumer can trust
// the values without re-checking them. Applied on every load (startup and
// hot-reload). Each clamp logs so a user with a bad config gets a hint.
func normalize(cfg *Config) {
clampFontSize(cfg)
clampWindow(cfg)
}

// clampWindow bounds window geometry. Columns/Rows seed the initial buffer
// allocation (NewScreenBuffer) — a value below 1 panics there; padding below 0
// is meaningless. (NewScreenBuffer floors dims too, as defense in depth.)
func clampWindow(cfg *Config) {
if cfg.Window.Columns < 1 {
log.Printf("config: window.columns %d invalid, clamping to 1", cfg.Window.Columns)
cfg.Window.Columns = 1
}
if cfg.Window.Rows < 1 {
log.Printf("config: window.rows %d invalid, clamping to 1", cfg.Window.Rows)
cfg.Window.Rows = 1
}
if cfg.Window.Padding < 0 {
log.Printf("config: window.padding %d invalid, clamping to 0", cfg.Window.Padding)
cfg.Window.Padding = 0
}
}

// clampFontSize bounds Font.Size to the renderable range. Applied on every load
// so all consumers (startup, hot-reload, interactive resize) can trust the value
// without re-checking it. A clamp is logged so a user with a bad config gets a hint.
func clampFontSize(cfg *Config) {
if cfg.Font.Size < MinFontSize {
log.Printf("config: font.size %.1f below minimum, clamping to %.0f", cfg.Font.Size, MinFontSize)
cfg.Font.Size = MinFontSize
} else if cfg.Font.Size > MaxFontSize {
log.Printf("config: font.size %.1f above maximum, clamping to %.0f", cfg.Font.Size, MaxFontSize)
cfg.Font.Size = MaxFontSize
}
}

// resolveShell fills in Shell.Program from $SHELL if not set.
func resolveShell(cfg *Config) {
if cfg.Shell.Program == "" {
Expand Down
53 changes: 53 additions & 0 deletions config/fontsize_clamp_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package config

import "testing"

func TestClampFontSize(t *testing.T) {
cases := []struct {
name string
in float64
want float64
}{
{"zero collapses to min", 0, MinFontSize},
{"negative collapses to min", -10, MinFontSize},
{"below min", MinFontSize - 0.1, MinFontSize},
{"at min stays", MinFontSize, MinFontSize},
{"in range stays", 15, 15},
{"at max stays", MaxFontSize, MaxFontSize},
{"above max collapses to max", MaxFontSize + 100, MaxFontSize},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
cfg := Config{Font: FontConfig{Size: c.in}}
clampFontSize(&cfg)
if cfg.Font.Size != c.want {
t.Errorf("clampFontSize(%v) = %v, want %v", c.in, cfg.Font.Size, c.want)
}
})
}
}

func TestClampWindow(t *testing.T) {
cases := []struct {
name string
cols, rows, pad int
wantCols, wantRows, wantPadding int
}{
{"negative cols/rows", -1, -1, 4, 1, 1, 4},
{"zero cols/rows", 0, 0, 4, 1, 1, 4},
{"negative padding", 80, 24, -5, 80, 24, 0},
{"valid values untouched", 120, 35, 4, 120, 35, 4},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
cfg := Config{Window: WindowConfig{Columns: c.cols, Rows: c.rows, Padding: c.pad}}
clampWindow(&cfg)
if cfg.Window.Columns != c.wantCols || cfg.Window.Rows != c.wantRows || cfg.Window.Padding != c.wantPadding {
t.Errorf("clampWindow(cols=%d,rows=%d,pad=%d) = (%d,%d,%d), want (%d,%d,%d)",
c.cols, c.rows, c.pad,
cfg.Window.Columns, cfg.Window.Rows, cfg.Window.Padding,
c.wantCols, c.wantRows, c.wantPadding)
}
})
}
}
24 changes: 24 additions & 0 deletions docs/__changelog__/releases/1.5.4.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
version: "1.5.4"
date: "2026-05-17T00:00:00Z"
---

## Fixed

- **Hang on wake from display sleep** — zurm could freeze permanently (requiring a force-quit) after a display slept and woke: closing the laptop lid in clamshell mode, a monitor power-cycling, or the display idle-timeout. When idle, zurm skipped the GPU present to save CPU; on macOS 14+ that stranded Ebitengine's display-link callback thread, which then deadlocked the main thread inside QuartzCore the next time a display was reconfigured. zurm now re-presents the last frame every tick so the present loop never stalls.

- **Tick rate not restored after sleep** — after a sleep/wake cycle the engine could keep running at 60 TPS instead of the configured `[performance] tps` value, raising idle CPU. The configured rate is now always restored on wake.

- **Crash on display sleep or disconnect** — zurm could panic (`ebiten: width at NewImage must be positive but 0`) when a display went to sleep or an external monitor was unplugged: with no screen attached the window reports a `0×0` size, which zurm then tried to allocate render buffers for. Resizes to a zero-sized window are now ignored; layout recommits automatically once a display is back.

- **Scroll jumped on focus change** — with smooth scrolling enabled, clicking a pane to focus it (or switching tabs) could snap its viewport up or down at random. The scroll animation tracked the previous pane's position and forced it onto the newly focused pane. Each pane now keeps its own scroll position across focus and tab changes.

- **Window not resized when moved to another display** — dragging zurm onto a different monitor could leave the content stranded at its old size in the top-left corner with the rest of the window black. The render surface was sized from `ebiten.WindowSize()`, which lags the framebuffer that actually drives rendering when the window crosses displays. Render geometry is now committed from the size ebiten renders against, so content fills the window after a move.

- **Crash on invalid font size** — a `font.size` of zero or below in config (including via a typo during a live config edit, which hot-reloads) collapsed the glyph cell to 0×0 and panicked with an integer divide-by-zero. Font size is now clamped to a renderable range (6–72 points) on every load, and the renderer guards against a zero-sized cell as a final safeguard.

- **Crash on invalid window dimensions** — a `window.columns` or `window.rows` of zero or below panicked at startup when allocating the terminal buffer. Window columns/rows are now clamped to a minimum of 1 and padding to a minimum of 0 on load, and the screen buffer floors its own dimensions as a final safeguard.

- **Click-count carried across panes** — clicking to select in one pane then quickly clicking the same cell in another pane could register as a double/triple click. Click tracking now resets when focus changes panes.

- **Crash restoring a corrupt session** — a session file with an out-of-range active tab index (e.g. negative, from manual editing or corruption) panicked at startup, preventing launch until the file was deleted. The restored active-tab index is now clamped to a valid range.
8 changes: 4 additions & 4 deletions docs/getting-started/02-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ Hot-reload with `Cmd+,` — no restart needed.
```toml
[font]
family = "JetBrains Mono" # font family name (informational)
size = 15 # font size in points
size = 15 # font size in points (clamped to 6–72)
# file = "/path/to/Font.ttf" # custom TTF/OTF; overrides embedded JetBrains Mono
# fallbacks = [...] # see Optional Fonts page

[window]
columns = 120 # initial terminal width in character columns
rows = 35 # initial terminal height in character rows
padding = 4 # pixels inside each pane edge
columns = 120 # initial terminal width in character columns (min 1)
rows = 35 # initial terminal height in character rows (min 1)
padding = 4 # pixels inside each pane edge (min 0)

[shell]
program = "" # shell binary; empty = read from $SHELL, fallback /bin/zsh
Expand Down
39 changes: 32 additions & 7 deletions game_drain.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,29 @@ import (
// No-op when cfg.Scroll.Smooth is false. Called every Update() after
// handleInput so the target is already updated for this frame.
func (g *Game) tickSmoothScroll() {
if !g.cfg.Scroll.Smooth || g.activeFocused() == nil {
if !g.cfg.Scroll.Smooth {
return
}
buf := g.activeFocused().Term.Buf
p := g.activeFocused()
if p == nil {
return
}
buf := p.Term.Buf
buf.Lock()
defer buf.Unlock()

// Focus or tab changed since the last tick. The animation state is global
// to the gesture, but each pane owns its own ViewOffset — so re-sync to the
// newly focused pane instead of forcing the previous pane's scroll position
// onto it. Without this, clicking/switching to another pane snaps it to
// wherever the last pane was scrolled (random up/down jump).
if p != g.smoothScrollPane {
g.smoothScrollPane = p
g.smoothScrollPos = float64(buf.ViewOffset)
g.smoothScrollTarget = g.smoothScrollPos
return
}

maxTarget := float64(buf.ScrollbackLen())
if g.smoothScrollTarget < 0 {
g.smoothScrollTarget = 0
Expand Down Expand Up @@ -58,7 +74,7 @@ func (g *Game) handleResize() {
// switches, and multi-monitor DPI transitions — all of which take variable
// time that a fixed frame count cannot safely bound.
if g.screenSettleFrames > 0 {
w, h := ebiten.WindowSize()
w, h := g.logicalSize()
dpi := g.monitorDPI()
if w != g.screenSettleW || h != g.screenSettleH || dpi != g.screenSettleDPI {
// Geometry still changing — restart the wait.
Expand All @@ -75,7 +91,16 @@ func (g *Game) handleResize() {
return
}

w, h := ebiten.WindowSize()
w, h := g.logicalSize()
// A sleeping or disconnected display reports a 0-sized window (glfw logs
// "Cannot query content scale without screen"). Committing 0×0 would panic
// in ebiten.NewImage via Renderer.SetSize. Guard on the live window size
// (the authoritative "screen attached" signal — it can't go stale) as well
// as the Layout-derived commit dims, which can briefly lag a disconnect.
// Geometry recommits naturally once a real display is back.
if lw, lh := ebiten.WindowSize(); lw <= 0 || lh <= 0 || w <= 0 || h <= 0 {
return
}
dpi := g.monitorDPI()
dpiChanged := dpi != g.dpi
if w == g.winW && h == g.winH && !dpiChanged {
Expand Down Expand Up @@ -448,7 +473,7 @@ func (g *Game) drainShellIntegration() {
}
case 'C':
// Command about to execute — one-shot query for foreground name.
go p.Term.QueryForeground(g.ctx)
go p.Term.QueryForeground()
}
default:
}
Expand All @@ -462,14 +487,14 @@ func (g *Game) pollStatusOnOutput() {

if g.status.Poller.ShouldPollCwd(seq) {
if g.activeFocused() != nil {
go g.activeFocused().Term.QueryCWD(g.ctx)
go g.activeFocused().Term.QueryCWD()
}
}

if g.cfg.StatusBar.ShowProcess && g.status.Poller.ShouldPollFg(seq) && g.tabMgr.ActiveIdx < len(g.tabMgr.Tabs) {
for _, leaf := range g.tabMgr.Tabs[g.tabMgr.ActiveIdx].Layout.Leaves() {
if !leaf.Pane.Term.HasOSC133() {
go leaf.Pane.Term.QueryForeground(g.ctx)
go leaf.Pane.Term.QueryForeground()
}
}
}
Expand Down
14 changes: 13 additions & 1 deletion game_lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func (g *Game) handleFocus() {
// wait. handleResize will keep sampling window size/DPI each frame and
// only commit layout once they have been unchanged for 3 consecutive
// frames, so zurm adapts to however long EDID negotiation actually takes.
w, h := ebiten.WindowSize()
w, h := g.logicalSize()
dpi := g.monitorDPI()
g.winW = w
g.screenSettleW = w
Expand Down Expand Up @@ -173,6 +173,18 @@ func (g *Game) monitorDPI() float64 {
return g.dpi
}

// logicalSize returns the window's logical size as ebiten last reported it to
// Layout. ebiten.WindowSize() can lag the framebuffer that drives Layout when
// the window moves between displays, so the Layout-reported size is the
// authoritative source for committing render geometry. Falls back to
// ebiten.WindowSize() before the first Layout call.
func (g *Game) logicalSize() (int, int) {
if g.layoutW > 0 && g.layoutH > 0 {
return g.layoutW, g.layoutH
}
return ebiten.WindowSize()
}

// physSize returns the physical pixel dimensions of the window.
func (g *Game) physSize() (int, int) {
return int(float64(g.winW) * g.dpi), int(float64(g.winH) * g.dpi)
Expand Down
22 changes: 9 additions & 13 deletions game_misc.go
Original file line number Diff line number Diff line change
Expand Up @@ -756,8 +756,8 @@ func (g *Game) forceRefresh() {
g.flashStatus("Refreshed")
return
}
go g.activeFocused().Term.QueryCWD(g.ctx)
g.activeFocused().Term.RefreshForeground(g.ctx)
go g.activeFocused().Term.QueryCWD()
g.activeFocused().Term.RefreshForeground()
if g.cfg.StatusBar.ShowGit && g.status.Bar.Cwd != "" {
g.status.Poller.StartGitQuery(g.status.Bar.Cwd)
}
Expand Down Expand Up @@ -811,7 +811,7 @@ func (g *Game) reloadRuntimeSettings(cfg *config.Config) {
if cfg.Keyboard.RepeatIntervalMs > 0 {
keyRepeatInterval = time.Duration(cfg.Keyboard.RepeatIntervalMs) * time.Millisecond
}
ebiten.SetTPS(cfg.Performance.TPS)
applyTPS(cfg.Performance.TPS)
g.blocksEnabled = cfg.Blocks.Enabled
g.renderer.BlocksEnabled = g.blocksEnabled

Expand Down Expand Up @@ -874,24 +874,20 @@ func (g *Game) switchTheme(name string) {
g.flashStatus("Theme: " + name)
}

const (
minFontSizePt = 6 // smallest allowed font size in points
maxFontSizePt = 72 // largest allowed font size in points
)

// adjustFontSize changes the font size by delta points and reloads the font.
// Clamped to [minFontSizePt, maxFontSizePt]. Skipped when recording is active.
// Clamped to the renderable range (config.MinFontSize..MaxFontSize). Skipped
// when recording is active.
func (g *Game) adjustFontSize(delta float64) {
if g.rec.Recorder != nil && g.rec.Recorder.Active() {
g.flashStatus("Cannot resize font while recording")
return
}
newSize := g.cfg.Font.Size + delta
if newSize < minFontSizePt {
newSize = minFontSizePt
if newSize < config.MinFontSize {
newSize = config.MinFontSize
}
if newSize > maxFontSizePt {
newSize = maxFontSizePt
if newSize > config.MaxFontSize {
newSize = config.MaxFontSize
}
if newSize == g.cfg.Font.Size {
return
Expand Down
8 changes: 7 additions & 1 deletion game_panes.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,14 @@ func (g *Game) setFocusNoHistory(p *pane.Pane) {
g.input.MouseHeldBtn = -1
g.input.LastMouseCol = 0
g.input.LastMouseRow = 0
// Reset click-count tracking so a click in the previous pane cannot combine
// with the first click in the newly focused pane into a false double/triple
// click. The sentinel cell guarantees the next click is never "same cell".
g.input.LastClickRow = -1
g.input.LastClickCol = -1
g.input.ClickCount = 0
g.status.Bar.ForegroundProc = ""
p.Term.RefreshForeground(g.ctx)
p.Term.RefreshForeground()
if g.search.State.Open {
g.closeSearchOverlay()
}
Expand Down
2 changes: 1 addition & 1 deletion game_tabs.go
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ func (g *Game) switchTabNoHistory(i int) {
g.input.SelDrag.Active = false
g.status.Bar.ForegroundProc = ""
if f := g.activeFocused(); f != nil {
f.Term.RefreshForeground(g.ctx)
f.Term.RefreshForeground()
}
if g.search.State.Open {
g.closeSearchOverlay()
Expand Down
Loading
Loading