From 575e694ed71fade343dd6740df435200105930fd Mon Sep 17 00:00:00 2001 From: yetone Date: Wed, 30 Sep 2026 02:49:24 +0800 Subject: [PATCH] =?UTF-8?q?settings:=20a=20Text=20size=20preference=20(?= =?UTF-8?q?=E6=96=87=E5=AD=97=E5=A4=A7=E5=B0=8F)=20that=20makes=20everythi?= =?UTF-8?q?ng=20in=20magpie's=20windows=20larger,=20100/110/125/150%,=20wi?= =?UTF-8?q?th=20Ctrl/Cmd=20+=20=3D=20=E2=88=92=200=20in=20the=20window=20a?= =?UTF-8?q?nd=20the=20tray=20panel,=20and=20Windows'=20text=20drawn=20in?= =?UTF-8?q?=20its=20own=20UI=20faces=20(Discord,=20Clarence=20Dan:=20the?= =?UTF-8?q?=20font=20size=20is=20hard=20on=20the=20eyes=20on=20Windows)=20?= =?UTF-8?q?=E2=80=94=20Settings=20=E2=86=92=20Preferences=20gains=20a=20Te?= =?UTF-8?q?xt=20size=20row=20under=20Language=20whose=20pick=20posts=20to?= =?UTF-8?q?=20a=20new=20POST=20/api/settings/text-size=20(saved=20as=20set?= =?UTF-8?q?tings.TextSize,=20validated=20against=20100/110/125/150,=200=20?= =?UTF-8?q?read=20as=20100,=20and=20kept=20by=20the=20Settings=20page's=20?= =?UTF-8?q?general=20save,=20which=20never=20sends=20it).=20The=20size=20i?= =?UTF-8?q?s=20each=20webview's=20own=20zoom,=20as=20a=20browser=20zooms,?= =?UTF-8?q?=20not=20CSS=20zoom:=20CSS=20zoom=20leaves=20getBoundingClientR?= =?UTF-8?q?ect=20and=20clientX=20in=20zoomed=20pixels=20while=20offsets,?= =?UTF-8?q?=20scrollTop=20and=20100vh=20are=20not,=20and=20app.js,=20routi?= =?UTF-8?q?ng.js=20and=20library.js=20place=20menus,=20popovers=20and=20th?= =?UTF-8?q?e=20panel's=20height=20by=20those=20measures;=20under=20the=20w?= =?UTF-8?q?ebview's=20zoom=20the=20page=20is=20laid=20out=20again=20in=20f?= =?UTF-8?q?ewer,=20larger=20CSS=20pixels=20and=20every=20measure=20agrees.?= =?UTF-8?q?=20Windows=20and=20Linux=20use=20Wails'=20SetZoom=20(WebView2's?= =?UTF-8?q?=20zoom=20factor,=20WebKitGTK's=20zoom=20level;=20the=20Zoom=20?= =?UTF-8?q?window=20option=20at=20creation,=20so=20the=20pages=20open=20at?= =?UTF-8?q?=20the=20saved=20size),=20the=20Mac=20WKWebView.pageZoom=20thro?= =?UTF-8?q?ugh=20cgo=20(macOS=2011+),=20since=20Wails'=20Mac=20zoom=20is?= =?UTF-8?q?=20magnification,=20which=20lays=20nothing=20out=20again.=20Non?= =?UTF-8?q?e=20is=20under=20100%=20because=20Wails=20clamps=20SetZoom=20be?= =?UTF-8?q?low=201=20on=20Windows=20and=20Linux.=20The=20windows=20grow=20?= =?UTF-8?q?around=20the=20pages:=20the=20panel's=20width=20and=20the=20hei?= =?UTF-8?q?ght=20its=20page=20asks=20for=20(still=20in=20CSS=20pixels)=20a?= =?UTF-8?q?re=20multiplied=20by=20the=20zoom,=20capped=20at=20the=20screen?= =?UTF-8?q?'s=20work=20area=20(textsize.go=20panelFrame),=20and=20resized?= =?UTF-8?q?=20at=20once=20when=20the=20size=20changes;=20the=20main=20wind?= =?UTF-8?q?ow's=20minimum=20is=20560=C3=97420=20CSS=20pixels=20at=20the=20?= =?UTF-8?q?size,=20capped=20by=20the=20screen,=20so=20a=20window=20smaller?= =?UTF-8?q?=20than=20that=20grows.=20Runtime=20changes=20wait=20for=20the?= =?UTF-8?q?=20app=20to=20be=20ready=20(whenReady),=20and=20at=20startup=20?= =?UTF-8?q?the=20Mac=20applies=20the=20zoom=20once=20its=20webviews=20exis?= =?UTF-8?q?t.=20The=20Mac=20header,=20drawn=20under=20the=20native=20traff?= =?UTF-8?q?ic=20lights=20which=20do=20not=20grow,=20divides=20its=20height?= =?UTF-8?q?,=20left=20padding=20and=20the=20modal's=20top=20by=20--zoom=20?= =?UTF-8?q?(set=20from=20boot.js=20before=20the=20first=20paint=20and=20fr?= =?UTF-8?q?om=20the=20settings),=20so=20it=20stays=2050=20points=20tall=20?= =?UTF-8?q?and=20level=20with=20them.=20The=20shortcuts=20are=20Cmd=20on?= =?UTF-8?q?=20the=20Mac=20and=20Ctrl=20elsewhere=20(the=20other=20modifier?= =?UTF-8?q?=20does=20nothing),=20step=20through=20the=20sizes=20and=200=20?= =?UTF-8?q?goes=20back=20to=20100%,=20with=20a=20"Text=20size=20125%"=20to?= =?UTF-8?q?ast;=20in=20`magpie=20web`=20the=20row=20is=20hidden=20and=20th?= =?UTF-8?q?e=20keys=20are=20left=20to=20the=20browser's=20own=20zoom.=20On?= =?UTF-8?q?=20Windows=20the=20page=20names=20its=20faces=20(html.win):=20S?= =?UTF-8?q?egoe=20UI=20Variable=20Text,=20Segoe=20UI,=20then=20Microsoft?= =?UTF-8?q?=20YaHei=20UI=20for=20Chinese=20rather=20than=20whatever=20WebV?= =?UTF-8?q?iew2=20falls=20back=20to;=20the=20Mac=20and=20Linux=20stack=20i?= =?UTF-8?q?s=20unchanged,=20and=20there=20is=20no=20automatic=20size=20bum?= =?UTF-8?q?p.=20New=20strings=20in=20en=20and=20zh.=20Tested:=20internal/g?= =?UTF-8?q?ui/tests/text-size.test.cjs=20in=20Chromium=20and=20WebKit,=20e?= =?UTF-8?q?n=20and=20zh,=20light=20and=20dark=20=E2=80=94=20a=20pick=20pos?= =?UTF-8?q?ts=20and=20does=20not=20scroll=20the=20settings=20page,=20stays?= =?UTF-8?q?=20picked=20after=20a=20reload=20with=20--zoom=20from=20boot.js?= =?UTF-8?q?,=20the=20Settings=20page's=20other=20saves=20leave=20it,=20the?= =?UTF-8?q?=20shortcuts=20step=20and=20reset=20in=20the=20window=20and=20t?= =?UTF-8?q?he=20panel,=20and=20at=20150%=20(the=20smallest=20window,=20840?= =?UTF-8?q?=C3=97630=20points,=20as=20560=C3=97420=20CSS=20pixels=20at=20d?= =?UTF-8?q?evice=20scale=201.5)=20no=20view,=20the=20header=20or=20the=20p?= =?UTF-8?q?anel=20runs=20off=20to=20the=20side,=20the=20Mac=20header=20is?= =?UTF-8?q?=2050=20points;=20screenshots=20at=20100%=20and=20150%=20looked?= =?UTF-8?q?=20at.=20currency.test.cjs's=20viewport=20is=20480=20tall=20ins?= =?UTF-8?q?tead=20of=20420,=20as=20settings-groups'=20is:=20the=20new=20ro?= =?UTF-8?q?w=20put=20the=20Currency=20control=20under=20the=20footer,=20wh?= =?UTF-8?q?ere=20WebKit's=20hover=20could=20not=20reach=20it.=20Go:=20Test?= =?UTF-8?q?TextSizeFrames=20(panel=20and=20window=20sizes)=20and=20TestTex?= =?UTF-8?q?tSizeSetting=20(the=20endpoint=20saves,=20zooms=20the=20windows?= =?UTF-8?q?,=20rejects=20133,=20survives=20the=20general=20save,=20boot.js?= =?UTF-8?q?=20carries=20it)=20in=20internal/gui,=20and=20the=20settings=20?= =?UTF-8?q?round=20trip.=20go=20build=20-tags=20nogui=20(and=20GOOS=3Dlinu?= =?UTF-8?q?x,=20GOOS=3Dwindows,=20plain=20darwin),=20go=20test=20-tags=20n?= =?UTF-8?q?ogui=20./...=20(TestUserPath=20flaked=20once=20under=20load=20a?= =?UTF-8?q?nd=20passes=20alone)=20and=20the=20whole=20GUI=20suite=20with?= =?UTF-8?q?=20--test-concurrency=3D2=20pass.=20Not=20tried=20in=20the=20re?= =?UTF-8?q?al=20Wails=20window=20on=20any=20platform:=20the=20Mac=20pageZo?= =?UTF-8?q?om,=20WebView2's=20zoom=20factor=20with=20the=20resized=20panel?= =?UTF-8?q?=20and=20the=20Windows=20font=20are=20untested=20there.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/gui/api.go | 29 +++- internal/gui/app.go | 101 +++++++++-- internal/gui/assets/app.css | 10 +- internal/gui/assets/app.js | 49 ++++++ internal/gui/assets/i18n.js | 3 + internal/gui/assets/index.html | 4 + internal/gui/dev_on.go | 7 + internal/gui/glide_darwin.go | 26 ++- internal/gui/glide_other.go | 6 +- internal/gui/tests/README.md | 14 +- internal/gui/tests/currency.test.cjs | 2 +- internal/gui/tests/text-size.test.cjs | 235 ++++++++++++++++++++++++++ internal/gui/textsize.go | 55 ++++++ internal/gui/textsize_test.go | 91 ++++++++++ internal/gui/web.go | 1 + internal/settings/settings.go | 13 ++ internal/settings/settings_test.go | 12 ++ 17 files changed, 637 insertions(+), 21 deletions(-) create mode 100644 internal/gui/tests/text-size.test.cjs create mode 100644 internal/gui/textsize.go create mode 100644 internal/gui/textsize_test.go 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
+
+
Text size
+
+
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()) }