From 4ff1d516901098a3675cc04842512df093c2e2dc Mon Sep 17 00:00:00 2001 From: yetone Date: Mon, 5 Oct 2026 22:48:43 +0800 Subject: [PATCH] settings, gui: Windows and Linux start at 110% text size until one is picked, and a new text size leaves no blank room at the page's foot (#914, #911, emo172) --- internal/gui/assets/app.js | 11 ++++++- internal/gui/tests/README.md | 7 +++-- internal/gui/tests/text-size.test.cjs | 44 +++++++++++++++++++++++++++ internal/settings/settings.go | 15 ++++++++- internal/settings/settings_test.go | 39 ++++++++++++++++++++++-- 5 files changed, 110 insertions(+), 6 deletions(-) diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 9af278a7..0afcc657 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -15903,9 +15903,18 @@ function applyZoom(n) { document.documentElement.style.setProperty("--zoom", web ? 1 : (n || 100) / 100); } let textSize = window.bootPrefs?.textSize || 100; +// A new text size lays every page out anew: what was clicked (the size's +// own button) isn't held where it was, nor any room kept for it, or the +// page zoomed out at its foot was given a blank as tall as it shrank +// (#911). Nothing is scrolled; the browser fits the page as it is. +function newTextSize() { + held = null; + for (const v of document.querySelectorAll(".view")) setRoom(v, 0); +} async function setTextSize(n) { if (web || n === textSize) return; textSize = n; + newTextSize(); applyZoom(n); try { prefs = await writingPrefs(api("settings/text-size", { size: n })); @@ -15987,7 +15996,7 @@ function applyPrefs(s, rate) { if (applyPrefs.painted) { renderCosts(); if (mode === "panel" && panelTab === "stats") renderPanelUse(); } } applyPrefs.painted = true; - if (!prefsBusy && (s.textSize || 100) !== textSize) { textSize = s.textSize || 100; applyZoom(textSize); } + if (!prefsBusy && (s.textSize || 100) !== textSize) { textSize = s.textSize || 100; newTextSize(); applyZoom(textSize); } const was = locale; setLocale(s.lang); if (was !== locale && mode === "window") queueMicrotask(() => slide($("#nav"), "nav")); diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md index ef1f3cf0..97681c52 100644 --- a/internal/gui/tests/README.md +++ b/internal/gui/tests/README.md @@ -566,8 +566,11 @@ leaves it be; Ctrl/Cmd + = and − step through the sizes and 0 goes back to The zoom is the webview's, so here it is as a browser zooms: the smallest window at 150% (840×630 points) is 560×420 CSS pixels at a device scale of 1.5, and no view (nor the panel at 150%) runs off to the side, the Mac -header still 50 points tall for the traffic lights. English and Chinese, -light and dark; ARTIFACT_DIR gets screenshots at 100% and 150%. +header still 50 points tall for the traffic lights. At 150% in a 1440×900 +window scrolled down, a click on 100% (the window then 1440×900 CSS pixels) +leaves no `.view-room` and no blank under the page, nor scrolls it beyond +the browser's own fit (#911). English and Chinese, light and dark; +ARTIFACT_DIR gets screenshots at 100% and 150%. `settings-groups.test.cjs` puts the Settings page's warm-ups and check-in under a tab per service (#124): Codex, Claude Code and WorkBuddy tabs where a diff --git a/internal/gui/tests/text-size.test.cjs b/internal/gui/tests/text-size.test.cjs index 4e7d6e4c..b42a703c 100644 --- a/internal/gui/tests/text-size.test.cjs +++ b/internal/gui/tests/text-size.test.cjs @@ -201,6 +201,50 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await page.context().close(); }); + await t.test(lang + ": 100% picked at the foot of the page leaves no blank under it", async () => { + // #911: at 150% scrolled to the foot, a click on 100% held the + // control where it was as the webview zoomed out, and the page grew + // a blank foot to hold it; Ctrl+0 went to the top. The page laid out + // anew is the browser's to fit: no room, nothing scrolled. + const posts = []; + // emo172's window: 1440×900 points, 960×600 CSS pixels at 150% + const page = await open(lang, "light", 150, posts, { width: 960, height: 600, zoom: 1.5 }); + await page.locator("#prefs").click(); + const segs = page.locator("#textSizeSegs .opt"); + await segs.first().waitFor(); + const v = page.locator("#view-settings"); + await v.hover(); + // down as far as the control is still in sight: to the foot, or the + // control at the view's top + for (let i = 0; i < 400; i++) { + if (await v.evaluate((v) => v.scrollTop >= v.scrollHeight - v.clientHeight - 1 + || document.querySelector("#textSizeSegs").getBoundingClientRect().top < v.getBoundingClientRect().top + 60)) break; + await page.mouse.wheel(0, 40); + await page.waitForTimeout(20); + } + await page.waitForTimeout(300); + const before = await v.evaluate((v) => v.scrollTop); + assert(before > 0, "the settings page must scroll"); + await segs.nth(0).click(); + await page.locator("#textSizeSegs .opt.on", { hasText: "100%" }).waitFor(); + // the webview's zoom out: the same window is half as many CSS pixels again + await page.setViewportSize({ width: 1440, height: 900 }); + await page.waitForTimeout(600); + const foot = await v.evaluate((v) => { + const kids = [...v.children].filter((c) => !c.classList.contains("view-room") && c.getClientRects().length); + const end = Math.max(...kids.map((c) => c.getBoundingClientRect().bottom)); + return { room: !!v.querySelector(":scope > .view-room"), blank: v.getBoundingClientRect().bottom - end, top: v.scrollTop, max: v.scrollHeight - v.clientHeight }; + }); + assert.deepEqual(posts, [100]); + assert.equal(foot.room, false, "no room may be left at the foot"); + // a page that still scrolls reaches the window's foot (one shorter has its own) + assert(foot.max < 1 || foot.blank < 60, `a blank of ${foot.blank}px under the page`); + // nothing scrolled: where it was, or as far down as the page now goes + assert(Math.abs(foot.top - Math.min(before, foot.max)) < 2, `scrolled from ${before} to ${foot.top} (max ${foot.max})`); + assert.deepEqual(page.errors, []); + await page.context().close(); + }); + for (const theme of ["light", "dark"]) { await t.test(lang + ", " + theme + ": nothing runs off the side at 150%", async () => { // the smallest window at 150% is 840×630 points, 560×420 CSS pixels diff --git a/internal/settings/settings.go b/internal/settings/settings.go index cc0f8e62..35b1a830 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -17,6 +17,7 @@ import ( "path/filepath" "reflect" "regexp" + "runtime" "slices" "strings" "sync" @@ -517,6 +518,18 @@ var ( TextSizes = []int{100, 110, 125, 150} ) +// DefaultTextSize is the text size of settings that have never had one +// saved. Windows' and Linux's body text is 14px against magpie's 13px at +// 100%, a size smaller than what is around it there, so they start at +// 110% (#914); the Mac's is 13px, as magpie's. A size once saved, 100% +// too, is kept as it is. A var so tests can be any system. +var DefaultTextSize = func() int { + if runtime.GOOS == "windows" || runtime.GOOS == "linux" { + return 110 + } + return 100 +} + var validTerminalBundleID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9.-]{0,254}$`) // providerID is how a provider's id is spelled: lower-case letters, digits @@ -911,7 +924,7 @@ func (s Settings) normal() Settings { s.UpdateEvery = 360 } if s.TextSize == 0 { - s.TextSize = 100 + s.TextSize = DefaultTextSize() } // one card, as a magpie before TrayUsages kept it; an empty list // sent on purpose (all of them turned off) stays empty diff --git a/internal/settings/settings_test.go b/internal/settings/settings_test.go index 954cd6ab..cc69d464 100644 --- a/internal/settings/settings_test.go +++ b/internal/settings/settings_test.go @@ -11,6 +11,7 @@ import ( "strings" "testing" + "github.com/yetone/magpie/internal/filememo" "github.com/yetone/magpie/internal/redact" ) @@ -56,8 +57,8 @@ func TestRoundTrip(t *testing.T) { if Save(Settings{SessionTerminal: "Ghostty; rm -rf /"}) == nil { t.Fatal("bad terminal app id accepted") } - // the text size is 100% until one of the sizes is chosen - if Save(Settings{}) != nil || Load().TextSize != 100 { + // the text size is the system's default until one of the sizes is chosen + if Save(Settings{}) != nil || Load().TextSize != DefaultTextSize() { t.Fatalf("default text size: %+v", Load()) } if Save(Settings{TextSize: 125}) != nil || Load().TextSize != 125 { @@ -500,3 +501,37 @@ func TestAlertsChecked(t *testing.T) { t.Fatalf("read back %d %v", s.UsageAlert, s.BalanceAlert) } } + +// Windows and Linux start at 110% (#914), the Mac at 100%; a size saved +// before, 100% too, is kept on any of them. +func TestDefaultTextSize(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + orig := DefaultTextSize + t.Cleanup(func() { DefaultTextSize = orig }) + for _, c := range []struct { + goos string + want int + }{{"windows", 110}, {"linux", 110}, {"darwin", 100}} { + DefaultTextSize = func() int { return c.want } + if err := os.Remove(Path()); err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + filememo.Forget() + if got := Load().TextSize; got != c.want { + t.Fatalf("%s, nothing saved: text size %d, want %d", c.goos, got, c.want) + } + if err := Save(Settings{TextSize: 100}); err != nil { + t.Fatal(err) + } + if got := Load().TextSize; got != 100 { + t.Fatalf("%s: a saved 100%% became %d", c.goos, got) + } + } + want := 100 + if runtime.GOOS == "windows" || runtime.GOOS == "linux" { + want = 110 + } + if orig() != want { + t.Fatalf("on %s the default is %d, want %d", runtime.GOOS, orig(), want) + } +}