diff --git a/docs/screenshots/font-settings.png b/docs/screenshots/font-settings.png new file mode 100644 index 00000000..87731027 Binary files /dev/null and b/docs/screenshots/font-settings.png differ diff --git a/docs/subsystems/gui-shell.md b/docs/subsystems/gui-shell.md index 0ad3360a..9d10e277 100644 --- a/docs/subsystems/gui-shell.md +++ b/docs/subsystems/gui-shell.md @@ -22,13 +22,51 @@ three. 1. `magpie` with no arguments, `magpie app` or `magpie tray` calls `runGUI`, which calls `gui.Run`. A `nogui` build runs the TUI instead. A second launch hands over to the instance already running. 2. `startBackend` brings up the gateway, or finds another magpie serving it. -3. The panel and the window load `index.html` from `Handler`. `boot.js` passes the saved language, theme and text size before the first paint. +3. The panel and the window load `index.html` from `Handler`. `boot.js` passes the saved language, theme, text size and desktop font choices before the first paint. 4. The page reads `GET /api/state` and writes through `POST` routes. Each write answers with the new state, built under `held`. 5. Closing the window hides it. On the Mac a full-screen window first leaves full screen (`closeStep`). 6. The window opens as it was last left. Its settled size and whether it was maximised are kept in `settings.Window` and `settings.WindowMaximised` (`settle`; per machine, see `KeepOwn`). A maximised window keeps the size it restores to. `makeMain` opens it at that size (`openSize`). On its first show, `placeMain` maximises it again on the Mac and Windows (on Windows once the page has come). On Linux, `makeMain` makes it maximised with `StartState`. On Windows a size larger than the screen's work area is fitted and centred (`fitRoom`), and the larger size stays kept. The window's position is not kept. ## Constraints and failure behavior +### Desktop fonts + +Settings → General offers independent interface and code fonts, each with +the installed family's actual styles. `GET /api/fonts` calls +[`fonts.List`](../../internal/fonts/fonts.go): DirectWrite on Windows, +CoreText on macOS and Fontconfig on Linux. Discovery is lazy, cached in the +handler, and refreshed by the menu. It returns names and CSS traits, never +font files or paths. A failed refresh answers 503 and preserves the previous +collection; an empty successful collection is distinct from failure. +`magpie web` and `nogui` do not expose this desktop catalogue. + +`settings.UIFont` and `settings.CodeFont` keep the family, style name, +OpenType weight, CSS slope and width percentage. A missing choice keeps the +platform default. A settings POST can restore that default with an explicit +null; omitting the field preserves it, as does every browser-mode save. +`KeepOwn` preserves this computer's choices during sync and backup restore. +Invalid traits reject the save without replacing the previous settings. + +[`fonts.js`](../../internal/gui/assets/fonts.js) applies choices at boot and +through `applyPrefs`. After a successful font change `host.fontsChanged` +notifies both existing webviews through `receiveFonts`, without waiting for +the other window to gain focus; a released lightweight webview uses boot +settings when recreated. An in-flight local save ignores the notification +and finishes its own queue. +The selected family precedes the existing platform fallback stack. The +interface and code traits apply independently; explicit component weights +still mark emphasis. A choice takes precedence over Omarchy's font, while +restoring the default follows the theme again. If discovery finds a saved +face missing, the page retains its name, explains the fallback and uses the +default until the face is available or the user chooses another. A discovery +error never clears a saved choice. The browser's own typography is unchanged. + +The menus support search, keyboard selection and refresh. Family changes +keep a matching style where possible, otherwise the nearest regular face; +named variable instances are offered without arbitrary axis controls. + +### Shared UI rules + - Every user-visible string has zh, ja and de translations with the same placeholders. `gui-ja.test.cjs` and `gui-de.test.cjs` fail on a missing one. - A click never moves the page. Code scrolls a view only with the reader's event in hand (`scrollOnPurpose`); `click-scroll.test.cjs` guards this. - There are no native ` + Code badge`); + await page.addScriptTag({ content: js }); + const read = () => page.evaluate(() => ["code", "ui", "emphasis", "badge"].map(id => { + const s = getComputedStyle(document.getElementById(id)); + return { weight: s.fontWeight, style: s.fontStyle, family: s.fontFamily }; + })); + const defaults = await read(); + assert.equal(defaults[0].weight, "400", "code's authored regular weight must not inherit its bold parent"); + assert.equal(defaults[1].weight, "400", "a UI family override must not discard the key's authored weight"); + assert.equal(defaults[2].weight, "450"); + await page.evaluate(s => window.desktopFonts.apply(s), { uiFont: choice("Arial", 500, "normal"), codeFont: choice("Consolas", 700, "italic") }); + const picked = await read(); + assert.equal(picked[0].weight, "700"); + assert.equal(picked[0].style, "italic"); + assert.equal(picked[1].weight, "500"); + assert.equal(picked[2].weight, "450", "component emphasis stays explicit"); + await page.evaluate(() => window.desktopFonts.apply({})); + assert.deepEqual(await read(), defaults, "reset restores every original font trait"); + await page.evaluate(s => window.desktopFonts.apply(s), { uiFont: choice("Arial", 600, "italic"), codeFont: choice("Consolas", 400, "normal") }); + const independent = await read(); + assert.equal(independent[3].weight, "400"); + assert.equal(independent[3].style, "normal", "code badges don't inherit the chosen interface slope"); + } finally { await browser.close(); } + }); +} diff --git a/internal/gui/tests/fonts.test.cjs b/internal/gui/tests/fonts.test.cjs new file mode 100644 index 00000000..c35bee69 --- /dev/null +++ b/internal/gui/tests/fonts.test.cjs @@ -0,0 +1,292 @@ +// Installed desktop fonts: real assets with an isolated API, in both engines. +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 = process.env.MAGPIE_FONT_ASSETS || path.resolve(__dirname, "../assets"); +const face = (family, name = "Regular", weight = 400, style = "normal", stretch = 100) => ({ family, name, weight, style, stretch }); +const harmony = [face("HarmonyOS Sans SC"), face("HarmonyOS Sans SC", "Medium", 500), face("HarmonyOS Sans SC", "Bold", 700)]; +const mono = [face("Consolas"), face("Consolas", "Italic", 400, "italic")]; +const odd = face('Reader\'s "中文" \\ family'); +const catalogue = [ + { name: harmony[0].family, styles: harmony }, { name: mono[0].family, styles: mono }, { name: odd.family, styles: [odd] }, + ...Array.from({ length: 200 }, (_, i) => ({ name: "Test Family " + i, styles: [face("Test Family " + i)] })), +]; + +async function open(browser, lang = "en", options = {}) { + const context = await browser.newContext({ locale: lang === "zh" ? "zh-CN" : "en-US", viewport: options.viewport || { width: 1000, height: 820 } }); + const api = { posts: [], reads: 0, fonts: structuredClone(catalogue), fontError: false, saveError: false, delay: 0, + cur: { lang, theme: "light", textSize: 100, currency: "usd", tray: "panel", version: "0.1.0", visionModels: [], imageGenModels: [], ...options.settings } }; + const page = await context.newPage(); + page.setDefaultTimeout(5000); + const errors = []; + page.on("pageerror", e => { errors.push(e.message); console.error(e.message); }); + await page.route("**/*", 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=${JSON.stringify({ ...api.cur, web: !!options.web, ...(options.omarchy ? { omarchy: { mode: "dark", stamp: "one", name: "Test theme", vars: { "--font": '"Theme Font", sans-serif', "--code": '"Theme Mono", monospace' } } } : {}) })};` }); + if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); + if (url.pathname === "/api/fonts") { + api.reads++; + return api.fontError ? route.fulfill({ status: 503, body: "could not read installed fonts" }) : json(api.fonts); + } + if (url.pathname === "/api/settings") { + if (req.method() === "POST") { + const body = req.postDataJSON(); + api.posts.push(body); + if (api.delay) await new Promise(r => setTimeout(r, api.delay)); + if (api.saveError) return route.fulfill({ status: 400, body: "test save failed" }); + api.cur = { ...api.cur, ...body }; + } + return json(api.cur); + } + if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings: api.cur }); + if (url.pathname === "/api/plugins") return json({ plugins: [] }); + if (url.pathname === "/api/usage/quotas") return json([]); + if (url.pathname === "/api/groups") return json({ groups: [], models: [] }); + 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)]; + try { return await route.fulfill({ body: await fs.readFile(file), contentType }); } + catch { return route.fulfill({ status: 404, body: "not found" }); } + }); + await page.goto("http://magpie.test/?view=settings&tab=general"); + await page.locator("#themeSegs .opt").first().waitFor({ state: "attached" }); + // A real assertion, also usable against the original assets, rather than + // a timeout or a compile error as the negative-control result. + assert.equal(await page.locator("#uiFontRow").count(), 1, "Settings must offer an interface font picker"); + if (!options.web) await page.waitForFunction(() => fontList !== null || fontListError); + await page.evaluate(async () => { + await document.fonts.ready; + await Promise.all(document.getAnimations().map(a => a.finished.catch(() => {}))); + }); + return { page, api, errors, close: () => context.close() }; +} + +async function family(page, row, name) { + await page.locator(`#${row} .font-family`).click(); + await page.locator(".font-menu .sc-q").fill(name); + await page.locator(".font-menu .pm-item").filter({ has: page.locator(".pm-name", { hasText: name }) }).first().click(); +} +async function style(page, row, name) { + await page.locator(`#${row} .font-style`).click(); + await page.locator(".font-menu .pm-item").filter({ has: page.locator(".pm-name", { hasText: name }) }).click(); +} +async function reset(page, row) { + await page.locator(`#${row} .font-family`).click(); + await page.locator(".font-menu .pm-item").first().click(); +} +const settled = page => page.waitForFunction(() => prefsBusy === 0 && Object.keys(fontDrafts).length === 0); +const css = (page, selector) => page.locator(selector).evaluate(e => { + const s = getComputedStyle(e); + return { family: s.fontFamily, weight: s.fontWeight, style: s.fontStyle, stretch: s.fontStretch }; +}); +const engines = [["chromium", chromium], ["webkit", webkit]].filter(([name]) => !process.env.BROWSER || process.env.BROWSER === name); + +for (const [engine, runtime] of engines) { + for (const lang of ["en", "zh"]) test(`${engine} ${lang}: independent installed fonts, real styles, persistence and reset`, async () => { + const browser = await runtime.launch(); + try { + const { page, api, errors } = await open(browser, lang); + const before = await css(page, "#codeFontRow .font-preview"); + assert.equal(await page.locator("select").count(), 0); + await family(page, "uiFontRow", "HarmonyOS Sans SC"); + await style(page, "uiFontRow", "Medium"); + await settled(page); + assert.equal(api.cur.uiFont.weight, 500); + assert.match((await css(page, "body")).family, /^"?HarmonyOS Sans SC/); + assert.equal((await css(page, "body")).weight, "500"); + assert.deepEqual(await css(page, "#codeFontRow .font-preview"), before); + await family(page, "codeFontRow", "Consolas"); + await style(page, "codeFontRow", "Italic"); + await settled(page); + assert.equal((await css(page, "#codeFontRow .font-preview")).style, "italic"); + assert.equal((await css(page, "body")).style, "normal"); + // Other saved preferences must carry both choices through the queue. + await page.locator("#themeSegs .opt").nth(2).click(); + await settled(page); + assert.equal(api.cur.uiFont.weight, 500); + assert.equal(api.cur.codeFont.style, "italic"); + await page.reload(); + await page.locator("#uiFontRow .font-family").waitFor(); + await page.waitForFunction(() => fontList !== null && !fontFlight); + await page.evaluate(async () => { await document.fonts.ready; await Promise.all(document.getAnimations().map(a => a.finished.catch(() => {}))); }); + assert.equal((await css(page, "body")).weight, "500"); + assert.equal((await css(page, "#codeFontRow .font-preview")).style, "italic"); + await family(page, "uiFontRow", "Consolas"); + await settled(page); + assert.equal(api.cur.uiFont.name, "Regular", "a family without Medium chooses a regular face"); + await reset(page, "uiFontRow"); + await settled(page); + assert.equal(api.cur.uiFont, null); + assert.equal(api.cur.codeFont.style, "italic"); + await reset(page, "codeFontRow"); + await settled(page); + assert.deepEqual(await css(page, "#codeFontRow .font-preview"), before); + assert.deepEqual(errors, []); + } finally { await browser.close(); } + }); + + test(`${engine}: queued choices and failed writes restore the saved face`, async () => { + const browser = await runtime.launch(); + try { + const { page, api, errors } = await open(browser); + api.delay = 250; + await family(page, "uiFontRow", "HarmonyOS Sans SC"); + await style(page, "uiFontRow", "Medium"); + await family(page, "codeFontRow", "Consolas"); + await settled(page); + assert.equal(api.cur.uiFont.name, "Medium"); + assert.equal(api.cur.codeFont.family, "Consolas"); + api.saveError = true; + await style(page, "uiFontRow", "Bold"); + await settled(page); + assert.equal((await css(page, "body")).weight, "500"); + assert.equal(await page.locator("#uiFontRow .font-style span").textContent(), "Medium"); + assert.deepEqual(errors, []); + } finally { await browser.close(); } + }); + + test(`${engine}: refresh errors, missing faces, empty lists and literal names`, async () => { + const browser = await runtime.launch(); + try { + const { page, api, errors } = await open(browser, "en", { settings: { uiFont: harmony[1] } }); + api.fontError = true; + await page.locator("#uiFontRow .font-family").click(); + await page.getByRole("menuitemradio", { name: "Refresh fonts" }).click(); + await page.waitForFunction(() => fontListError); + assert.equal((await css(page, "body")).weight, "500", "failed discovery must not erase the last collection"); + api.fontError = false; + api.fonts = [{ name: "Consolas", styles: mono }, { name: odd.family, styles: [odd] }]; + await page.locator("#uiFontRow .font-family").click(); + await page.getByRole("menuitemradio", { name: /Refresh fonts/ }).click(); + await page.waitForFunction(() => !fontFlight && !fontListError); + assert.match(await page.locator("#uiFontRow .font-note").textContent(), /unavailable/); + assert.doesNotMatch((await css(page, "body")).family, /HarmonyOS/); + assert.equal(api.cur.uiFont.family, harmony[0].family, "missing font choice stays saved"); + await family(page, "uiFontRow", odd.family); + await settled(page); + assert.equal(await page.locator("#uiFontRow .font-family span").textContent(), odd.family); + assert.match((await css(page, "body")).family, /Reader/); + api.fonts = []; + await page.locator("#uiFontRow .font-family").click(); + await page.getByRole("menuitemradio", { name: "Refresh fonts" }).click(); + await page.waitForFunction(() => !fontFlight && fontList.length === 0); + await reset(page, "uiFontRow"); + await settled(page); + assert.equal(api.cur.uiFont, null); + assert.match(await page.locator("#uiFontRow .font-note").textContent(), /No installed fonts/); + assert.deepEqual(errors, []); + } finally { await browser.close(); } + }); + + test(`${engine}: font menu search and keys fit narrow windows without scrolling the page`, async () => { + const browser = await runtime.launch(); + try { + const { page, errors } = await open(browser, "zh", { viewport: { width: 560, height: 620 } }); + const top = await page.locator("#view-settings").evaluate(e => e.scrollTop); + await page.locator("#uiFontRow .font-family").click(); + const menu = page.locator(".font-menu"); + const box = await menu.boundingBox(); + assert(box.x >= 0 && box.x + box.width <= 560 && box.y >= 0 && box.y + box.height <= 620); + await menu.hover(); + await page.mouse.wheel(0, 240); + await page.waitForFunction(() => document.querySelector(".font-menu").scrollTop > 0); + assert.equal(await page.locator("#view-settings").evaluate(e => e.scrollTop), top); + await page.locator(".font-menu .sc-q").fill("HarmonyOS"); + await page.keyboard.press("Enter"); + await settled(page); + assert.equal(await page.locator("#uiFontRow .font-family span").textContent(), "HarmonyOS Sans SC"); + assert.equal(await page.locator("#view-settings").evaluate(e => e.scrollTop), top); + await page.locator("#uiFontRow .font-style").click(); + await page.keyboard.press("ArrowDown"); + await page.keyboard.press("Enter"); + await settled(page); + assert.equal(await page.locator("#uiFontRow .font-style span").textContent(), "Medium"); + await page.locator("#uiFontRow .font-style").click(); + await page.keyboard.press("Escape"); + assert.equal(await page.locator(".font-menu").count(), 0); + for (const width of [440, 560, 1000]) { + await page.setViewportSize({ width, height: 820 }); + assert(await page.locator("#view-settings").evaluate(e => e.scrollWidth <= e.clientWidth)); + for (const row of ["uiFontRow", "codeFontRow"]) { + const bounds = await page.locator(`#${row} .font-controls`).boundingBox(); + assert(bounds.x >= 0 && bounds.x + bounds.width <= width, `${row} at ${width}`); + } + } + if (process.env.ARTIFACT_DIR) { + await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true }); + await style(page, "uiFontRow", "Medium"); + await family(page, "codeFontRow", "Consolas"); + await settled(page); + await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `fonts-${engine}-zh.png`) }); + } + assert.deepEqual(errors, []); + } finally { await browser.close(); } + }); + + test(`${engine}: explicit fonts override Omarchy and browser mode keeps its defaults`, async () => { + const browser = await runtime.launch(); + try { + const { page, errors } = await open(browser, "en", { omarchy: true, settings: { uiFont: harmony[1], codeFont: mono[0] } }); + assert.match((await css(page, "body")).family, /HarmonyOS/); + await reset(page, "uiFontRow"); + await settled(page); + assert.match((await css(page, "body")).family, /Theme Font/); + assert.match((await css(page, "#codeFontRow .font-preview")).family, /Consolas/); + assert.deepEqual(errors, []); + const web = await open(browser, "en", { web: true, settings: { uiFont: harmony[1] } }); + assert.equal(await web.page.locator("#uiFontRow").isVisible(), false); + assert.equal(web.api.reads, 0); + assert.doesNotMatch((await css(web.page, "body")).family, /HarmonyOS/); + assert.deepEqual(web.errors, []); + } finally { await browser.close(); } + }); + + test(`${engine}: native notifications update the other webview without another save`, async () => { + const browser = await runtime.launch(); + try { + const { page, api, errors } = await open(browser); + api.cur.uiFont = harmony[1]; + api.cur.codeFont = mono[1]; + await page.evaluate(s => window.receiveFonts(s), api.cur); + assert.equal((await css(page, "body")).weight, "500"); + assert.equal((await css(page, "#codeFontRow .font-preview")).style, "italic"); + assert.equal(await page.locator("#uiFontRow .font-style span").textContent(), "Medium"); + assert.equal(api.posts.length, 0, "a notification must not echo another save"); + api.cur.uiFont = null; + await page.evaluate(s => window.receiveFonts(s), api.cur); + assert.doesNotMatch((await css(page, "body")).family, /HarmonyOS/); + assert.equal((await css(page, "#codeFontRow .font-preview")).style, "italic"); + // A local queued choice wins over an older notice sent before its reply. + api.delay = 150; + await family(page, "uiFontRow", "HarmonyOS Sans SC"); + await page.evaluate(s => window.receiveFonts(s), { uiFont: mono[0], codeFont: null }); + await settled(page); + assert.match((await css(page, "body")).family, /HarmonyOS/); + assert.equal((await css(page, "#codeFontRow .font-preview")).style, "italic"); + assert.deepEqual(errors, []); + } finally { await browser.close(); } + }); + + test(`${engine}: named variable traits retain fractional weight and custom width`, async () => { + const browser = await runtime.launch(); + try { + const { page, api, errors } = await open(browser); + const book = face("Variable Sans", "Book", 425.5, "normal", 25); + api.fonts.push({ name: book.family, styles: [book] }); + await page.locator("#uiFontRow .font-family").click(); + await page.getByRole("menuitemradio", { name: "Refresh fonts" }).click(); + await page.waitForFunction(() => !fontFlight && fontList.some(f => f.name === "Variable Sans")); + await family(page, "uiFontRow", book.family); + await settled(page); + assert.equal(api.cur.uiFont.weight, 425.5); + assert.equal(api.cur.uiFont.stretch, 25); + assert.equal((await css(page, "body")).weight, "425.5"); + assert.equal((await css(page, "body")).stretch, "25%"); + assert.deepEqual(errors, []); + } finally { await browser.close(); } + }); +} diff --git a/internal/gui/tests/omarchy.test.cjs b/internal/gui/tests/omarchy.test.cjs index 6ab831a0..0fedd27a 100644 --- a/internal/gui/tests/omarchy.test.cjs +++ b/internal/gui/tests/omarchy.test.cjs @@ -126,6 +126,15 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", const segs = page.locator("#barIconSegs .opt"); assert.deepEqual(await segs.allTextContents(), [off, on]); assert.equal(await segs.nth(0).evaluate((b) => b.classList.contains("on")), true, "off to start"); + // Font preferences add rows above the bar control. Bring it into + // this short window with a reader's wheel before testing a click. + await page.locator("#view-settings").hover(); + await page.mouse.wheel(0, 260); + await page.waitForFunction(() => { + const r = document.querySelector("#barIconSegs").getBoundingClientRect(); + const v = document.querySelector("#view-settings").getBoundingClientRect(); + return r.top >= v.top && r.bottom <= v.bottom; + }); const before = await view(page); await segs.nth(1).click(); await page.locator("#barIconSegs .opt.on", { hasText: on }).waitFor(); diff --git a/internal/settings/fonts_test.go b/internal/settings/fonts_test.go new file mode 100644 index 00000000..46ae1089 --- /dev/null +++ b/internal/settings/fonts_test.go @@ -0,0 +1,45 @@ +package settings + +import ( + "reflect" + "testing" + + "github.com/yetone/magpie/internal/fonts" + "github.com/yetone/magpie/internal/testenv" +) + +func TestFontChoicesPersistAndStayLocal(t *testing.T) { + testenv.SetHome(t, t.TempDir()) + ui := &fonts.Face{Family: "HarmonyOS Sans SC", Name: "Medium", Weight: 500, Style: "normal", Stretch: 100} + code := &fonts.Face{Family: "Consolas", Name: "Italic", Weight: 400, Style: "italic", Stretch: 100} + if err := Save(Settings{UIFont: ui, CodeFont: code}); err != nil { + t.Fatal(err) + } + got := Load() + if !reflect.DeepEqual(got.UIFont, ui) || !reflect.DeepEqual(got.CodeFont, code) { + t.Fatalf("font choices lost: %#v %#v", got.UIFont, got.CodeFont) + } + other := Settings{Theme: "dark"} + other.KeepOwn(got) + if !reflect.DeepEqual(other.UIFont, ui) || !reflect.DeepEqual(other.CodeFont, code) { + t.Fatal("sync replaced this computer's fonts") + } + other.KeepOwn(Settings{}) + if other.UIFont != nil || other.CodeFont != nil { + t.Fatal("sync imported fonts into a computer using defaults") + } + bad := *ui + bad.Weight = 1001 + if Save(Settings{UIFont: &bad}) == nil { + t.Fatal("saved an invalid font weight") + } + if !reflect.DeepEqual(Load().UIFont, ui) { + t.Fatal("a rejected choice overwrote the saved font") + } + if err := Save(Settings{}); err != nil { + t.Fatal(err) + } + if Load().UIFont != nil || Load().CodeFont != nil { + t.Fatal("default fonts did not persist") + } +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 41cfc49b..c292da63 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -27,6 +27,7 @@ import ( "github.com/yetone/magpie/internal/catalog" "github.com/yetone/magpie/internal/edit" "github.com/yetone/magpie/internal/filememo" + "github.com/yetone/magpie/internal/fonts" "github.com/yetone/magpie/internal/redact" "github.com/yetone/magpie/internal/steady" ) @@ -305,6 +306,10 @@ type Settings struct { // 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"` + // UIFont and CodeFont are this computer's installed faces. Nil keeps + // the platform's stack (or Omarchy's). Sync never replaces them. + UIFont *fonts.Face `json:"uiFont,omitempty"` + CodeFont *fonts.Face `json:"codeFont,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 @@ -696,6 +701,7 @@ func (s Settings) Compact() int { // icon (yoooo on Discord: usage turned off on a Mac came back from a // Windows box that shows it). func (s *Settings) KeepOwn(cur Settings) { + s.UIFont, s.CodeFont = cur.UIFont, cur.CodeFont s.Window, s.Proxy, s.Port, s.Dock, s.DockWindow, s.Lightweight = cur.Window, cur.Proxy, cur.Port, cur.Dock, cur.DockWindow, cur.Lightweight s.WindowMaximised, s.KeepAwake, s.KeepAwakeDisplay = cur.WindowMaximised, cur.KeepAwake, cur.KeepAwakeDisplay s.TrayUsages, s.TrayUsage, s.TrayUsageEvery, s.TrayNoLogos, s.TrayNoBird = cur.TrayUsages, cur.TrayUsage, cur.TrayUsageEvery, cur.TrayNoLogos, cur.TrayNoBird @@ -841,6 +847,11 @@ func Save(s Settings) error { if s.SessionTerminal != "" && s.SessionTerminal != "system" && !validTerminalBundleID.MatchString(s.SessionTerminal) { return fmt.Errorf("session terminal must be an app bundle id or system, not %q", s.SessionTerminal) } + for _, choice := range []*fonts.Face{s.UIFont, s.CodeFont} { + if err := fonts.Validate(choice); err != nil { + return err + } + } if !slices.Contains(Currencies, s.Currency) { return fmt.Errorf("currency must be one of %v, not %q", Currencies, s.Currency) }