diff --git a/internal/gui/api.go b/internal/gui/api.go
index 9fb481da..3bb9f294 100644
--- a/internal/gui/api.go
+++ b/internal/gui/api.go
@@ -56,6 +56,9 @@ type Windows interface {
// keeps up with the panel's size; false when it can't, for the page to
// go on painting it itself.
TintPanel(rgba [4]uint8, ms int) bool
+ // SetTextSize zooms the window's and the panel's pages to percent
+ // (settings.TextSizes), the panel's size with them.
+ SetTextSize(percent int)
}
type fieldJSON struct {
@@ -262,7 +265,9 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
// they came only with the settings, so the tabs showed English first
mux.HandleFunc("GET /boot.js", func(rw http.ResponseWriter, r *http.Request) {
s := settings.Load()
- b, _ := json.Marshal(map[string]any{"lang": s.Lang, "theme": s.Theme, "web": isWeb(w)})
+ // and the text size, which the Mac's header measures against the
+ // traffic lights
+ b, _ := json.Marshal(map[string]any{"lang": s.Lang, "theme": s.Theme, "textSize": s.TextSize, "web": isWeb(w)})
rw.Header().Set("Content-Type", "text/javascript; charset=utf-8")
rw.Header().Set("Cache-Control", "no-store")
rw.Write(append(append([]byte("window.bootPrefs = "), b...), ";\n"...))
@@ -440,6 +445,8 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
in.RedactRules = cur.RedactRules // the masking rules, set on their own
// used or left is the Usage page's toggle as much as Settings', set on its own
in.QuotaLeft = cur.QuotaLeft
+ // and the text size, which the keyboard changes too (text-size below)
+ in.TextSize = cur.TextSize
if v := strings.TrimSpace(in.Vision); v != "" && v != "off" && v != cur.Vision {
if _, _, ok := provider.Resolve(v); !ok {
fail(rw, fmt.Errorf("no model %s to describe images", v))
@@ -484,6 +491,26 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
}
writeJSON(rw, settingsState())
})
+ // how large the window and the panel are drawn: Settings' choice and
+ // Ctrl/Cmd +, − and 0 in either, set on its own so a key pressed while
+ // the Settings page saves something else is never undone by it
+ mux.HandleFunc("POST /api/settings/text-size", func(rw http.ResponseWriter, r *http.Request) {
+ var in struct{ Size int }
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
+ fail(rw, err)
+ return
+ }
+ s := settings.Load()
+ s.TextSize = in.Size
+ if err := settings.Save(s); err != nil {
+ fail(rw, err)
+ return
+ }
+ if w != nil {
+ w.SetTextSize(in.Size)
+ }
+ writeJSON(rw, settingsState())
+ })
// the Agents page's order and what it folds away, in magpie's settings
mux.HandleFunc("POST /api/agents/arrange", func(rw http.ResponseWriter, r *http.Request) {
var in struct{ Order, Hidden, Shown []string }
diff --git a/internal/gui/app.go b/internal/gui/app.go
index a71a5db6..c84fb3e8 100644
--- a/internal/gui/app.go
+++ b/internal/gui/app.go
@@ -69,7 +69,9 @@ type host struct {
// click in it doesn't start another over it
flapping atomic.Bool
- panelHeight int
+ panelHeight int // the panel's, in points
+ panelPage int // what its page last asked for, in CSS pixels
+ textSize atomic.Int64 // Settings' text size, in percent (settings.TextSizes)
glides atomic.Int64 // the newest panel glide; older ones stop
query string // what the windows' URLs carry (a forced theme)
@@ -123,14 +125,13 @@ func (h *host) ChooseFolder(title string) (string, error) {
SetTitle(title).AttachToWindow(h.main).PromptForSingleSelection()
}
-// panelMax keeps the panel a drop-down, not most of the screen: longer
-// content (the usage of many accounts) scrolls in it (#124)
-const panelWidth, panelMin, panelMax = 440, 220, 560
-
// FitPanel grows or shrinks the panel to its content and keeps it anchored
-// under the tray icon; a shown panel glides there when g says how.
+// under the tray icon; a shown panel glides there when g says how. height
+// is the page's, in its CSS pixels: at a larger text size the panel is
+// that much taller (and wider), no taller than the screen has room for.
func (h *host) FitPanel(height int, g Glide) {
- height = max(panelMin, min(panelMax, height))
+ h.panelPage = height
+ _, height = panelFrame(height, h.zoom(), h.panelRoom())
if h.panelHeight == height {
return
}
@@ -139,12 +140,70 @@ func (h *host) FitPanel(height int, g Glide) {
return
}
h.glides.Add(1)
- h.panel.SetSize(panelWidth, height)
+ h.panel.SetSize(h.panelW(), height)
if h.panel.IsVisible() {
_ = h.tray.PositionWindow(h.panel, 6)
}
}
+// zoom is the text size the pages are drawn at, as a factor.
+func (h *host) zoom() float64 { return zoomOf(int(h.textSize.Load())) }
+
+// panelW is the panel's width, in points, at the text size.
+func (h *host) panelW() int { return zoomed(panelWidth, h.zoom()) }
+
+// panelRoom is how tall the panel can be on its screen: the work area,
+// less the gap under the menu bar (or over the taskbar) and a little air.
+func (h *host) panelRoom() int {
+ if _, rh := screenRoom(h.panel); rh > 0 {
+ return rh - 16
+ }
+ return 0
+}
+
+// screenRoom is the work area of the screen w is on, 0s when unknown.
+func screenRoom(w *application.WebviewWindow) (int, int) {
+ s, err := w.GetScreen()
+ if err != nil || s == nil {
+ return 0, 0
+ }
+ return s.WorkArea.Width, s.WorkArea.Height
+}
+
+// SetTextSize zooms both pages to percent, the Settings page's choice or a
+// Ctrl/Cmd +, − or 0 in either window. The panel is resized at once, to
+// the height its page last asked for; the main window is kept no smaller
+// than the page's least, larger if it has to be.
+func (h *host) SetTextSize(percent int) {
+ if int(h.textSize.Swap(int64(percent))) == percent {
+ return
+ }
+ h.whenReady(h.applyZoom)
+}
+
+// applyZoom puts the text size on the windows as they are now.
+func (h *host) applyZoom() {
+ z := h.zoom()
+ setPageZoom(h.main, z)
+ setPageZoom(h.panel, z)
+ sw, sh := screenRoom(h.main)
+ h.main.SetMinSize(windowMin(z, sw, sh))
+ page := h.panelPage
+ if page == 0 {
+ page = panelStart
+ }
+ w, ht := panelFrame(page, z, h.panelRoom())
+ h.glides.Add(1)
+ h.panelHeight = ht
+ h.panel.SetSize(w, ht)
+ if h.panel.IsVisible() {
+ _ = h.tray.PositionWindow(h.panel, 6)
+ }
+}
+
+// panelStart is the panel's height before its page first asks for one.
+const panelStart = 520
+
// Run starts the desktop app: a menu bar icon whose click drops down a compact
// panel, plus a regular window for when you want it to stay around.
// showMain opens the window immediately; otherwise only the tray icon appears.
@@ -172,6 +231,11 @@ func Run(version string, showMain bool, link string) error {
theme = "&theme=" + t
}
h := &host{query: theme, ready: make(chan struct{})}
+ // the pages open at the saved text size: Windows' and Linux's webviews
+ // take it as they are made (the Zoom options below), the Mac's once
+ // the app has started (applyZoom)
+ h.textSize.Store(int64(settings.Load().TextSize))
+ zoom := h.zoom()
go stats.Run(version, "app")
handler := devShell(h)
if handler == nil {
@@ -203,21 +267,26 @@ func Run(version string, showMain bool, link string) error {
e.Cancel()
})
- h.panel = h.app.Window.NewWithOptions(panelOptions(runtime.GOOS, theme))
+ po := panelOptions(runtime.GOOS, theme)
+ po.Width, po.Height, po.Zoom = zoomed(panelWidth, zoom), zoomed(panelStart, zoom), zoom
+ h.panel = h.app.Window.NewWithOptions(po)
// the window opens at the size it was last given
width, height := 660, 600
if s := settings.Load().Window; len(s) == 2 && s[0] >= 560 && s[1] >= 420 {
width, height = s[0], s[1]
}
+ // and no smaller than its page's least at the text size
+ minW, minH := windowMin(zoom, 0, 0)
h.main = h.app.Window.NewWithOptions(application.WebviewWindowOptions{
Name: "main",
Title: "magpie",
URL: "/?" + theme,
- Width: width,
- Height: height,
- MinWidth: 560,
- MinHeight: 420,
+ Width: max(width, minW),
+ Height: max(height, minH),
+ MinWidth: minW,
+ MinHeight: minH,
+ Zoom: zoom,
Hidden: true,
Mac: application.MacWindow{
// no InvisibleTitleBarHeight: that strip drags from anywhere in
@@ -323,6 +392,12 @@ func Run(version string, showMain bool, link string) error {
markReady()
})
}
+ // the Mac's webviews take the text size only once they are made;
+ // Windows' took it with the options, and its panel's webview may not be
+ // made yet here
+ if zoom != 1 && runtime.GOOS != "windows" {
+ h.whenReady(h.applyZoom)
+ }
if showMain {
h.whenReady(func() { h.ShowMain("") })
}
diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css
index 6e7ac105..9eb61d0d 100644
--- a/internal/gui/assets/app.css
+++ b/internal/gui/assets/app.css
@@ -80,6 +80,9 @@
--logo-invert: 1;
--tk-s: #98d8a4; --tk-k: #c4b5fd; --tk-f: #93c5fd; --tk-n: #f2b544; --tk-v: #67e8f9; --tk-o: #f0abfc; --tk-c: #8b8b94;
}
+/* Windows' own text faces, and Chinese in YaHei UI, its UI face for it, not
+ the webview's fallback; the Mac's and Linux's stack is the one above */
+:root.win { --font: "Segoe UI Variable Text", "Segoe UI", "Microsoft YaHei UI", "Microsoft YaHei", system-ui, sans-serif; }
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
@@ -141,8 +144,9 @@ code { font: 11.5px var(--code); }
/* ---- header --------------------------------------------------------------- */
.top { display: flex; align-items: center; gap: 8px; padding: 0 10px 0 14px; height: 46px; flex: none; position: relative; }
-body.window.mac .top { padding-left: 96px; height: 50px; padding-top: 2px; } /* the traffic lights centre 25.75pt from the top, not 25 */
-body.window.mac .seg { top: calc(50% + 1px); } /* 50% centres in the padding box; the 2px top padding puts the flex row a pixel lower */
+/* the traffic lights centre 25.75pt from the top, not 25; they are the window's, drawn in points, so at a larger text size (--zoom, the page's zoom) the header is that many fewer CSS pixels, still level with them */
+body.window.mac .top { padding-left: calc(96px / var(--zoom, 1)); height: calc(50px / var(--zoom, 1)); padding-top: calc(2px / var(--zoom, 1)); }
+body.window.mac .seg { top: calc(50% + 1px / var(--zoom, 1)); } /* 50% centres in the padding box; the 2px top padding puts the flex row a pixel lower */
.brand { display: flex; align-items: center; gap: 6px; font-weight: 600; letter-spacing: .01em; }
body.window:not(.mac):not(.linux) .brand { display: none; } /* Windows' title bar above already shows the icon and the name */
/* Linux: the header is the title bar, so it closes the window too — round,
@@ -1665,7 +1669,7 @@ html.theming, html.theming *, html.theming *::before, html.theming *::after {
.modal .editor.framed > .editor-error { flex: none; padding: 8px 18px; border-top: 1px solid var(--line-2); }
/* Keep native window chrome outside the scrim and dialog. In particular, the
macOS traffic lights must never sit on top of modal content. */
-body.window.mac .modal { top: 50px; padding: 16px 24px 24px; }
+body.window.mac .modal { top: calc(50px / var(--zoom, 1)); padding: 16px 24px 24px; }
body.window .modal { padding: 16px 24px 24px; }
body.panel .modal { padding: 16px 12px; }
@keyframes fade-in { from { opacity: 0; } }
diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js
index d6b3f6f0..68c18476 100644
--- a/internal/gui/assets/app.js
+++ b/internal/gui/assets/app.js
@@ -13,6 +13,9 @@ if (web) document.body.classList.add("web");
// has the name, the close button and a double-click to maximise.
if (!web && /^Mac/.test(navigator.platform)) document.body.classList.add("mac");
if (!web && /^Linux/.test(navigator.platform)) document.body.classList.add("linux");
+// Windows: its own UI faces by name, Chinese in Microsoft YaHei UI rather
+// than whatever the webview falls back to for it
+if (/^Win/.test(navigator.platform)) document.documentElement.classList.add("win");
// The window is dragged by its header, and only where the header says so
// (--wails-draggable), so the tabs and buttons in it stay plain clicks.
// Outside the app — a browser on the gateway's page — there is no runtime.
@@ -23,6 +26,7 @@ if (window.bootPrefs) {
const b = window.bootPrefs;
if (!params.get("theme") && b.theme && b.theme !== "system") document.documentElement.dataset.theme = b.theme;
setLocale(b.lang);
+ document.documentElement.style.setProperty("--zoom", b.web ? 1 : (b.textSize || 100) / 100);
}
let state = { agents: [], profiles: [], catalog: "", settings: {} };
@@ -7312,6 +7316,46 @@ const THEMES = [["system", "System"], ["light", "Light"], ["dark", "Dark"]];
const LOCALES = [["system", "System"], ["en", "English"], ["zh", "中文"]];
const TRAYS = [["panel", "Quick panel"], ["window", "Main window"]];
const CURRENCIES = [["usd", "$ USD"], ["cny", "¥ CNY"]];
+// The text size is the windows' own zoom, as a browser's Ctrl/Cmd +: the
+// page is laid out again in larger CSS pixels, so everything it measures is
+// as at 100%, and magpie sizes the windows around it (textsize.go). None is
+// under 100%: WebView2 and WebKitGTK zoom no smaller through Wails.
+const TEXT_SIZES = [100, 110, 125, 150];
+const textSizeKeys = () => /^Mac/.test(navigator.platform) ? "⌘+ ⌘− ⌘0" : "Ctrl+ Ctrl− Ctrl+0";
+// --zoom is the text size for what is placed against the window's own
+// drawing, which does not grow with the page: the Mac's traffic lights.
+function applyZoom(n) {
+ document.documentElement.style.setProperty("--zoom", web ? 1 : (n || 100) / 100);
+}
+let textSize = window.bootPrefs?.textSize || 100;
+async function setTextSize(n) {
+ if (web || n === textSize) return;
+ textSize = n;
+ applyZoom(n);
+ try {
+ prefs = await writingPrefs(api("settings/text-size", { size: n }));
+ state.settings = prefs;
+ if (view === "settings") renderSettings();
+ } catch (e) {
+ status(e.message, "err");
+ }
+}
+// Ctrl/Cmd + = and − step through the sizes, 0 goes back to 100%, in the
+// window and the panel alike; in a browser tab they are the browser's.
+if (!web) addEventListener("keydown", (e) => {
+ const mod = /^Mac/.test(navigator.platform) ? e.metaKey && !e.ctrlKey : e.ctrlKey && !e.metaKey;
+ if (!mod || e.altKey) return;
+ const k = e.code === "NumpadAdd" || e.key === "=" || e.key === "+" ? 1
+ : e.code === "NumpadSubtract" || e.key === "-" || e.key === "_" ? -1
+ : e.code === "Digit0" || e.code === "Numpad0" || e.key === "0" ? 0 : null;
+ if (k === null) return;
+ e.preventDefault();
+ const i = TEXT_SIZES.indexOf(textSize);
+ const n = k === 0 ? 100 : TEXT_SIZES[Math.max(0, Math.min(TEXT_SIZES.length - 1, (i < 0 ? 0 : i) + k))];
+ if (n === textSize) return;
+ setTextSize(n);
+ status(t("Text size {n}%", { n }));
+}, true);
// applyPrefs paints and speaks as the saved settings say, costs at the
// exchange rate given (rate, /api/state's fx) or the settings' own. A
@@ -7353,6 +7397,7 @@ function applyPrefs(s, rate) {
if (applyPrefs.painted) renderCosts();
}
applyPrefs.painted = true;
+ if (!prefsBusy && (s.textSize || 100) !== textSize) { textSize = s.textSize || 100; applyZoom(textSize); }
const was = locale;
setLocale(s.lang);
if (was !== locale && mode === "window") queueMicrotask(() => slide($("#nav"), "nav"));
@@ -7446,6 +7491,10 @@ function renderSettings() {
prefsBase = keep;
$("#themeSegs").replaceChildren(segs(THEMES.map(([id, name]) => [id, t(name)]), s.theme, (theme) => savePrefs({ ...keep, theme })));
$("#langSegs").replaceChildren(segs(LOCALES.map(([id, name]) => [id, t(name)]), s.lang, (lang) => savePrefs({ ...keep, lang })));
+ // a browser tab has its own zoom, and magpie leaves it to it
+ $("#textSizeRow").hidden = web;
+ $("#textSizeSub").textContent = t("Everything in magpie's windows, larger; {keys} too", { keys: textSizeKeys() });
+ $("#textSizeSegs").replaceChildren(segs(TEXT_SIZES.map((n) => [n, n + "%"]), s.textSize || 100, (n) => setTextSize(n)));
$("#traySegs").replaceChildren(segs(TRAYS.map(([id, name]) => [id, t(name)]), s.tray || "panel", (tray) => savePrefs({ ...keep, tray })));
// the Dock is the Mac's; the tray and the login item the app's
$("#dockRow").hidden = !document.body.classList.contains("mac");
diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js
index 5f553c64..0c50e4a5 100644
--- a/internal/gui/assets/i18n.js
+++ b/internal/gui/assets/i18n.js
@@ -963,6 +963,9 @@ const I18N = {
"Light or dark, or whatever the system is showing": "浅色、深色,或跟随系统",
"Language": "语言",
"Applies at once, no restart": "立即生效,无需重启",
+ "Text size": "文字大小",
+ "Everything in magpie's windows, larger; {keys} too": "放大 magpie 窗口里的一切;也可用 {keys}",
+ "Text size {n}%": "文字大小 {n}%",
"System": "跟随系统",
"Light": "浅色",
"Dark": "深色",
diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html
index d488752b..0b5fd1b0 100644
--- a/internal/gui/assets/index.html
+++ b/internal/gui/assets/index.html
@@ -165,6 +165,10 @@
Language
Applies at once, no restart
+
Tray icon
What clicking magpie’s icon in the menu bar or tray opens
diff --git a/internal/gui/dev_on.go b/internal/gui/dev_on.go
index afb32080..ffa64625 100644
--- a/internal/gui/dev_on.go
+++ b/internal/gui/dev_on.go
@@ -230,6 +230,9 @@ func (c remoteWindows) TintPanel(rgba [4]uint8, ms int) bool {
q := url.Values{"c": {fmt.Sprintf("%d,%d,%d,%d", rgba[0], rgba[1], rgba[2], rgba[3])}, "ms": {strconv.Itoa(ms)}}
return c.post("tint", url.Values{"arg": {q.Encode()}}) == "ok"
}
+func (c remoteWindows) SetTextSize(percent int) {
+ c.post("textsize", url.Values{"arg": {strconv.Itoa(percent)}})
+}
// backendClient waits out a backend's restart: a request made while the old
// one is gone connects to the new one once it listens.
@@ -295,6 +298,10 @@ func devShell(h *host) http.Handler {
if n, g, ok := parseFit(q); ok {
h.FitPanel(n, g)
}
+ case "textsize":
+ if n, err := strconv.Atoi(arg); err == nil {
+ h.SetTextSize(n)
+ }
case "tint":
q, _ := url.ParseQuery(arg)
if c, ms, ok := parseTint(q); ok && h.TintPanel(c, ms) {
diff --git a/internal/gui/glide_darwin.go b/internal/gui/glide_darwin.go
index a808605a..da95dc14 100644
--- a/internal/gui/glide_darwin.go
+++ b/internal/gui/glide_darwin.go
@@ -4,9 +4,26 @@ package gui
/*
#cgo CFLAGS: -x objective-c
-#cgo LDFLAGS: -framework Cocoa -framework QuartzCore
+#cgo LDFLAGS: -framework Cocoa -framework QuartzCore -framework WebKit
#import
#import
+#import
+
+// The text size is the webview's page zoom, Safari's Cmd-+: the page is laid
+// out again in larger CSS pixels. Wails' own zoom on the Mac is the
+// webview's magnification, a pinch that lays nothing out again and scrolls
+// the page sideways.
+static void setPageZoom(void *w, double z) {
+ NSWindow *win = (NSWindow *)w;
+ dispatch_async(dispatch_get_main_queue(), ^{
+ if (![win respondsToSelector:@selector(webView)]) return;
+ id web = [win performSelector:@selector(webView)];
+ if (![web isKindOfClass:[WKWebView class]]) return;
+ if (@available(macOS 11.0, *)) {
+ ((WKWebView *)web).pageZoom = z;
+ }
+ });
+}
// The panel hangs from the menu bar, so its top edge stays where it is and
// the bottom moves; the system animates the frame on its own display clock.
@@ -134,3 +151,10 @@ func boolInt(b bool) int {
}
return 0
}
+
+// setPageZoom zooms w's page to z, the text size.
+func setPageZoom(w *application.WebviewWindow, z float64) {
+ if p := w.NativeWindow(); p != nil {
+ C.setPageZoom(p, C.double(z))
+ }
+}
diff --git a/internal/gui/glide_other.go b/internal/gui/glide_other.go
index 2967d765..2ce997a5 100644
--- a/internal/gui/glide_other.go
+++ b/internal/gui/glide_other.go
@@ -14,7 +14,7 @@ func (h *host) glidePanel(height int, g Glide) bool {
_, from := h.panel.Size()
gen := h.glides.Add(1)
go stepGlide(g, from, height, func() bool { return h.glides.Load() == gen }, func(v int) {
- h.panel.SetSize(panelWidth, v)
+ h.panel.SetSize(h.panelW(), v)
_ = h.tray.PositionWindow(h.panel, 6)
})
return true
@@ -22,3 +22,7 @@ func (h *host) glidePanel(height int, g Glide) bool {
// TintPanel: the page paints the panel's tint itself here.
func (h *host) TintPanel(c [4]uint8, ms int) bool { return false }
+
+// setPageZoom zooms w's page to z, as a browser zooms (WebView2's zoom
+// factor, WebKitGTK's zoom level): the text size.
+func setPageZoom(w *application.WebviewWindow, z float64) { w.SetZoom(z) }
diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md
index ea281d4f..3d5d977b 100644
--- a/internal/gui/tests/README.md
+++ b/internal/gui/tests/README.md
@@ -98,6 +98,18 @@ cny (#212): the Usage page's total converts, the row's tooltip carries the
rate, picking it with the settings list scrolled well down moves nothing,
and the choice survives a reload — in English and Chinese.
+`text-size.test.cjs` checks the Settings page's Text size row (100, 110,
+125 and 150%): a pick posts to /api/settings/text-size without scrolling
+the page, stays picked after a reload (boot.js carries it, and the page's
+`--zoom` has it before the first paint), and a save of the other settings
+leaves it be; Ctrl/Cmd + = and − step through the sizes and 0 goes back to
+100%, in the window and the tray panel, the other modifier doing nothing.
+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%.
+
`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
heading would be, after Preferences, before Local network, Codex's picked to
@@ -193,7 +205,7 @@ saying it stays where it is and posting the name; in English and Chinese.
With Node.js and Playwright available:
```sh
-node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs node --test internal/gui/tests/signin-callback.test.cjs internal/gui/tests/routing-steady.test.cjs internal/gui/tests/old-webkit.test.cjs internal/gui/tests/session-terminal.test.cjs
+node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs node --test internal/gui/tests/signin-callback.test.cjs internal/gui/tests/routing-steady.test.cjs internal/gui/tests/old-webkit.test.cjs internal/gui/tests/session-terminal.test.cjs internal/gui/tests/text-size.test.cjs
```
If Playwright is installed outside the repository, set `NODE_PATH` to the
diff --git a/internal/gui/tests/currency.test.cjs b/internal/gui/tests/currency.test.cjs
index a9c3041b..b8743dbb 100644
--- a/internal/gui/tests/currency.test.cjs
+++ b/internal/gui/tests/currency.test.cjs
@@ -80,7 +80,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
]) {
await t.test(lang, async () => {
const errors = [];
- const context = await browser.newContext({ viewport: { width: 900, height: 420 }, reducedMotion: "reduce" });
+ const context = await browser.newContext({ viewport: { width: 900, height: 480 }, reducedMotion: "reduce" });
const page = await context.newPage();
pages.push(page);
page.setDefaultTimeout(5000);
diff --git a/internal/gui/tests/text-size.test.cjs b/internal/gui/tests/text-size.test.cjs
new file mode 100644
index 00000000..4e7d6e4c
--- /dev/null
+++ b/internal/gui/tests/text-size.test.cjs
@@ -0,0 +1,235 @@
+// Run with Node's test runner and Playwright on the module path; see README.md.
+// The Text size setting (Settings → Preferences, Ctrl/Cmd + − 0): a pick
+// posts to /api/settings/text-size and stays picked after a reload (boot.js
+// carries it); the shortcuts step through 100/110/125/150% and back, in the
+// window and the tray panel, and a click on the control never scrolls the
+// page. The zoom itself is the webview's (Go), so here it is as a browser
+// zoomed: the window's points over the zoom in CSS pixels, deviceScaleFactor
+// the zoom — and at 150% nothing in any view runs off to the side, the Mac
+// header still level with the traffic lights. English and Chinese; no
+// backend, the API is faked here. ARTIFACT_DIR gets screenshots at 100% and
+// 150%, light and dark.
+const assert = require("node:assert/strict");
+const fs = require("node:fs/promises");
+const path = require("node:path");
+const { test } = require("node:test");
+const { chromium, webkit } = require("playwright");
+
+const assets = path.resolve(__dirname, "../assets");
+
+const usage = {
+ calls: 5, errors: 0, input: 12000, output: 4000, cache_read: 500, cache_write: 100, reasoning: 200,
+ unpriced: 0, cost: 12.34, bucket: "day",
+ series: [
+ { label: "Mon", input: 6000, output: 2000, calls: 3, cost: 7 },
+ { label: "Tue", input: 6000, output: 2000, calls: 2, cost: 5.34 },
+ ],
+ agents: [{ name: "Claude Code", calls: 5, cost: 12.34 }],
+ models: [{ name: "claude-opus", calls: 5, cost: 12.34 }],
+};
+
+const calls = Array.from({ length: 5 }, (_, i) => ({
+ time: new Date(Date.now() - (i + 1) * 60e3).toISOString(), agent: "fixture", model: "fixture/model-a", status: 200, ms: 900,
+}));
+const providers = {
+ providers: [],
+ presets: [], excluded: [],
+ gateway: { running: true, window: true, mine: true, url: "http://127.0.0.1:3999", calls, groups: [] },
+};
+
+function settingsPayload(over) {
+ return {
+ theme: "light", lang: "en", tray: "panel", quotaLeft: false, currency: "usd", textSize: 100,
+ dock: false, dockWindow: false, proxy: "", redact: false, redactPersonal: false, redactWords: [],
+ codexWarmup: "", claudeWarmup: "", codexWarmAt: "", claudeWarmAt: "", workbuddyCheckin: false, noStats: false,
+ trayUsage: "", trayUsageEvery: 3, vision: "", imageGen: "",
+ version: "0.1.400", dir: "~/.config/magpie", gateway: "http://127.0.0.1:3425",
+ proxyNow: "none", proxySource: "none", login: false,
+ visionModels: [], imageGenModels: [], workbuddyCheckins: [], lanURLs: [],
+ fx: { rate: 7.2, at: new Date().toISOString(), stale: false },
+ ...over,
+ };
+}
+
+// one fake magpie per test: its settings live across reloads, as the real
+// one's do, and every text size posted is kept in posts
+function server(lang, theme, size, posts) {
+ let cur = settingsPayload({ lang, theme, textSize: size });
+ return async (route) => {
+ const req = route.request(), url = new URL(req.url());
+ const json = (data) => route.fulfill({ json: data });
+ if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"${theme}",web:false,textSize:${cur.textSize}};` });
+ if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
+ if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings: { lang, theme, textSize: cur.textSize }, fx: cur.fx });
+ if (url.pathname === "/api/settings/text-size") {
+ const { size: n } = req.postDataJSON();
+ posts.push(n);
+ cur = { ...cur, textSize: n };
+ return json(cur);
+ }
+ if (url.pathname === "/api/settings") {
+ // the Settings page's own save never carries the text size
+ if (req.method() === "POST") cur = { ...cur, ...req.postDataJSON(), textSize: cur.textSize };
+ return json(cur);
+ }
+ if (url.pathname === "/api/usage") return json(usage);
+ if (url.pathname === "/api/library") return json({ dir: "~/.config/magpie/library", backups: "", home: "~", agents: [], instructions: { agents: [], sets: [] }, servers: [], foundServers: [], skills: [] });
+ if (url.pathname === "/api/usage/quotas") return json([]);
+ if (url.pathname === "/api/groups") return json({ groups: [], models: [] });
+ if (url.pathname === "/api/providers") return json(providers);
+ if (url.pathname === "/api/gateway/trace") {
+ if (url.searchParams.get("wait")) await new Promise((r) => setTimeout(r, 20e3));
+ return json({ mine: true, now: new Date().toISOString(), seq: 0, totals: { requests: 0, rerouted: 0, errors: 0 }, routes: [] });
+ }
+ if (url.pathname === "/api/gateway/history") return json({ cut: false, days: [], routes: [] });
+ if (url.pathname === "/api/window/fit") return route.fulfill({ status: 204 });
+ if (url.pathname.startsWith("/api/")) return json({});
+ const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
+ const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
+ await route.fulfill({ body: await fs.readFile(file), contentType });
+ };
+}
+
+const zoomVar = (page) => page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--zoom").trim());
+const settingsTop = (page) => page.locator("#view-settings").evaluate((v) => v.scrollTop);
+// what runs off to the side: the page, the view, and anything in the view
+// wider than it
+const overflow = (page) => page.evaluate(() => {
+ const out = [];
+ const de = document.documentElement;
+ if (de.scrollWidth > de.clientWidth) out.push("page " + de.scrollWidth + ">" + de.clientWidth);
+ const v = [...document.querySelectorAll("main.view")].find((m) => !m.hidden);
+ if (v && v.scrollWidth > v.clientWidth) out.push(v.id + " " + v.scrollWidth + ">" + v.clientWidth);
+ const top = document.querySelector(".top");
+ if (top && top.scrollWidth > top.clientWidth) out.push("header " + top.scrollWidth + ">" + top.clientWidth);
+ return out;
+});
+
+for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
+ test(engine + ": the text size", async (t) => {
+ assert(["chromium", "webkit"].includes(engine), "BROWSER must be chromium or webkit");
+ const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
+ const shots = [];
+ t.after(async () => {
+ if (process.env.ARTIFACT_DIR) {
+ await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true });
+ for (const [name, buf] of shots) await fs.writeFile(path.join(process.env.ARTIFACT_DIR, `${engine}-text-size-${name}.png`), buf);
+ }
+ await browser.close();
+ });
+ const open = async (lang, theme, size, posts, { width = 900, height = 520, zoom = 1, url = "/" } = {}) => {
+ const context = await browser.newContext({ viewport: { width, height }, deviceScaleFactor: zoom, reducedMotion: "reduce", colorScheme: theme });
+ const page = await context.newPage();
+ page.setDefaultTimeout(5000);
+ page.errors = [];
+ page.on("pageerror", (e) => page.errors.push(e.message));
+ await page.route("**/*", server(lang, theme, size, posts));
+ await page.goto("http://magpie.test" + url);
+ return page;
+ };
+
+ for (const [lang, name, toast] of [["en", "Text size", "Text size 125%"], ["zh", "文字大小", "文字大小 125%"]]) {
+ await t.test(lang + ": picked, kept, and stepped with the keys", async () => {
+ const posts = [];
+ const page = await open(lang, "light", 100, posts);
+ const mod = await page.evaluate(() => /^Mac/.test(navigator.platform) ? "Meta" : "Control");
+ assert.equal(await zoomVar(page), "1");
+ await page.locator("#prefs").click();
+ const segs = page.locator("#textSizeSegs .opt");
+ await segs.first().waitFor();
+ assert.equal((await page.locator("#textSizeRow .name").textContent()).trim(), name);
+ assert.deepEqual((await segs.allTextContents()).map((s) => s.trim()), ["100%", "110%", "125%", "150%"]);
+ assert.equal(await segs.nth(0).evaluate((b) => b.classList.contains("on")), true, "100% starts picked");
+ const sub = await page.locator("#textSizeSub").textContent();
+ assert(sub.includes(mod === "Meta" ? "⌘0" : "Ctrl+0"), sub);
+
+ // scrolled a little (a real wheel), picking 125% moves nothing
+ await page.locator("#textSizeSegs").hover();
+ for (let i = 0; i < 20 && !(await settingsTop(page)); i++) { await page.mouse.wheel(0, 30); await page.waitForTimeout(20); }
+ const before = await settingsTop(page);
+ assert(before > 0, "the settings list must scroll");
+ await segs.nth(2).click();
+ await page.locator("#textSizeSegs .opt.on", { hasText: "125%" }).waitFor();
+ await page.waitForTimeout(300);
+ assert.equal(await settingsTop(page), before, "picking a text size must not scroll the settings page");
+ assert.deepEqual(posts, [125]);
+ assert.equal(await zoomVar(page), "1.25");
+
+ // kept: a reload has it from boot.js before the first paint
+ await page.reload();
+ assert.equal(await zoomVar(page), "1.25");
+ await page.locator("#prefs").click();
+ await page.locator("#textSizeSegs .opt.on", { hasText: "125%" }).waitFor();
+
+ // the keys: + to 150, + again stays there, − to 125, 0 back to 100
+ await page.keyboard.press(mod + "+Equal");
+ await page.locator("#textSizeSegs .opt.on", { hasText: "150%" }).waitFor();
+ await page.keyboard.press(mod + "+Equal");
+ await page.keyboard.press(mod + "+Minus");
+ await page.locator("#textSizeSegs .opt.on", { hasText: "125%" }).waitFor();
+ assert.equal((await page.locator("#status").textContent()).trim(), toast);
+ await page.keyboard.press(mod + "+Digit0");
+ await page.locator("#textSizeSegs .opt.on", { hasText: "100%" }).waitFor();
+ assert.deepEqual(posts, [125, 150, 125, 100]);
+ assert.equal(await zoomVar(page), "1");
+ // the other modifier is not the shortcut
+ await page.keyboard.press((mod === "Meta" ? "Control" : "Alt") + "+Equal");
+ await page.waitForTimeout(100);
+ assert.deepEqual(posts, [125, 150, 125, 100]);
+ // a save of the page's other settings leaves it be
+ await page.locator("#themeSegs .opt").nth(2).click();
+ await page.waitForTimeout(200);
+ await page.reload();
+ await page.locator("#prefs").click();
+ await page.locator("#textSizeSegs .opt.on", { hasText: "100%" }).waitFor();
+ assert.deepEqual(page.errors, []);
+ await page.context().close();
+ });
+
+ await t.test(lang + ": the panel", async () => {
+ const posts = [];
+ const page = await open(lang, "light", 110, posts, { width: 440, height: 560, url: "/?mode=panel" });
+ const mod = await page.evaluate(() => /^Mac/.test(navigator.platform) ? "Meta" : "Control");
+ await page.locator("#view-agents").waitFor();
+ assert.equal(await zoomVar(page), "1.1");
+ await page.keyboard.press(mod + "+Equal");
+ await page.waitForTimeout(200);
+ await page.keyboard.press(mod + "+Digit0");
+ await page.waitForTimeout(200);
+ assert.deepEqual(posts, [125, 100]);
+ 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
+ for (const [zoom, size] of [[1, 100], [1.5, 150]]) {
+ const page = await open(lang, theme, size, [], { width: Math.round(840 / zoom), height: Math.round(630 / zoom), zoom });
+ for (const v of ["agents", "providers", "gateway", "routing", "usage", "library", "settings"]) {
+ await page.locator(v === "settings" ? "#prefs" : `[data-view="${v}"]`).click();
+ await page.waitForTimeout(250);
+ assert.deepEqual(await overflow(page), [], `${v} at ${size}%`);
+ assert.equal(await page.locator("#status.err").count(), 0, `${v}: ${await page.locator("#status").textContent()}`);
+ if (v === "agents" || v === "settings") shots.push([`${lang}-${theme}-${size}-${v}`, await page.screenshot()]);
+ }
+ if (await page.evaluate(() => document.body.classList.contains("mac"))) {
+ // the header in points stays 50 tall, as the traffic lights want
+ const h = await page.locator(".top").evaluate((e) => e.getBoundingClientRect().height);
+ assert(Math.abs(h * zoom - 50) < 1, `header ${h}px at ${size}%`);
+ }
+ assert.deepEqual(page.errors, []);
+ await page.context().close();
+ }
+ // the panel, as wide as it always is in CSS pixels
+ const panel = await open(lang, theme, 150, [], { width: 440, height: 560, zoom: 1.5, url: "/?mode=panel" });
+ await panel.locator("#view-agents").waitFor();
+ await panel.waitForTimeout(250);
+ assert.deepEqual(await overflow(panel), [], "the panel at 150%");
+ shots.push([`${lang}-${theme}-150-panel`, await panel.screenshot()]);
+ await panel.context().close();
+ });
+ }
+ }
+ });
+}
diff --git a/internal/gui/textsize.go b/internal/gui/textsize.go
new file mode 100644
index 00000000..33cb64e0
--- /dev/null
+++ b/internal/gui/textsize.go
@@ -0,0 +1,55 @@
+package gui
+
+import "math"
+
+// The text size (Settings → Text size, Ctrl/Cmd +, − and 0) is each
+// webview's own zoom, as a browser zooms a page: the page is laid out in
+// fewer, larger CSS pixels, and everything it measures stays in them, so
+// what it places by a measure (a menu under its button, the panel's
+// height) is where it was. The windows around the pages grow instead.
+
+// panelMax keeps the panel a drop-down, not most of the screen: longer
+// content (the usage of many accounts) scrolls in it (#124). These are the
+// page's CSS pixels; at a larger text size the panel is that much larger.
+const panelWidth, panelMin, panelMax = 440, 220, 560
+
+// The main window's smallest size, in the page's CSS pixels too.
+const windowMinW, windowMinH = 560, 420
+
+// zoomed is n CSS pixels in the window's points at zoom z.
+func zoomed(n int, z float64) int { return int(math.Round(float64(n) * z)) }
+
+// zoomOf is a text size in percent as the webviews' zoom factor; anything
+// unset or out of reason is 1.
+func zoomOf(percent int) float64 {
+ if percent < 50 || percent > 300 {
+ return 1
+ }
+ return float64(percent) / 100
+}
+
+// panelFrame is the panel's size, in points, for a page page CSS pixels
+// tall at zoom z, never taller than room (the screen's height below the
+// menu bar or above the taskbar; 0 when it isn't known).
+func panelFrame(page int, z float64, room int) (w, h int) {
+ top := zoomed(panelMax, z)
+ if room > 0 && top > room {
+ top = room
+ }
+ h = min(top, max(zoomed(panelMin, z), zoomed(page, z)))
+ return zoomed(panelWidth, z), h
+}
+
+// windowMin is the main window's smallest size, in points, at zoom z: the
+// page gets no fewer CSS pixels than at 100%, unless the screen (w, h; 0
+// when unknown) is smaller than that.
+func windowMin(z float64, w, h int) (int, int) {
+ mw, mh := zoomed(windowMinW, z), zoomed(windowMinH, z)
+ if w > 0 {
+ mw = min(mw, max(windowMinW, w))
+ }
+ if h > 0 {
+ mh = min(mh, max(windowMinH, h))
+ }
+ return mw, mh
+}
diff --git a/internal/gui/textsize_test.go b/internal/gui/textsize_test.go
new file mode 100644
index 00000000..94096c1b
--- /dev/null
+++ b/internal/gui/textsize_test.go
@@ -0,0 +1,91 @@
+package gui
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/yetone/magpie/internal/settings"
+)
+
+// The panel and the window grow with the text size, so the page inside
+// has as many CSS pixels as at 100%; the panel no taller than the screen.
+func TestTextSizeFrames(t *testing.T) {
+ for _, c := range []struct {
+ page int
+ z float64
+ room, w, h int
+ }{
+ {400, 1, 0, 440, 400},
+ {400, 1.5, 0, 660, 600},
+ {100, 1.25, 0, 550, 275}, // no shorter than panelMin, zoomed
+ {900, 1.5, 0, 660, 840}, // no taller than panelMax, zoomed
+ {900, 1.5, 700, 660, 700}, // nor than the screen has room for
+ {400, 1.1, 1000, 484, 440}, // 440 at 110%
+ } {
+ if w, h := panelFrame(c.page, c.z, c.room); w != c.w || h != c.h {
+ t.Errorf("panelFrame(%d, %v, %d) = %d×%d, want %d×%d", c.page, c.z, c.room, w, h, c.w, c.h)
+ }
+ }
+ if w, h := windowMin(1.5, 0, 0); w != 840 || h != 630 {
+ t.Errorf("window min at 150%%: %d×%d", w, h)
+ }
+ if w, h := windowMin(1.5, 800, 600); w != 800 || h != 600 {
+ t.Errorf("window min on a small screen: %d×%d", w, h)
+ }
+ if w, h := windowMin(1.5, 400, 300); w != 560 || h != 420 {
+ t.Errorf("window min is never under 100%%'s: %d×%d", w, h)
+ }
+ if zoomOf(0) != 1 || zoomOf(125) != 1.25 || zoomOf(9000) != 1 {
+ t.Error("zoomOf")
+ }
+}
+
+type zoomWindows struct {
+ webHost
+ sizes []int
+}
+
+func (z *zoomWindows) SetTextSize(n int) { z.sizes = append(z.sizes, n) }
+
+// The text size is saved on its own, zooms the windows, and a save of the
+// Settings page (which never sends it) leaves it as it is.
+func TestTextSizeSetting(t *testing.T) {
+ h := t.TempDir()
+ t.Setenv("HOME", h)
+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(h, ".config"))
+ t.Setenv("XDG_CACHE_HOME", filepath.Join(h, ".cache"))
+ w := &zoomWindows{}
+ srv := Handler(w, nil)
+ post := func(path, body string) *httptest.ResponseRecorder {
+ rec := httptest.NewRecorder()
+ srv.ServeHTTP(rec, httptest.NewRequest("POST", path, strings.NewReader(body)))
+ return rec
+ }
+ rec := post("/api/settings/text-size", `{"size":125}`)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("%d %s", rec.Code, rec.Body)
+ }
+ var got struct{ TextSize int }
+ if json.Unmarshal(rec.Body.Bytes(), &got); got.TextSize != 125 || settings.Load().TextSize != 125 {
+ t.Fatalf("answered %d, saved %d", got.TextSize, settings.Load().TextSize)
+ }
+ if len(w.sizes) != 1 || w.sizes[0] != 125 {
+ t.Fatalf("windows zoomed to %v", w.sizes)
+ }
+ if rec := post("/api/settings/text-size", `{"size":133}`); rec.Code == http.StatusOK || settings.Load().TextSize != 125 || len(w.sizes) != 1 {
+ t.Fatalf("a size not offered was taken: %d", rec.Code)
+ }
+ if rec := post("/api/settings", `{"theme":"dark","lang":"en"}`); rec.Code != http.StatusOK || settings.Load().TextSize != 125 {
+ t.Fatalf("the Settings page's save changed the text size: %d %d", rec.Code, settings.Load().TextSize)
+ }
+ // the page is told it before it paints
+ rec = httptest.NewRecorder()
+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/boot.js", nil))
+ if !strings.Contains(rec.Body.String(), `"textSize":125`) {
+ t.Fatalf("boot.js: %s", rec.Body)
+ }
+}
diff --git a/internal/gui/web.go b/internal/gui/web.go
index 01b7b60c..5d443d0f 100644
--- a/internal/gui/web.go
+++ b/internal/gui/web.go
@@ -33,6 +33,7 @@ func (webHost) OpenURL(string) {} // the page opens links itself
func (webHost) Copy(string) bool { return false }
func (webHost) FitPanel(int, Glide) {}
func (webHost) TintPanel([4]uint8, int) bool { return false }
+func (webHost) SetTextSize(int) {} // the browser zooms its own tab
func (webHost) OpenFolder(path string) error {
return errors.New("in the browser magpie can't open folders: it is " + tilde(path))
}
diff --git a/internal/settings/settings.go b/internal/settings/settings.go
index d56b5cc8..d3547bee 100644
--- a/internal/settings/settings.go
+++ b/internal/settings/settings.go
@@ -102,6 +102,10 @@ type Settings struct {
// QuotaLeft shows a subscription's windows by how much of each is left,
// not used: the Usage page, the tray panel and the menu bar alike.
QuotaLeft bool `json:"quotaLeft,omitempty"`
+ // TextSize is how large the window's and the tray panel's pages are
+ // drawn, in percent (one of TextSizes): the webviews' own zoom, as a
+ // browser's, so the text and everything around it grow together.
+ TextSize int `json:"textSize,omitempty"`
// How the agents are listed, by agent id. AgentOrder comes first, as
// ordered; an agent it doesn't name (one installed since) follows in
// magpie's own order. A hidden agent is folded away at the bottom of the
@@ -174,6 +178,9 @@ var (
Warmups = []string{"", "week", "all"}
// TrayEvery are TrayUsageEvery's values, in minutes.
TrayEvery = []int{1, 3, 5, 10, 30}
+ // TextSizes are TextSize's values, in percent. None is under 100: the
+ // webviews' zoom on Windows and Linux (Wails' SetZoom) goes no lower.
+ TextSizes = []int{100, 110, 125, 150}
)
var validTerminalBundleID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9.-]{0,254}$`)
@@ -251,6 +258,9 @@ func Save(s Settings) error {
if !slices.Contains(TrayEvery, s.TrayUsageEvery) {
return fmt.Errorf("the menu bar's usage is refreshed every %v minutes, not %d", TrayEvery, s.TrayUsageEvery)
}
+ if !slices.Contains(TextSizes, s.TextSize) {
+ return fmt.Errorf("text size must be one of %v percent, not %d", TextSizes, s.TextSize)
+ }
s.Proxy = strings.TrimSpace(s.Proxy)
if err := CheckProxy(s.Proxy); err != nil {
return err
@@ -301,6 +311,9 @@ func (s Settings) normal() Settings {
if s.TrayUsageEvery == 0 {
s.TrayUsageEvery = 3
}
+ if s.TextSize == 0 {
+ s.TextSize = 100
+ }
// a time of day as 06:00 whichever way it came (6:00, 06:00:00)
for _, at := range []*string{&s.CodexWarmAt, &s.ClaudeWarmAt} {
*at = strings.TrimSpace(*at)
diff --git a/internal/settings/settings_test.go b/internal/settings/settings_test.go
index 55de6608..a08e4be6 100644
--- a/internal/settings/settings_test.go
+++ b/internal/settings/settings_test.go
@@ -51,6 +51,18 @@ 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 {
+ t.Fatalf("default text size: %+v", Load())
+ }
+ if Save(Settings{TextSize: 125}) != nil || Load().TextSize != 125 {
+ t.Fatal("text size not kept")
+ }
+ for _, bad := range []int{90, 120, 300, -1} {
+ if Save(Settings{TextSize: bad}) == nil {
+ t.Fatalf("text size %d accepted", bad)
+ }
+ }
if filepath.Base(Path()) != "settings.json" {
t.Fatal(Path())
}