settings: a Text size preference (文字大小) that makes everything in magpie's windows larger, 100/110/125/150%, with Ctrl/Cmd + = − 0 in the window and the tray panel, and Windows' text drawn in its own UI faces (Discord, Clarence Dan: the font size is hard on the eyes on Windows) — Settings → Preferences gains a Text size row under Language whose pick posts to a new POST /api/settings/text-size (saved as settings.TextSize, validated against 100/110/125/150, 0 read as 100, and kept by the Settings page's general save, which never sends it). The size is each webview's own zoom, as a browser zooms, not CSS zoom: CSS zoom leaves getBoundingClientRect and clientX in zoomed pixels while offsets, scrollTop and 100vh are not, and app.js, routing.js and library.js place menus, popovers and the panel's height by those measures; under the webview's zoom the page is laid out again in fewer, larger CSS pixels and every measure agrees. Windows and Linux use Wails' SetZoom (WebView2's zoom factor, WebKitGTK's zoom level; the Zoom window option at creation, so the pages open at the saved size), the Mac WKWebView.pageZoom through cgo (macOS 11+), since Wails' Mac zoom is magnification, which lays nothing out again. None is under 100% because Wails clamps SetZoom below 1 on Windows and Linux. The windows grow around the pages: the panel's width and the height its page asks for (still in CSS pixels) are multiplied by the zoom, capped at the screen's work area (textsize.go panelFrame), and resized at once when the size changes; the main window's minimum is 560×420 CSS pixels at the size, capped by the screen, so a window smaller than that grows. Runtime changes wait for the app to be ready (whenReady), and at startup the Mac applies the zoom once its webviews exist. The Mac header, drawn under the native traffic lights which do not grow, divides its height, left padding and the modal's top by --zoom (set from boot.js before the first paint and from the settings), so it stays 50 points tall and level with them. The shortcuts are Cmd on the Mac and Ctrl elsewhere (the other modifier does nothing), step through the sizes and 0 goes back to 100%, with a "Text size 125%" toast; in magpie web the row is hidden and the keys are left to the browser's own zoom. On Windows the page names its faces (html.win): Segoe UI Variable Text, Segoe UI, then Microsoft YaHei UI for Chinese rather than whatever WebView2 falls back to; the Mac and Linux stack is unchanged, and there is no automatic size bump. New strings in en and zh. Tested: internal/gui/tests/text-size.test.cjs in Chromium and WebKit, en and zh, light and dark — a pick posts and does not scroll the settings page, stays picked after a reload with --zoom from boot.js, the Settings page's other saves leave it, the shortcuts step and reset in the window and the panel, and at 150% (the smallest window, 840×630 points, as 560×420 CSS pixels at device scale 1.5) no view, the header or the panel runs off to the side, the Mac header is 50 points; screenshots at 100% and 150% looked at. currency.test.cjs's viewport is 480 tall instead of 420, as settings-groups' is: the new row put the Currency control under the footer, where WebKit's hover could not reach it. Go: TestTextSizeFrames (panel and window sizes) and TestTextSizeSetting (the endpoint saves, zooms the windows, rejects 133, survives the general save, boot.js carries it) in internal/gui, and the settings round trip. go build -tags nogui (and GOOS=linux, GOOS=windows, plain darwin), go test -tags nogui ./... (TestUserPath flaked once under load and passes alone) and the whole GUI suite with --test-concurrency=2 pass. Not tried in the real Wails window on any platform: the Mac pageZoom, WebView2's zoom factor with the resized panel and the Windows font are untested there.
This commit is contained in:
17 files changed
+637
-21
No files matched your search
+28
-1
@@ -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 }
|
||||
|
||||
+88
-13
@@ -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("") })
|
||||
}
|
||||
|
||||
@@ -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; } }
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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": "深色",
|
||||
|
||||
@@ -165,6 +165,10 @@
|
||||
<div class="who"><div class="name" data-t>Language</div><div class="sub" data-t>Applies at once, no restart</div></div>
|
||||
<div id="langSegs"></div>
|
||||
</div>
|
||||
<div class="row pref" id="textSizeRow">
|
||||
<div class="who"><div class="name" data-t>Text size</div><div class="sub" id="textSizeSub"></div></div>
|
||||
<div id="textSizeSegs"></div>
|
||||
</div>
|
||||
<div class="row pref">
|
||||
<div class="who"><div class="name" data-t>Tray icon</div><div class="sub" data-t>What clicking magpie’s icon in the menu bar or tray opens</div></div>
|
||||
<div id="traySegs"></div>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 <Cocoa/Cocoa.h>
|
||||
#import <QuartzCore/QuartzCore.h>
|
||||
#import <WebKit/WebKit.h>
|
||||
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user