gui: let users choose installed interface and code fonts (#1007)

* gui: choose installed interface and code fonts and styles

Discover installed styles through DirectWrite, CoreText and Fontconfig. Keep choices per computer, apply them before paint and notify both desktop webviews after saving; defaults retain platform and Omarchy fallbacks.

Verified seven focused Go tests, including real Windows discovery (90 families, 276 styles), 14 font browser cases in Chromium/WebKit, 68 settings/zoom/Omarchy/web cases, go vet, Windows amd64/arm64 and Linux nogui builds. Against the original assets, the new UI test fails with Settings must offer an interface font picker (0 != 1). Full Windows suites have separately reproduced upstream failures; macOS/Linux native discovery is covered by the added CI step.

* gui: retain bundled Mac fonts and variable style precision

Only filter an undownloaded CoreText face when it is explicitly downloadable; bundled fonts are installed without being downloaded. Use resolved font names when descriptors omit them. Preserve fractional weights and nonnegative CSS widths for named variable styles.

* gui: restore the authored font cascade when returning to defaults

Apply family-only trait overrides only while a custom face is active. The browser regression catches code under a bold parent becoming 700 instead of its original 400. Keep the compatibility shim first, and apply boot fonts before visible content.

* gui: keep font picker review separate from CI integration

Leave the native test sources and manual verification command with the feature. Move the added workflow step to a follow-up so the font picker can take the normal review path requested on PR #1007.
This commit is contained in:
tsuixl authored and GitHub committed 2026-10-06 19:32:56 +08:00
1 parent 490d2ea362
commit bd33e575f1
32 files changed
+1568 -72

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

+40 -1
View File
@@ -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 `<select>` elements and no colored left-border stripes. State is shown with a dot or a swatch.
@@ -51,6 +89,7 @@ waits for `show` to accept navigation before creating its draft.
```sh
go test -tags nogui ./internal/gui
go test -v ./internal/fonts # native discovery on each desktop OS
make test-ui # every internal/gui/tests/*.test.cjs, Chromium and WebKit
BROWSER=webkit node --test internal/gui/tests/click-scroll.test.cjs
```
+94
View File
@@ -0,0 +1,94 @@
// Package fonts describes the installed faces a desktop page can choose.
// It returns names and CSS traits, never font files or their paths.
package fonts
import (
"cmp"
"fmt"
"math"
"slices"
"strings"
"unicode"
)
// Face is one installed style. Name is the font's own style name; Style is
// its CSS slope. Stretch is a percentage, and Weight is an OpenType weight.
type Face struct {
Family string `json:"family"`
Name string `json:"name"`
Weight float64 `json:"weight"`
Style string `json:"style"`
Stretch float64 `json:"stretch"`
}
type Family struct {
Name string `json:"name"`
Styles []Face `json:"styles"`
}
// Validate also accepts an absent choice, which keeps the platform's stack.
// Availability is separate: a font removed since it was chosen stays saved.
func Validate(f *Face) error {
if f == nil {
return nil
}
for _, s := range []string{f.Family, f.Name} {
if strings.TrimSpace(s) == "" || len(s) > 512 || strings.ContainsFunc(s, unicode.IsControl) {
return fmt.Errorf("font family and style must be nonempty names without control characters")
}
}
if math.IsNaN(f.Weight) || math.IsInf(f.Weight, 0) || f.Weight < 1 || f.Weight > 1000 {
return fmt.Errorf("font weight must be between 1 and 1000")
}
if f.Style != "normal" && f.Style != "italic" && f.Style != "oblique" {
return fmt.Errorf("font style must be normal, italic or oblique")
}
// The nine traditional width classes span 50–200%, but named
// variable instances can use other nonnegative CSS percentages.
if math.IsNaN(f.Stretch) || math.IsInf(f.Stretch, 0) || f.Stretch < 0 {
return fmt.Errorf("font stretch must be a finite nonnegative percentage")
}
return nil
}
// List reads a fresh system collection, including fonts installed for just
// this user. Callers may cache a successful result until the user refreshes.
func List() ([]Family, error) {
faces, err := installed()
if err != nil {
return nil, err
}
return group(faces), nil
}
func group(faces []Face) []Family {
byName := map[string][]Face{}
seen := map[Face]bool{}
for _, f := range faces {
f.Family, f.Name = strings.TrimSpace(f.Family), strings.TrimSpace(f.Name)
if Validate(&f) != nil || seen[f] {
continue
}
seen[f] = true
byName[f.Family] = append(byName[f.Family], f)
}
out := make([]Family, 0, len(byName))
for name, styles := range byName {
slices.SortFunc(styles, func(a, b Face) int {
return cmp.Or(cmp.Compare(a.Weight, b.Weight), cmp.Compare(a.Style, b.Style), cmp.Compare(a.Stretch, b.Stretch), strings.Compare(a.Name, b.Name))
})
out = append(out, Family{Name: name, Styles: styles})
}
slices.SortFunc(out, func(a, b Family) int {
return cmp.Or(strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)), strings.Compare(a.Name, b.Name))
})
return out
}
// OpenType and DirectWrite share these width classes.
func width(n int) float64 {
if n < 1 || n > 9 {
return 100
}
return [...]float64{50, 62.5, 75, 87.5, 100, 112.5, 125, 150, 200}[n-1]
}
+31
View File
@@ -0,0 +1,31 @@
//go:build darwin && cgo && !nogui
package fonts
/*
#cgo LDFLAGS: -framework AppKit -framework CoreText
#include <stdlib.h>
char *magpieInstalledFonts(void);
*/
import "C"
import (
"encoding/json"
"errors"
"unsafe"
)
const Available = true
func installed() ([]Face, error) {
raw := C.magpieInstalledFonts()
if raw == nil {
return nil, errors.New("CoreText could not list installed fonts")
}
defer C.free(unsafe.Pointer(raw))
var faces []Face
if err := json.Unmarshal([]byte(C.GoString(raw)), &faces); err != nil {
return nil, err
}
return faces, nil
}
+112
View File
@@ -0,0 +1,112 @@
//go:build darwin && cgo && !nogui
#import <AppKit/AppKit.h>
#import <CoreText/CoreText.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>
static NSString *fontText(CFTypeRef value) {
NSString *text = value && CFGetTypeID(value) == CFStringGetTypeID()
? [(__bridge NSString *)value copy] : nil;
if (value) CFRelease(value);
return [text autorelease];
}
static NSString *fontName(CTFontDescriptorRef descriptor, CFStringRef key) {
return fontText(CTFontDescriptorCopyAttribute(descriptor, key));
}
static double axis(CFDictionaryRef variation, uint32_t tag, double fallback) {
if (!variation) return fallback;
int64_t number = tag;
CFNumberRef key = CFNumberCreate(NULL, kCFNumberSInt64Type, &number);
CFNumberRef value = CFDictionaryGetValue(variation, key);
double out = fallback;
if (value && CFGetTypeID(value) == CFNumberGetTypeID()) CFNumberGetValue(value, kCFNumberDoubleType, &out);
CFRelease(key);
return out;
}
// Apple AAT fonts need not have an OS/2 table. Their native trait uses
// AppKit's weight scale, not the OpenType/CSS numeric scale.
static int traitWeight(CTFontRef font) {
const CGFloat native[] = {NSFontWeightUltraLight, NSFontWeightThin, NSFontWeightLight,
NSFontWeightRegular, NSFontWeightMedium, NSFontWeightSemibold,
NSFontWeightBold, NSFontWeightHeavy, NSFontWeightBlack};
CFDictionaryRef traits = CTFontCopyTraits(font);
CFNumberRef value = traits ? CFDictionaryGetValue(traits, kCTFontWeightTrait) : NULL;
double weight = 0;
if (value) CFNumberGetValue(value, kCFNumberDoubleType, &weight);
if (traits) CFRelease(traits);
int best = 3;
for (int i = 0; i < 9; ++i) if (fabs(weight - native[i]) < fabs(weight - native[best])) best = i;
return (best + 1) * 100;
}
char *magpieInstalledFonts(void) {
@autoreleasepool {
CTFontCollectionRef collection = CTFontCollectionCreateFromAvailableFonts(NULL);
if (!collection) return NULL;
CFArrayRef descriptors = CTFontCollectionCreateMatchingFontDescriptors(collection);
CFRelease(collection);
if (!descriptors) return NULL;
NSMutableArray *faces = [NSMutableArray array];
const double widths[] = {100, 50, 62.5, 75, 87.5, 100, 112.5, 125, 150, 200};
for (CFIndex i = 0; i < CFArrayGetCount(descriptors); ++i) {
CTFontDescriptorRef descriptor = (CTFontDescriptorRef)CFArrayGetValueAtIndex(descriptors, i);
CFTypeRef downloadable = CTFontDescriptorCopyAttribute(descriptor, kCTFontDownloadableAttribute);
CFTypeRef downloaded = CTFontDescriptorCopyAttribute(descriptor, kCTFontDownloadedAttribute);
// A bundled font was never downloaded either. Only a font
// explicitly offered for download can be a remote-only face.
BOOL remote = downloadable && CFEqual(downloadable, kCFBooleanTrue)
&& downloaded && CFEqual(downloaded, kCFBooleanFalse);
if (downloadable) CFRelease(downloadable);
if (downloaded) CFRelease(downloaded);
if (remote) continue;
CTFontRef font = CTFontCreateWithFontDescriptor(descriptor, 0, NULL);
if (!font) continue;
NSString *family = fontName(descriptor, kCTFontFamilyNameAttribute);
NSString *name = fontName(descriptor, kCTFontStyleNameAttribute);
if (!family.length) family = fontText(CTFontCopyFamilyName(font));
if (!name.length) name = fontText(CTFontCopyName(font, kCTFontSubFamilyNameKey));
// Dot-prefixed faces are private system UI implementations, not
// families the webview can request by name.
if (!family.length || !name.length || [family hasPrefix:@"."]) { CFRelease(font); continue; }
CTFontSymbolicTraits traits = CTFontGetSymbolicTraits(font);
double weight = traitWeight(font);
double stretch = traits & kCTFontCondensedTrait ? 75 : traits & kCTFontExpandedTrait ? 125 : 100;
NSString *slope = traits & kCTFontItalicTrait ? @"italic" : @"normal";
// OpenType weights are CSS weights; CoreText's floating trait
// scale is not. Keep the original table values where available.
CFDataRef table = CTFontCopyTable(font, kCTFontTableOS2, kCTFontTableOptionNoOptions);
if (table) {
const UInt8 *p = CFDataGetBytePtr(table);
if (CFDataGetLength(table) >= 8) {
int w = (p[4] << 8) | p[5], width = (p[6] << 8) | p[7];
if (w > 0 && w <= 1000) weight = w;
if (width > 0 && width <= 9) stretch = widths[width];
}
if (CFDataGetLength(table) >= 64 && (p[62] & 2)) slope = @"oblique";
CFRelease(table);
}
// Named variable instances can override the base font's table.
CFDictionaryRef variation = CTFontCopyVariation(font);
weight = axis(variation, 'wght', weight);
stretch = axis(variation, 'wdth', stretch);
if (axis(variation, 'ital', 0) != 0) slope = @"italic";
else if (axis(variation, 'slnt', 0) != 0) slope = @"oblique";
if (variation) CFRelease(variation);
CFRelease(font);
[faces addObject:@{@"family": family, @"name": name, @"weight": @(weight), @"style": slope, @"stretch": @(stretch)}];
}
CFRelease(descriptors);
NSData *json = [NSJSONSerialization dataWithJSONObject:faces options:0 error:NULL];
if (!json) return NULL;
char *out = malloc(json.length + 1);
if (!out) return NULL;
memcpy(out, json.bytes, json.length);
out[json.length] = 0;
return out;
}
}
+20
View File
@@ -0,0 +1,20 @@
//go:build darwin && cgo && !nogui
package fonts
import "testing"
// Built-in fonts are installed without having been downloaded. CoreText's
// downloaded=false attribute must not filter out the Mac's Helvetica.
func TestMacBundledFontIsAvailable(t *testing.T) {
families, err := List()
if err != nil {
t.Fatal(err)
}
for _, family := range families {
if family.Name == "Helvetica" && len(family.Styles) > 0 {
return
}
}
t.Fatal("the installed collection did not include the bundled Helvetica family")
}
+74
View File
@@ -0,0 +1,74 @@
//go:build linux && cgo && !nogui
package fonts
/*
#cgo pkg-config: fontconfig
#include <fontconfig/fontconfig.h>
static FcFontSet *magpieFonts(void) {
FcPattern *pattern = FcPatternCreate();
if (!pattern) return NULL;
FcPatternAddBool(pattern, FC_SCALABLE, FcTrue);
FcObjectSet *objects = FcObjectSetBuild(FC_FAMILY, FC_STYLE, FC_WEIGHT, FC_SLANT, FC_WIDTH, NULL);
if (!objects) { FcPatternDestroy(pattern); return NULL; }
FcFontSet *fonts = FcFontList(NULL, pattern, objects);
FcObjectSetDestroy(objects);
FcPatternDestroy(pattern);
return fonts;
}
static FcPattern *magpieFontAt(FcFontSet *set, int i) { return set->fonts[i]; }
static const char *magpieFontString(FcPattern *font, const char *key) {
FcChar8 *value = NULL;
return FcPatternGetString(font, key, 0, &value) == FcResultMatch ? (const char *)value : "";
}
static double magpieFontNumber(FcPattern *font, const char *key, double fallback) {
FcValue value;
if (FcPatternGet(font, key, 0, &value) != FcResultMatch) return fallback;
if (value.type == FcTypeInteger) return value.u.i;
if (value.type == FcTypeDouble) return value.u.d;
return fallback;
}
static const char *magpieFontFamily(FcPattern *f) { return magpieFontString(f, FC_FAMILY); }
static const char *magpieFontStyle(FcPattern *f) { return magpieFontString(f, FC_STYLE); }
static double magpieFontWeight(FcPattern *f) { return magpieFontNumber(f, FC_WEIGHT, -1); }
static double magpieFontSlant(FcPattern *f) { return magpieFontNumber(f, FC_SLANT, FC_SLANT_ROMAN); }
static double magpieFontWidth(FcPattern *f) { return magpieFontNumber(f, FC_WIDTH, 100); }
*/
import "C"
import "errors"
const Available = true
func installed() ([]Face, error) {
set := C.magpieFonts()
if set == nil {
return nil, errors.New("Fontconfig could not list installed fonts")
}
defer C.FcFontSetDestroy(set)
faces := make([]Face, 0, int(set.nfont))
for i := C.int(0); i < set.nfont; i++ {
font := C.magpieFontAt(set, i)
// A variable font's range is not a named instance. Its enumerated
// named instances have numeric weights and are included normally.
weight := C.magpieFontWeight(font)
if weight < 0 {
continue
}
style := "normal"
switch int(C.magpieFontSlant(font)) {
case C.FC_SLANT_ITALIC:
style = "italic"
case C.FC_SLANT_OBLIQUE:
style = "oblique"
}
faces = append(faces, Face{
Family: C.GoString(C.magpieFontFamily(font)),
Name: C.GoString(C.magpieFontStyle(font)),
Weight: float64(C.FcWeightToOpenTypeDouble(weight)),
Style: style, Stretch: float64(C.magpieFontWidth(font)),
})
}
return faces, nil
}
+11
View File
@@ -0,0 +1,11 @@
//go:build nogui || (!windows && !darwin && !linux) || (!windows && !cgo)
package fonts
import "errors"
const Available = false
func installed() ([]Face, error) {
return nil, errors.New("installed fonts are available only in the desktop app")
}
+85
View File
@@ -0,0 +1,85 @@
package fonts
import (
"math"
"reflect"
"testing"
)
func TestGroupInstalledStyles(t *testing.T) {
regular := Face{Family: "HarmonyOS Sans SC", Name: "Regular", Weight: 400, Style: "normal", Stretch: 100}
medium := regular
medium.Name, medium.Weight = "Medium", 500
italic := regular
italic.Name, italic.Style = "Italic", "italic"
chinese := Face{Family: "思源黑体", Name: "Regular", Weight: 400, Style: "normal", Stretch: 100}
got := group([]Face{chinese, medium, italic, regular, medium, {Family: "invalid"}})
want := []Family{{Name: regular.Family, Styles: []Face{italic, regular, medium}}, {Name: chinese.Family, Styles: []Face{chinese}}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("installed families and styles = %#v, want %#v", got, want)
}
if got := group(nil); got == nil || len(got) != 0 {
t.Fatalf("an empty successful collection must be [], got %#v", got)
}
}
func TestValidateChoice(t *testing.T) {
f := Face{Family: `Reader's "中文"`, Name: "Medium", Weight: 500, Style: "normal", Stretch: 100}
if err := Validate(&f); err != nil {
t.Fatal(err)
}
if err := Validate(nil); err != nil {
t.Fatal(err)
}
for _, change := range []func(*Face){
func(f *Face) { f.Family = "" }, func(f *Face) { f.Name = "\x00" },
func(f *Face) { f.Weight = 0 }, func(f *Face) { f.Weight = 1001 },
func(f *Face) { f.Style = "bold" }, func(f *Face) { f.Stretch = -1 },
func(f *Face) { f.Weight = math.NaN() }, func(f *Face) { f.Stretch = math.Inf(1) },
} {
bad := f
change(&bad)
if Validate(&bad) == nil {
t.Fatalf("accepted invalid font %#v", bad)
}
}
}
func TestVariableTraitsKeepCSSPrecision(t *testing.T) {
for _, width := range []float64{0, 25, 87.5, 250} {
f := Face{Family: "Variable Sans", Name: "Book", Weight: 425.5, Style: "normal", Stretch: width}
if err := Validate(&f); err != nil {
t.Fatal(err)
}
got := group([]Face{f})
if len(got) != 1 || len(got[0].Styles) != 1 || got[0].Styles[0] != f {
t.Fatalf("lost variable traits: %#v", got)
}
}
}
func TestInstalledFonts(t *testing.T) {
if !Available {
t.Skip("this build has no desktop font API")
}
families, err := List()
if err != nil {
t.Fatal(err)
}
if len(families) == 0 {
t.Fatal("the system font collection is empty")
}
count := 0
for _, family := range families {
for _, face := range family.Styles {
if face.Family != family.Name {
t.Fatalf("wrong family: %#v", face)
}
if err := Validate(&face); err != nil {
t.Fatalf("%#v: %v", face, err)
}
count++
}
}
t.Logf("read %d installed families and %d styles", len(families), count)
}
+111
View File
@@ -0,0 +1,111 @@
//go:build windows && !nogui
package fonts
import (
"fmt"
"runtime"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
const Available = true
// DirectWrite works without cgo, as the Windows production build does.
// The method order is the IDWrite interfaces in the Windows SDK's dwrite.h.
type dwrite struct{ vtbl *[16]uintptr }
//go:uintptrescapes
func (p *dwrite) call(method int, args ...uintptr) uintptr {
r, _, _ := syscall.SyscallN(p.vtbl[method], append([]uintptr{uintptr(unsafe.Pointer(p))}, args...)...)
runtime.KeepAlive(p)
return r
}
func (p *dwrite) release() { p.call(2) }
func failed(hr uintptr) bool { return int32(hr) < 0 }
func installed() ([]Face, error) {
create := windows.NewLazySystemDLL("dwrite.dll").NewProc("DWriteCreateFactory")
if err := create.Find(); err != nil {
return nil, err
}
iid := windows.GUID{Data1: 0xb859ee5a, Data2: 0xd838, Data3: 0x4b5b, Data4: [8]byte{0xa2, 0xe8, 0x1a, 0xdc, 0x7d, 0x93, 0xdb, 0x48}}
var factory *dwrite
hr, _, _ := create.Call(0, uintptr(unsafe.Pointer(&iid)), uintptr(unsafe.Pointer(&factory)))
if failed(hr) {
return nil, fmt.Errorf("DirectWrite factory: HRESULT %#x", uint32(hr))
}
defer factory.release()
var collection *dwrite
// checkForUpdates asks DirectWrite to notice an install or removal.
if hr = factory.call(3, uintptr(unsafe.Pointer(&collection)), 1); failed(hr) {
return nil, fmt.Errorf("DirectWrite fonts: HRESULT %#x", uint32(hr))
}
defer collection.release()
var faces []Face
for i, n := uintptr(0), collection.call(3); i < n; i++ {
var family *dwrite
if failed(collection.call(4, i, uintptr(unsafe.Pointer(&family)))) {
continue
}
faces = append(faces, familyFaces(family)...)
family.release()
}
return faces, nil
}
func familyFaces(family *dwrite) []Face {
name := fontString(family, 6)
if name == "" {
return nil
}
var faces []Face
for i, n := uintptr(0), family.call(4); i < n; i++ {
var font *dwrite
if failed(family.call(5, i, uintptr(unsafe.Pointer(&font)))) {
continue
}
// Synthesised bold/italic is not an installed style to offer.
if font.call(10) == 0 {
style := "normal"
switch font.call(6) {
case 1:
style = "oblique"
case 2:
style = "italic"
}
faces = append(faces, Face{Family: name, Name: fontString(font, 8), Weight: float64(font.call(4)), Style: style, Stretch: width(int(font.call(5)))})
}
font.release()
}
return faces
}
// Stable English names where present, otherwise the font's first locale.
// CSS accepts these same names regardless of the page's language.
func fontString(owner *dwrite, method int) string {
var names *dwrite
if failed(owner.call(method, uintptr(unsafe.Pointer(&names)))) {
return ""
}
defer names.release()
if names.call(3) == 0 {
return ""
}
locale, _ := windows.UTF16PtrFromString("en-us")
var index, exists, size uint32
if failed(names.call(4, uintptr(unsafe.Pointer(locale)), uintptr(unsafe.Pointer(&index)), uintptr(unsafe.Pointer(&exists)))) || exists == 0 {
index = 0
}
if failed(names.call(7, uintptr(index), uintptr(unsafe.Pointer(&size)))) || size > 4096 {
return ""
}
buf := make([]uint16, size+1)
if failed(names.call(8, uintptr(index), uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)))) {
return ""
}
return windows.UTF16ToString(buf)
}
+24
View File
@@ -0,0 +1,24 @@
//go:build windows && !nogui
package fonts
import "testing"
func TestWindowsFontNamesAndWeights(t *testing.T) {
list, err := List()
if err != nil {
t.Fatal(err)
}
for _, family := range list {
if family.Name != "Segoe UI" {
continue
}
for _, face := range family.Styles {
if face.Name == "Regular" && face.Weight == 400 && face.Style == "normal" && face.Stretch == 100 {
return
}
}
t.Fatalf("Segoe UI has no regular face: %#v", family)
}
t.Fatal("DirectWrite did not return the Windows UI font's family name")
}
+24 -1
View File
@@ -32,6 +32,7 @@ import (
"github.com/yetone/magpie/internal/autostart"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/filememo"
"github.com/yetone/magpie/internal/fonts"
"github.com/yetone/magpie/internal/fx"
"github.com/yetone/magpie/internal/gateway"
"github.com/yetone/magpie/internal/library"
@@ -646,6 +647,9 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
// and the text size, which the Mac's header measures against the
// traffic lights
boot := map[string]any{"lang": s.Lang, "theme": s.Theme, "textSize": s.TextSize, "web": isWeb(w)}
if !isWeb(w) {
boot["uiFont"], boot["codeFont"] = s.UIFont, s.CodeFont
}
// and whether it is a gateway's page alone (gatewaymode.go), so the
// pages left out never show
if on, _ := gatewayMode(isWeb(w)); on {
@@ -670,6 +674,7 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
writeJSON(rw, nil)
})
omarchyRoutes(mux, w)
fontRoutes(mux, isWeb(w) || !fonts.Available, fonts.List)
mux.HandleFunc("GET /api/state", func(rw http.ResponseWriter, r *http.Request) {
// the panel and its model picker load from here: an account still
// without its vendor's list (one whose try at start-up failed) is
@@ -948,14 +953,29 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
writeJSON(rw, settingsState())
})
mux.HandleFunc("POST /api/settings", func(rw http.ResponseWriter, r *http.Request) {
var body json.RawMessage
var in settings.Settings
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
fail(rw, err)
return
}
if err := json.Unmarshal(body, &in); err != nil {
fail(rw, err)
return
}
// the Settings page sends its own choices; how the agents are
// arranged is the Agents page's, and the window's size its own; both stay as they are
cur := settings.Load()
var fields map[string]json.RawMessage
_ = json.Unmarshal(body, &fields)
// Older pages do not send fonts. An explicit null restores the
// default, while an omitted field (or a browser save) preserves it.
if _, sent := fields["uiFont"]; !sent || isWeb(w) {
in.UIFont = cur.UIFont
}
if _, sent := fields["codeFont"]; !sent || isWeb(w) {
in.CodeFont = cur.CodeFont
}
in.AgentOrder, in.AgentsHidden, in.AgentsShown = cur.AgentOrder, cur.AgentsHidden, cur.AgentsShown
in.Window, in.WindowMaximised = cur.Window, cur.WindowMaximised // the window's own, as it was last resized
// and what other pages keep here: which models an agent is shown, and
@@ -1030,6 +1050,9 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
fail(rw, err)
return
}
if onFonts != nil && (!sameFont(in.UIFont, cur.UIFont) || !sameFont(in.CodeFont, cur.CodeFont)) {
onFonts()
}
// whether agents are told every model takes images follows Vision
// (provider.Described)
if strings.TrimSpace(in.Vision) != strings.TrimSpace(cur.Vision) {
+1
View File
@@ -369,6 +369,7 @@ func Run(version string, showMain bool, link string) error {
h.panel = h.makePanel()
h.main = h.makeMain("/?" + theme)
onFonts = h.fontsChanged
// the menu in the page's language, relabelled when that changes (#301)
labels := trayMenuLabels(trayLang(settings.Load().Lang, systemLang), version, "")
+66 -35
View File
@@ -100,7 +100,7 @@
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
font: 13px/1.4 var(--font);
font: var(--ui-style, normal) var(--ui-weight, 400) 13px/1.4 var(--ui-font, var(--font)); font-stretch: var(--ui-stretch, 100%);
color: var(--fg);
background: var(--bg);
display: flex;
@@ -131,7 +131,7 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--accent); ou
input { font: inherit; color: var(--fg); }
.grow { flex: 1; }
[hidden] { display: none !important; }
code { font: 11.5px var(--code); }
code { font: var(--code-style, normal) var(--code-weight, 400) 11.5px var(--code-font, var(--code)); font-stretch: var(--code-stretch, 100%); }
/* ---- brand icons: colour ones are images, mono ones take the text colour --- */
/* a box of its own by default: a mask icon has no size to fall back on, and
@@ -801,7 +801,8 @@ body.window .field.solo { grid-column: auto; }
.led-body { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 272px; gap: 18px; }
.led-chart { min-width: 0; }
.led-chart .plot { position: relative; height: 236px; }
.led-chart svg { display: block; width: 100%; height: 100%; overflow: visible; font-family: var(--font); font-variant-numeric: tabular-nums; }
.led-chart svg { display: block; width: 100%; height: 100%; overflow: visible; font-family: var(--ui-font, var(--font)); font-variant-numeric: tabular-nums; }
:where(html[data-fonts]) .led-chart svg { font-style: var(--ui-style, inherit); font-weight: var(--ui-weight, inherit); font-stretch: var(--ui-stretch, 100%); }
.led-chart .grid { stroke: var(--line-2); stroke-width: 1; stroke-dasharray: 2 3; }
.led-chart .axis { fill: var(--muted); font-size: 10.5px; }
.led-chart .hot { fill: var(--pill); }
@@ -961,8 +962,8 @@ body.window .field.solo { grid-column: auto; }
.cx-role { padding: 0 6px; border-radius: 5px; background: var(--pill); font-size: 10.5px; line-height: 16px; color: var(--fg-2); }
.cx-role.user { color: var(--accent); background: var(--accent-soft); }
.cx-role.assistant { color: var(--green); background: var(--green-soft); }
.cx-name { font: 11px var(--code); color: var(--fg-2); }
.cx-t { margin: 0; padding: 7px 9px; border-radius: 8px; background: var(--card); border: 1px solid var(--line-2); font: 11.5px/1.5 var(--code); color: var(--fg); white-space: pre-wrap; overflow-wrap: anywhere; user-select: text; -webkit-user-select: text; }
.cx-name { font: var(--code-style, normal) var(--code-weight, 400) 11px var(--code-font, var(--code)); font-stretch: var(--code-stretch, 100%); color: var(--fg-2); }
.cx-t { margin: 0; padding: 7px 9px; border-radius: 8px; background: var(--card); border: 1px solid var(--line-2); font: var(--code-style, normal) var(--code-weight, 400) 11.5px/1.5 var(--code-font, var(--code)); font-stretch: var(--code-stretch, 100%); color: var(--fg); white-space: pre-wrap; overflow-wrap: anywhere; user-select: text; -webkit-user-select: text; }
.cx-cut { color: var(--muted); font-style: normal; }
.cx-part.clamp .cx-t { max-height: 176px; overflow: hidden; -webkit-mask-image: linear-gradient(#000 72%, transparent); mask-image: linear-gradient(#000 72%, transparent); }
.cx-more { margin-top: 2px; font-size: 11px; }
@@ -1151,10 +1152,12 @@ details.cx-part[open] > summary .cx-h { margin-bottom: 2px; }
.sess-line { display: flex; align-items: center; gap: 8px; min-width: 0; }
.sess-line .k { flex: 0 0 64px; color: var(--muted); }
.sess-line .v { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fg-2); }
.sess-line code { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--code); font-size: 11px; padding: 2px 6px; border-radius: 5px; background: var(--pill); }
.sess-line code { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--code-font, var(--code)); font-size: 11px; padding: 2px 6px; border-radius: 5px; background: var(--pill); }
:where(html[data-fonts]) .sess-line code { font-style: var(--code-style, inherit); font-weight: var(--code-weight, inherit); font-stretch: var(--code-stretch, 100%); }
.sess-line .copy { flex: none; }
.sess-models { display: grid; grid-template-columns: minmax(0, auto) 1fr auto; gap: 3px 12px; margin: 3px 0 2px 72px; font-variant-numeric: tabular-nums; }
.sess-models .model { font-family: var(--code); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sess-models .model { font-family: var(--code-font, var(--code)); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
:where(html[data-fonts]) .sess-models .model { font-style: var(--code-style, inherit); font-weight: var(--code-weight, inherit); font-stretch: var(--code-stretch, 100%); }
.sess-models .n { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sess-models .c { text-align: right; color: var(--green); }
.sess-models .c.none { color: var(--faint); }
@@ -1737,7 +1740,7 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; }
.row.model { min-height: 40px; cursor: default; }
.row.model:hover { background: var(--card-2); }
.row.model.selected { background: var(--sel); }
.row.model .name { font: 12px var(--code); }
.row.model .name { font: var(--code-style, normal) var(--code-weight, 400) 12px var(--code-font, var(--code)); font-stretch: var(--code-stretch, 100%); }
.row.model .who { user-select: text; -webkit-user-select: text; cursor: text; }
.row.model .sub { color: var(--fg-2); }
.row.model .copy { opacity: 0; }
@@ -1758,7 +1761,8 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; }
.call-item.open .call-chev { transform: rotate(90deg); }
.call .when { color: var(--faint); font-variant-numeric: tabular-nums; }
.call .a { color: var(--fg-2); flex: 0 0 auto; max-width: 90px; overflow: hidden; text-overflow: ellipsis; }
.call .m { font-family: var(--code); font-size: 11px; color: var(--fg); overflow: hidden; text-overflow: ellipsis; }
.call .m { font-family: var(--code-font, var(--code)); font-size: 11px; color: var(--fg); overflow: hidden; text-overflow: ellipsis; }
:where(html[data-fonts]) .call .m { font-style: var(--code-style, inherit); font-weight: var(--code-weight, inherit); font-stretch: var(--code-stretch, 100%); }
.call .p { color: var(--muted); }
.call .st { color: var(--green); font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; max-width: 55%; }
.call-item.bad .st { color: var(--red); }
@@ -1769,12 +1773,13 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; }
.call-body-truncated { font-size: 9.5px; color: var(--amber); }
.call-body .copy { width: 20px; height: 20px; }
.call-body pre { margin: 0; padding: 9px 10px 11px; min-height: 54px; max-height: 280px; overflow: auto; border-top: 1px solid var(--line-2); background: var(--code-bg, var(--card-2)); }
.call-body code { display: block; font: 10.5px/1.5 var(--code); color: var(--fg-2); white-space: pre-wrap; overflow-wrap: anywhere; user-select: text; -webkit-user-select: text; tab-size: 2; }
.call-body code.empty { color: var(--faint); font-family: var(--font); }
.call-body code { display: block; font: var(--code-style, normal) var(--code-weight, 400) 10.5px/1.5 var(--code-font, var(--code)); font-stretch: var(--code-stretch, 100%); color: var(--fg-2); white-space: pre-wrap; overflow-wrap: anywhere; user-select: text; -webkit-user-select: text; tab-size: 2; }
.call-body code.empty { color: var(--faint); font-family: var(--ui-font, var(--font)); }
:where(html[data-fonts]) .call-body code.empty { font-style: var(--ui-style, inherit); font-weight: var(--ui-weight, inherit); font-stretch: var(--ui-stretch, 100%); }
@media (max-width: 760px) { .call-details { grid-template-columns: 1fr; } }
/* a call the request archive kept: read back under the bodies, the whole width */
.call-archive { grid-column: 1 / -1; min-width: 0; background: var(--card); }
.call-archive-id { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 10px var(--code); color: var(--faint); }
.call-archive-id { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--code-style, normal) var(--code-weight, 400) 10px var(--code-font, var(--code)); font-stretch: var(--code-stretch, 100%); color: var(--faint); }
.call-archive .call-body-head button.text { flex: none; }
.call-archive-err { padding: 0 10px 9px; font-size: 11px; color: var(--red); overflow-wrap: anywhere; }
.call-archive-big { margin: 0; padding: 9px 10px 11px; border-top: 1px solid var(--line-2); font-size: 11px; color: var(--muted); }
@@ -1823,11 +1828,11 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; }
/* the one API a model is asked on, picked by the user: the accent, as a choice made */
.badge.mapi-tag { background: var(--accent-soft); color: var(--accent); letter-spacing: .02em; text-transform: none; }
.mchip .badge { margin: 0 -3px 0 1px; padding: 0 4px; font-size: 9px; line-height: 14px; }
.row.model .name .badge { margin-left: 6px; padding: 0 4px; font: 600 9px/14px var(--font); }
.row.model .name .badge { margin-left: 6px; padding: 0 4px; font: var(--ui-style, normal) 600 9px/14px var(--ui-font, var(--font)); font-stretch: var(--ui-stretch, 100%); }
/* the chips a model is summed up in, where a list of models is read: the
Gateway page's list, and a routing group's members in its editor */
.row.model .minfo, .rt-gedit .fbrow .minfo { display: inline-flex; align-items: center; gap: 4px; flex: none; margin-left: 8px; cursor: help; }
.row.model .minfo .badge, .rt-gedit .fbrow .minfo .badge { display: inline-flex; align-items: center; padding: 0 5px; font: 600 9.5px/16px var(--font); letter-spacing: .02em; text-transform: none; background: var(--pill); color: var(--muted); box-shadow: inset 0 0 0 1px var(--line); vertical-align: 0; }
.row.model .minfo .badge, .rt-gedit .fbrow .minfo .badge { display: inline-flex; align-items: center; padding: 0 5px; font: var(--ui-style, normal) 600 9.5px/16px var(--ui-font, var(--font)); font-stretch: var(--ui-stretch, 100%); letter-spacing: .02em; text-transform: none; background: var(--pill); color: var(--muted); box-shadow: inset 0 0 0 1px var(--line); vertical-align: 0; }
.row.model .minfo .mi-ctx, .rt-gedit .fbrow .minfo .mi-ctx { font-variant-numeric: tabular-nums; }
.row.model .minfo .mi-img, .rt-gedit .fbrow .minfo .mi-img { padding: 0 4px; }
.row.model .minfo .mi-img svg, .rt-gedit .fbrow .minfo .mi-img svg { display: block; }
@@ -1845,14 +1850,17 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; }
.use.open { box-shadow: inset 0 0 0 1px var(--accent); }
.use.add { background: none; box-shadow: none; border: 1px dashed var(--line); color: var(--faint); }
.use.add:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
.key { display: inline-flex; align-items: center; gap: 6px; height: 22px; padding: 0 8px; border-radius: 6px; font: 11px var(--code); color: var(--muted); white-space: nowrap; flex: none; margin-left: 4px; width: 128px; overflow: hidden; font-variant-numeric: tabular-nums; }
.key { display: inline-flex; align-items: center; gap: 6px; height: 22px; padding: 0 8px; border-radius: 6px; font: var(--code-style, normal) var(--code-weight, 400) 11px var(--code-font, var(--code)); font-stretch: var(--code-stretch, 100%); color: var(--muted); white-space: nowrap; flex: none; margin-left: 4px; width: 128px; overflow: hidden; font-variant-numeric: tabular-nums; }
.key::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--green); flex: none; }
.key > span { overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.key.free { font-family: var(--font); color: var(--faint); }
.key.free { font-family: var(--ui-font, var(--font)); color: var(--faint); }
:where(html[data-fonts]) .key.free { font-style: var(--ui-style, inherit); font-weight: var(--ui-weight, inherit); font-stretch: var(--ui-stretch, 100%); }
.key.free::before { background: var(--faint); }
.key.none { font-family: var(--font); color: var(--amber); background: var(--amber-soft); }
.key.none { font-family: var(--ui-font, var(--font)); color: var(--amber); background: var(--amber-soft); }
:where(html[data-fonts]) .key.none { font-style: var(--ui-style, inherit); font-weight: var(--ui-weight, inherit); font-stretch: var(--ui-stretch, 100%); }
.key.none::before { background: var(--amber); }
.key.acct { font-family: var(--font); }
.key.acct { font-family: var(--ui-font, var(--font)); }
:where(html[data-fonts]) .key.acct { font-style: var(--ui-style, inherit); font-weight: var(--ui-weight, inherit); font-stretch: var(--ui-stretch, 100%); }
.acct { display: flex; align-items: center; gap: 8px; min-height: 26px; font-size: 12.5px; }
.acct .n { font-weight: 500; }
.acct .plan { color: var(--muted); font-size: 11.5px; padding: 1px 6px; border-radius: 5px; background: var(--pill-hover); }
@@ -1902,16 +1910,19 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; }
.acc .dot.tick:hover { box-shadow: inset 0 0 0 1.5px var(--fg); }
.acc.in-use .dot.tick { background: var(--fg); }
.acc.off .n, .acc.off .plan { opacity: .55; }
.acc .rename.mono { font-family: var(--code); font-size: 11.5px; font-weight: 450; }
.acc .rename.mono { font-family: var(--code-font, var(--code)); font-size: 11.5px; font-weight: 450; }
:where(html[data-fonts]) .acc .rename.mono { font-style: var(--code-style, inherit); font-stretch: var(--code-stretch, 100%); }
.acc .rename-in { height: 24px; padding: 0 7px; font-size: 12.5px; max-width: 220px; }
.acc .plan.mono { font-family: var(--code); font-size: 10.5px; }
.acc .plan.mono { font-family: var(--code-font, var(--code)); font-size: 10.5px; }
:where(html[data-fonts]) .acc .plan.mono { font-style: var(--code-style, inherit); font-weight: var(--code-weight, inherit); font-stretch: var(--code-stretch, 100%); }
.acc.adding { display: grid; gap: 8px; padding: 10px 10px 8px 11px; background: var(--card-2); }
.acc.adding .kf { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); gap: 6px; }
.acc.adding .kb { display: flex; align-items: center; gap: 4px; }
.acc.adding .kb .grow { flex: 1; }
.acc + .acc.adding { border-top: 1px solid var(--line-2); }
@media (max-width: 520px) { .acc.adding .kf { grid-template-columns: 1fr; } }
.acc.adding .bulk-keys { width: 100%; box-sizing: border-box; resize: vertical; min-height: 84px; font-family: var(--code); font-size: 11.5px; line-height: 1.5; padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--card); color: var(--fg); }
.acc.adding .bulk-keys { width: 100%; box-sizing: border-box; resize: vertical; min-height: 84px; font-family: var(--code-font, var(--code)); font-size: 11.5px; line-height: 1.5; padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--card); color: var(--fg); }
:where(html[data-fonts]) .acc.adding .bulk-keys { font-style: var(--code-style, inherit); font-weight: var(--code-weight, inherit); font-stretch: var(--code-stretch, 100%); }
.acc.adding .bulk-count { margin-left: 6px; white-space: nowrap; }
.acc .key-weight { display: none; flex: none; font-size: 11px; padding: 1px 6px; border-radius: 5px; color: var(--fg-2); background: var(--pill); border: 0; cursor: pointer; white-space: nowrap; font-variant-numeric: tabular-nums; }
.accts.weighted .acc .key-weight { display: inline-block; }
@@ -2051,10 +2062,10 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; }
.signing .acts { display: flex; gap: 2px; margin: 2px 0 0 -4px; }
.signing .acts .link { color: var(--fg-2); }
.signing .devcode { display: flex; align-items: center; gap: 6px; margin: 6px 0 2px; }
.signing .devcode code { font: 600 17px/1.2 var(--code); letter-spacing: .12em; padding: 4px 9px; border: 1px solid var(--line); border-radius: 7px; background: var(--card); user-select: all; }
.signing .devcode code { font: var(--code-style, normal) 600 17px/1.2 var(--code-font, var(--code)); font-stretch: var(--code-stretch, 100%); letter-spacing: .12em; padding: 4px 9px; border: 1px solid var(--line); border-radius: 7px; background: var(--card); user-select: all; }
/* the sign-in link, one line, selected whole with a click */
.signing .signlink { display: flex; align-items: center; gap: 4px; margin: 5px 0 1px; min-width: 0; }
.signing .signlink code { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 11px/1.3 var(--code); color: var(--fg-2); padding: 4px 7px; border: 1px solid var(--line); border-radius: 6px; background: var(--card); user-select: all; -webkit-user-select: all; cursor: text; }
.signing .signlink code { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--code-style, normal) var(--code-weight, 400) 11px/1.3 var(--code-font, var(--code)); font-stretch: var(--code-stretch, 100%); color: var(--fg-2); padding: 4px 7px; border: 1px solid var(--line); border-radius: 6px; background: var(--card); user-select: all; -webkit-user-select: all; cursor: text; }
.signing .callback-form { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; margin-top: 6px; }
.signing .callback-form input { flex: 1; min-width: 120px; width: 100%; padding: 6px 8px; border: 1px solid var(--line); border-radius: 7px; background: var(--card); color: var(--fg); font-size: 12px; }
.signing .why { color: var(--red); }
@@ -2071,7 +2082,7 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; }
.signing.plugin-ask .choices { flex-basis: 100%; display: flex; flex-wrap: wrap; gap: 6px; }
.signing.plugin-ask .choices button { height: auto; min-height: 26px; max-width: 100%; padding: 4px 10px; white-space: normal; text-align: left; line-height: 1.3; overflow-wrap: anywhere; }
/* an import: what is pasted is sign-ins, so it is masked */
.signing.import textarea { margin-top: 6px; width: 100%; box-sizing: border-box; padding: 6px 8px; border-radius: 7px; border: 1px solid var(--line); background: var(--card); color: var(--fg); font: 12px/1.45 var(--code); resize: vertical; outline: none; -webkit-text-security: disc; }
.signing.import textarea { margin-top: 6px; width: 100%; box-sizing: border-box; padding: 6px 8px; border-radius: 7px; border: 1px solid var(--line); background: var(--card); color: var(--fg); font: var(--code-style, normal) var(--code-weight, 400) 12px/1.45 var(--code-font, var(--code)); font-stretch: var(--code-stretch, 100%); resize: vertical; outline: none; -webkit-text-security: disc; }
.signing.import textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); }
.signing.import .acts { align-items: center; gap: 8px; margin-top: 4px; }
.signing.import .fname { color: var(--muted); font-size: 11.5px; overflow-wrap: anywhere; }
@@ -2246,7 +2257,8 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; }
.editor .headers .pair.col .hhead { display: flex; gap: 8px; align-items: center; width: 100%; }
.editor .headers .pair.col .hhead input { flex: 1; min-width: 0; }
.editor .headers .pair.col .side { flex: none; }
.editor .headers textarea.json { width: 100%; box-sizing: border-box; padding: 7px 9px; border-radius: 7px; border: 1px solid var(--line); background: var(--card); color: var(--fg); outline: none; font-family: var(--code); font-size: 12px; line-height: 1.5; resize: vertical; tab-size: 2; white-space: pre; overflow: auto; transition: border-color .12s, box-shadow .12s; }
.editor .headers textarea.json { width: 100%; box-sizing: border-box; padding: 7px 9px; border-radius: 7px; border: 1px solid var(--line); background: var(--card); color: var(--fg); outline: none; font-family: var(--code-font, var(--code)); font-size: 12px; line-height: 1.5; resize: vertical; tab-size: 2; white-space: pre; overflow: auto; transition: border-color .12s, box-shadow .12s; }
:where(html[data-fonts]) .editor .headers textarea.json { font-style: var(--code-style, inherit); font-weight: var(--code-weight, inherit); font-stretch: var(--code-stretch, 100%); }
.editor .headers textarea.json.bad { border-color: var(--danger, #d66); }
.segs { position: relative; display: inline-flex; padding: 2px; border-radius: 7px; background: var(--pill); align-self: flex-start; }
.opt { position: relative; z-index: 1; white-space: nowrap; height: 22px; padding: 0 10px; border-radius: 5px; font-size: 12px; color: var(--muted); transition: color .18s var(--ease), transform .14s var(--ease); }
@@ -2325,7 +2337,7 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; }
.achip:hover .ic, .achip.on .ic { filter: none; opacity: 1; }
.achip.on { color: var(--fg); background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 45%, transparent); }
.achip.open { border-color: var(--accent); }
.achip .m { font: 11px var(--code); color: var(--accent); }
.achip .m { font: var(--code-style, normal) var(--code-weight, 400) 11px var(--code-font, var(--code)); font-stretch: var(--code-stretch, 100%); color: var(--accent); }
/* endpoints, each with its own test result */
.eps { display: flex; flex-direction: column; gap: 3px; }
@@ -2473,6 +2485,21 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; }
.row.pref { min-height: 50px; padding: 8px 12px 8px 14px; gap: 14px; }
.row.pref .who { flex: 1 1 auto; }
.row.pref .sub { margin-top: 2px; color: var(--fg-2); }
.row.pref.font-row { flex-wrap: wrap; gap: 8px 14px; }
.font-row .who { flex-basis: 160px; }
.font-row .font-note { white-space: normal; }
.font-controls { display: grid; grid-template-columns: minmax(100px, 1fr) minmax(72px, auto); gap: 5px 6px; width: 310px; max-width: 100%; margin-left: auto; min-width: 0; }
.row.pref .font-controls .pick { margin: 0; width: 100%; justify-content: space-between; min-width: 0; }
.font-controls .pick span { overflow: hidden; text-overflow: ellipsis; }
.font-controls .pick svg { flex: none; }
.font-controls .pick:disabled { opacity: .5; }
.font-preview { grid-column: 1 / -1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fg-2); padding: 2px 9px; user-select: text; }
#uiFontRow .font-preview { font: var(--ui-style, normal) var(--ui-weight, 400) 13px/1.5 var(--ui-font, var(--font)); font-stretch: var(--ui-stretch, 100%); }
#codeFontRow .font-preview { font: var(--code-style, normal) var(--code-weight, 400) 13px/1.5 var(--code-font, var(--code)); font-stretch: var(--code-stretch, 100%); }
.pop.font-menu { width: min(330px, calc(100vw - 16px)); max-width: calc(100vw - 16px); }
.font-menu .sc-top { top: -4px; }
.font-menu .pm-head { white-space: normal; }
.font-menu .pm-name { white-space: normal; overflow-wrap: anywhere; }
/* Keep export consent readable, and give collector URLs their own line. */
#otelList .otel-section { padding: 10px 14px 6px; font-size: 11px; font-weight: 600; color: var(--muted); background: var(--pill); }
#otelList .otel-section:not(:first-child) { border-top: 1px solid var(--line-2); }
@@ -2509,7 +2536,8 @@ input.words.rule-kind { flex: none; width: 110px; }
input.words.gateway-port { flex: none; width: 110px; font-variant-numeric: tabular-nums; }input.words.rule-match { flex: 1 1 auto; width: auto; min-width: 0; }
input.words.search-key, input.words.search-url { flex: 1 1 120px; width: auto; min-width: 0; }
/* a search API's key, shown: selectable, and broken anywhere rather than widening the row */
.search-api .sub.revealed { font-family: var(--code); overflow-wrap: anywhere; user-select: text; -webkit-user-select: text; }
.search-api .sub.revealed { font-family: var(--code-font, var(--code)); overflow-wrap: anywhere; user-select: text; -webkit-user-select: text; }
:where(html[data-fonts]) .search-api .sub.revealed { font-style: var(--code-style, inherit); font-weight: var(--code-weight, inherit); font-stretch: var(--code-stretch, 100%); }
.prefs .val.rule-add .segs { margin-left: 0; }
.row.pref .sub.err { color: var(--red); white-space: normal; }
.warm-at { display: flex; align-items: center; gap: 8px; margin-left: auto; flex: none; }
@@ -2537,14 +2565,14 @@ input.words.search-key, input.words.search-url { flex: 1 1 120px; width: auto; m
.prefs .lan-address-row .val { max-width: 64%; }
.lan-address-controls { position: absolute; inset: 0; pointer-events: none; }
.lan-address-controls > * { position: absolute; pointer-events: auto; }
.row.pref .proto.pick.lan-interface { top: 13px; right: 40px; width: 240px; max-width: calc(100% - 40px); margin-left: 0; font: 12px var(--code); transition: top .28s var(--ease), background .12s, color .12s; }
.row.pref .proto.pick.lan-interface { top: 13px; right: 40px; width: 240px; max-width: calc(100% - 40px); margin-left: 0; font: var(--code-style, normal) var(--code-weight, 400) 12px var(--code-font, var(--code)); font-stretch: var(--code-stretch, 100%); transition: top .28s var(--ease), background .12s, color .12s; }
.lan-interface .lan-url { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lan-address-controls .lan-address-text { top: 16px; right: 40px; width: 240px; max-width: calc(100% - 40px); font: 12px var(--code); transition: top .28s var(--ease); }
.lan-address-controls .lan-address-text { top: 16px; right: 40px; width: 240px; max-width: calc(100% - 40px); font: var(--code-style, normal) var(--code-weight, 400) 12px var(--code-font, var(--code)); font-stretch: var(--code-stretch, 100%); transition: top .28s var(--ease); }
.lan-address-controls .segs { top: 12px; right: 286px; margin-left: 0; transition: right .28s var(--ease); }
.lan-copy { top: 14px; right: 12px; display: inline-flex; transition: top .28s var(--ease); }
.row.pref.lan-container .sub { white-space: normal; }
.pop.proto-menu.lan-address-menu { width: min(320px, calc(100vw - 16px)); max-height: min(360px, 70vh); overflow-y: auto; }
.lan-address-menu .pm-name { font: 12px var(--code); }
.lan-address-menu .pm-name { font: var(--code-style, normal) var(--code-weight, 400) 12px var(--code-font, var(--code)); font-stretch: var(--code-stretch, 100%); }
@media (max-width: 600px) {
.row.pref.lan-address-row { height: 76px; }
.lan-address-controls .segs { right: 12px; }
@@ -2654,7 +2682,7 @@ html.theming, html.theming *, html.theming *::before, html.theming *::after {
.whatsnew .wn-notes ul { margin: 2px 0; padding-left: 18px; }
.whatsnew .wn-notes li + li { margin-top: 2px; }
.whatsnew .wn-notes p { margin: 4px 0; }
.whatsnew .wn-notes code { font: 11.5px/1.4 var(--code); padding: 0 4px; border-radius: 4px; background: var(--pill); }
.whatsnew .wn-notes code { font: var(--code-style, normal) var(--code-weight, 400) 11.5px/1.4 var(--code-font, var(--code)); font-stretch: var(--code-stretch, 100%); padding: 0 4px; border-radius: 4px; background: var(--pill); }
.whatsnew .wn-link { color: var(--accent); text-decoration: none; }
.whatsnew .wn-link:hover { text-decoration: underline; }
.whatsnew .wn-msg { margin: 4px 0 6px; font-size: 12.5px; color: var(--fg-2); }
@@ -2768,7 +2796,7 @@ body.panel .modal { padding: 16px 12px; }
.editor.importapps .applogo { width: 18px; height: 18px; flex: none; border-radius: 4px; }
.editor.importapps .appall { flex: none; display: flex; align-items: center; gap: 5px; font-size: 11.5px; font-weight: 400; color: var(--muted); text-align: left; cursor: pointer; padding-right: 6px; }
.editor.importapps .appall input { margin: 0; accent-color: var(--fg); }
.editor.importapps .apphead code { flex: 1; min-width: 0; font: 11px var(--code); color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.editor.importapps .apphead code { flex: 1; min-width: 0; font: var(--code-style, normal) var(--code-weight, 400) 11px var(--code-font, var(--code)); font-stretch: var(--code-stretch, 100%); color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.editor.importapps .appempty { font-size: 12px; color: var(--muted); padding: 2px 0 4px; }
.editor.importapps .approw { text-align: left; font-weight: 400; color: var(--fg); display: flex; align-items: flex-start; gap: 10px; padding: 8px 6px; border-radius: 8px; cursor: pointer; }
.editor.importapps .approw:hover:not(.dim) { background: var(--pill); }
@@ -3097,7 +3125,8 @@ body.window .row + .row.agent.conn > .who { min-height: 41px; } /* the line abov
#agents .ag-exp .ag-vl > .fields { display: flex; flex-wrap: wrap; width: 100%; gap: 6px; }
#agents .ag-exp .ag-vl > .fields > .field { flex: 0 1 auto; min-width: 170px; }
#agents .ag-exp .ag-vl > .fields > .extras-cell { justify-content: flex-start; flex-wrap: wrap; }
.ag-code, .ag-diff code, .ag-path { font-family: var(--code); font-size: 11.5px; }
.ag-code, .ag-diff code, .ag-path { font-family: var(--code-font, var(--code)); font-size: 11.5px; }
:where(html[data-fonts]) .ag-code, :where(html[data-fonts]) .ag-diff code, :where(html[data-fonts]) .ag-path { font-style: var(--code-style, inherit); font-weight: var(--code-weight, inherit); font-stretch: var(--code-stretch, 100%); }
.ag-code { padding: 1px 5px; border-radius: 4px; background: var(--pill); color: var(--fg); }
.ag-warn { gap: 8px; font-size: 12px; }
.ag-hint { font-size: 12px; color: var(--muted); }
@@ -3117,12 +3146,14 @@ body.window .row + .row.agent.conn > .who { min-height: 41px; } /* the line abov
/* a file's lines as disconnecting changes them */
.ag-diff { display: grid; gap: 10px; }
.ag-diff-file { border: 1px solid var(--line); border-radius: 8px; background: var(--card); overflow: hidden; }
.ag-diff-path { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--line-2); font-family: var(--code); font-size: 11.5px; color: var(--fg); }
.ag-diff-path { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--line-2); font-family: var(--code-font, var(--code)); font-size: 11.5px; color: var(--fg); }
:where(html[data-fonts]) .ag-diff-path { font-style: var(--code-style, inherit); font-weight: var(--code-weight, inherit); font-stretch: var(--code-stretch, 100%); }
.ag-diff-path:last-child { border-bottom: 0; }
.ag-diff-line { display: flex; align-items: baseline; gap: 8px; padding: 2px 10px; font-size: 11.5px; line-height: 1.6; }
.ag-diff-line:first-of-type { padding-top: 6px; }
.ag-diff-line:last-child { padding-bottom: 6px; }
.ag-diff-line .op { width: 10px; flex: none; text-align: center; font-family: var(--code); }
.ag-diff-line .op { width: 10px; flex: none; text-align: center; font-family: var(--code-font, var(--code)); }
:where(html[data-fonts]) .ag-diff-line .op { font-style: var(--code-style, inherit); font-weight: var(--code-weight, inherit); font-stretch: var(--code-stretch, 100%); }
.ag-diff-line code { flex: 1; min-width: 0; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--fg-2); }
.ag-diff-line.del .op, .ag-diff-line.del code { color: var(--red); }
.ag-diff-line .ind { text-decoration: none; }
+127 -5
View File
@@ -11719,7 +11719,7 @@ function closeProtoMenu() {
// ticked ones, in the menu's order, once it closes (and only if they
// changed). With live, choose runs after each tick instead; "" is none
// of them and closes it, "\x00" a note to read.
function openProtoMenu(anchor, opts, value, choose, head = "Protocol this key speaks", cls = "", align = "left", live = false) {
function openProtoMenu(anchor, opts, value, choose, head = "Protocol this key speaks", cls = "", align = "left", live = false, search = "") {
closeProtoMenu();
const multi = Array.isArray(value);
let picked = multi ? [...value] : null;
@@ -11727,6 +11727,15 @@ function openProtoMenu(anchor, opts, value, choose, head = "Protocol this key sp
const box = el("div", "pop proto-menu" + (cls ? " " + cls : ""));
box.setAttribute("role", "menu");
box.append(el("div", "pm-head", t(head)));
let filter = null;
if (search) {
const top = el("div", "sc-top");
filter = el("input", "sc-q");
filter.placeholder = t(search);
filter.setAttribute("aria-label", t(search));
top.append(filter);
box.append(top);
}
const tick = (b, o) => {
b.classList.toggle("on", isOn(o.v));
b.setAttribute("aria-checked", isOn(o.v));
@@ -11757,6 +11766,10 @@ function openProtoMenu(anchor, opts, value, choose, head = "Protocol this key sp
box.append(b);
return b;
});
if (filter) filter.oninput = () => {
const q = filter.value.trim().toLocaleLowerCase();
items.forEach((b, i) => { b.hidden = !opts[i].always && !opts[i].name.toLocaleLowerCase().includes(q); });
};
document.body.append(box);
// under the pill, or above it when the window runs out
const r = anchor.getBoundingClientRect(), w = box.offsetWidth, h = box.offsetHeight, pad = 8;
@@ -11778,12 +11791,19 @@ function openProtoMenu(anchor, opts, value, choose, head = "Protocol this key sp
// Scrolling the menu keeps it open; scrolling outside moves its anchor.
const scroll = (e) => { if (!box.contains(e.target)) closeProtoMenu(); };
const keys = (e) => {
const i = items.indexOf(document.activeElement);
const shown = filter ? items.filter((b) => !b.hidden) : items;
const i = shown.indexOf(document.activeElement);
if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); closeProtoMenu(); anchor.focus(); }
else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault(); e.stopPropagation();
const n = items.length, from = i < 0 ? (e.key === "ArrowDown" ? n - 1 : 0) : i;
items[(from + (e.key === "ArrowDown" ? 1 : n - 1)) % n].focus();
const n = shown.length, from = i < 0 ? (e.key === "ArrowDown" ? n - 1 : 0) : i;
if (n) shown[(from + (e.key === "ArrowDown" ? 1 : n - 1)) % n].focus();
} else if (filter && e.key === "Enter" && document.activeElement === filter) {
const first = shown.find((b) => !opts[items.indexOf(b)].always);
if (first) { e.preventDefault(); first.click(); }
} else if (filter && (e.key === "Home" || e.key === "End") && i >= 0) {
e.preventDefault();
shown[e.key === "Home" ? 0 : shown.length - 1]?.focus();
}
};
document.addEventListener("mousedown", outside, true);
@@ -11795,7 +11815,7 @@ function openProtoMenu(anchor, opts, value, choose, head = "Protocol this key sp
choose(opts.map((o) => o.v).filter((v) => picked.includes(v)));
} : null;
protoMenu = { box, anchor, outside, keys, scroll, done };
(items.find((b) => b.classList.contains("on")) || items[0]).focus({ preventScroll: true });
(filter || items.find((b) => b.classList.contains("on")) || items[0]).focus({ preventScroll: true });
}
// keyPill is a key provider's row badge: the key in use, or how many are.
@@ -16864,11 +16884,110 @@ if (!web) addEventListener("keydown", (e) => {
openSettings();
}, true);
// Font discovery is lazy and separate from polling the app's state. Drafts
// keep the family/style controls usable while the shared save queue drains.
let fontList = null, fontListError = false, fontFlight = null, fontRevision = 0;
const fontDrafts = {};
function fontChoices(s) {
return { ...s, ...Object.fromEntries(Object.entries(fontDrafts).map(([k, d]) => [k, d.value])) };
}
// The native host notifies both existing webviews after a successful save.
// The sender finishes its own queued writes; the other page updates now.
window.receiveFonts = (s) => {
if (web || prefsBusy) return;
const fonts = { uiFont: s.uiFont || null, codeFont: s.codeFont || null };
if (prefs) Object.assign(prefs, fonts);
if (state?.settings) Object.assign(state.settings, fonts);
window.desktopFonts.apply(fonts);
if (prefs && view === "settings") renderFonts(prefs);
};
async function loadFonts(refresh = false) {
if (web || fontFlight || (fontList !== null && !refresh)) return fontFlight;
fontListError = false;
fontFlight = api("fonts" + (refresh ? "?refresh=1" : ""))
.then((list) => {
if (!Array.isArray(list)) throw new Error("invalid font collection");
fontList = list;
window.desktopFonts.catalogue(list);
window.desktopFonts.apply(fontChoices(prefs || window.bootPrefs));
})
.catch(() => { fontListError = true; })
.finally(() => { fontFlight = null; if (prefs) renderFonts(prefs); });
if (prefs) renderFonts(prefs);
return fontFlight;
}
function chooseFont(key, value) {
const revision = ++fontRevision;
fontDrafts[key] = { value, revision };
window.desktopFonts.apply(fontChoices(prefs));
renderFonts(prefs);
return savePrefs({ ...prefsKeep(prefs), [key]: value }).finally(() => {
if (fontDrafts[key]?.revision === revision) delete fontDrafts[key];
window.desktopFonts.apply(fontChoices(prefs));
renderFonts(prefs);
});
}
function closestFont(styles, old) {
const exact = styles.find((f) => old && f.name === old.name && f.weight === old.weight && f.style === old.style && f.stretch === old.stretch);
if (exact) return exact;
const matching = styles.find((f) => old && f.weight === old.weight && f.style === old.style && f.stretch === old.stretch);
if (matching) return matching;
// A new family without that style starts at its nearest regular face.
return [...styles].sort((a, b) =>
Number(a.style !== "normal") - Number(b.style !== "normal") || Math.abs(a.weight - 400) - Math.abs(b.weight - 400) || Math.abs(a.stretch - 100) - Math.abs(b.stretch - 100))[0];
}
function renderFonts(s) {
const current = fontChoices(s);
for (const [key, id, label] of [["uiFont", "uiFontRow", "Interface font"], ["codeFont", "codeFontRow", "Code font"]]) {
const row = $("#" + id);
row.hidden = web;
if (web) continue;
const chosen = current[key], family = fontList?.find((f) => f.name === chosen?.family);
const missing = chosen && !window.desktopFonts.available(chosen);
const note = row.querySelector(".font-note");
note.textContent = fontListError ? t("Couldn't read installed fonts") : fontFlight ? t("Loading…")
: missing ? t("Font unavailable; using system default") : fontList?.length === 0 ? t("No installed fonts found") : t("Saved only on this computer");
note.classList.toggle("err", fontListError || !!missing);
const pill = row.querySelector(".font-family"), style = row.querySelector(".font-style");
const paint = (button, text, name) => {
button.replaceChildren(el("span", "", text), svg(CHEV, 11, 1.6));
button.title = text;
button.setAttribute("aria-label", t(label) + ": " + t(name));
};
paint(pill, chosen?.family || t("System default"), "Font family");
paint(style, chosen?.name || t("System default"), "Font style");
style.disabled = !family?.styles.length;
row.querySelector(".font-preview").textContent = t("Aa 0123 · 中文");
pill.onclick = (e) => {
e.stopPropagation();
if (pill.classList.contains("open")) return closeProtoMenu();
const options = [{ v: "", name: t("System default"), note: "", literalName: true },
...(fontList || []).map((f) => ({ v: "family:" + f.name, name: f.name, note: "", literalName: true })),
{ v: "refresh", name: t("Refresh fonts"), note: fontListError ? t("Couldn't read installed fonts") : "", literalName: true, always: true }];
openProtoMenu(pill, options, chosen ? "family:" + chosen.family : "", (v) => {
if (v === "refresh") { loadFonts(true); return; }
if (!v) { chooseFont(key, null); return; }
const next = fontList.find((f) => "family:" + f.name === v);
if (next?.styles.length) chooseFont(key, closestFont(next.styles, chosen));
}, label, "sess-menu font-menu", "right", false, "Search fonts…");
};
style.onclick = (e) => {
e.stopPropagation();
if (style.classList.contains("open")) return closeProtoMenu();
const styles = family?.styles || [];
openProtoMenu(style, styles.map((f, i) => ({ v: String(i), name: f.name, note: "", literalName: true })),
String(styles.findIndex((f) => window.desktopFonts.same(f, chosen))), (i) => chooseFont(key, styles[Number(i)]),
"Font style", "sess-menu font-menu", "right");
};
}
}
// 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
// ?theme= or ?locale= in the URL wins, so a forced look stays forced.
function applyPrefs(s, rate) {
s = s || {};
if (!prefsBusy) window.desktopFonts?.apply(s);
const root = document.documentElement;
if (!params.get("theme")) {
const want = !s.theme || s.theme === "system" ? undefined : s.theme;
@@ -16930,6 +17049,7 @@ function renderBarIcon() {
async function loadSettings() {
const since = prefsWrites;
if (!web && fontList === null && !fontListError) loadFonts();
if (window.bootPrefs?.omarchy && !barIcon) api("omarchy/widget").then((b) => { barIcon = b; renderBarIcon(); }).catch(() => {});
const s = await api("settings");
if (!prefsSettled(since) && prefs) return; // the save draws the page when it's in
@@ -17203,6 +17323,7 @@ function renderSettings() {
const s = prefs;
const keep = prefsKeep(s);
prefsBase = keep;
renderFonts(s);
$("#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 })));
renderGatewayMode(s);
@@ -18968,6 +19089,7 @@ function wbCheckinLine(r) {
function prefsKeep(s) {
return { theme: s.theme, lang: s.lang, tray: s.tray, dock: !!s.dock, dockWindow: !!s.dockWindow, lightweight: !!s.lightweight, keepAwake: !!s.keepAwake, keepAwakeDisplay: !!s.keepAwakeDisplay, proxy: s.proxy || "",
sessionTerminal: s.sessionTerminal || "",
uiFont: s.uiFont || null, codeFont: s.codeFont || null,
otel: s.otel || {},
trayUsages: s.trayUsages || [],
redact: !!s.redact, redactPersonal: !!s.redactPersonal, redactWords: s.redactWords || [], codexWarmup: s.codexWarmup || "",
+31
View File
@@ -0,0 +1,31 @@
// Installed font choices reach both webviews before their first paint.
// Keep --font and --code as the platform/theme fallbacks: an explicit
// choice is a separate variable, including on Omarchy's body.
window.desktopFonts = (() => {
let catalogue = null;
const same = (a, b) => !!a && !!b && ["family", "name", "weight", "style", "stretch"].every((k) => a[k] === b[k]);
const available = (face) => !face || catalogue === null || catalogue.some((f) => f.styles.some((s) => same(s, face)));
const quoted = (s) => '"' + s.replace(/[\\"\x00-\x1f\x7f]/g, (c) => "\\" + c.charCodeAt(0).toString(16) + " ") + '"';
function apply(s) {
const root = document.documentElement;
let active = false;
for (const [key, prefix, fallback] of [["uiFont", "ui", "font"], ["codeFont", "code", "code"]]) {
const f = !window.bootPrefs?.web && s?.[key];
const valid = f && typeof f.family === "string" && f.family && ["normal", "italic", "oblique"].includes(f.style)
&& Number.isFinite(f.weight) && f.weight >= 1 && f.weight <= 1000 && Number.isFinite(f.stretch) && f.stretch >= 0;
const values = valid && available(f) ? {
font: quoted(f.family) + ", var(--" + fallback + ")", weight: f.weight, style: f.style, stretch: f.stretch + "%",
} : {};
if (values.font) active = true;
for (const part of ["font", "weight", "style", "stretch"]) {
const name = "--" + prefix + "-" + part;
if (values[part] !== undefined) root.style.setProperty(name, values[part]);
else root.style.removeProperty(name);
}
}
if (active) root.setAttribute("data-fonts", "");
else root.removeAttribute("data-fonts");
}
return { apply, available, same, catalogue: (list) => { catalogue = list; } };
})();
window.desktopFonts.apply(window.bootPrefs);
+36
View File
@@ -4,6 +4,18 @@
// works. {name} slots are filled by t(str, vars).
const I18N = {
zh: {
"No installed fonts found": "未找到已安装字体",
"Interface font": "界面字体",
"Code font": "代码字体",
"Font family": "字体",
"Font style": "样式",
"System default": "系统默认",
"Search fonts…": "搜索字体…",
"Refresh fonts": "刷新字体列表",
"Couldn't read installed fonts": "无法读取已安装字体",
"Font unavailable; using system default": "字体不可用,暂用系统默认",
"Saved only on this computer": "仅保存在当前电脑",
"Aa 0123 · 中文": "Aa 0123 · 中文",
"Give skills as": "Skills 启用方式",
"Links": "符号链接",
"The agents get copies of their skills now": "现在各 Agent 拿到的是 Skills 的副本",
@@ -3516,6 +3528,18 @@ const I18N = {
"credits": "积分",
},
ja: {
"No installed fonts found": "インストール済みフォントが見つかりません",
"Interface font": "インターフェースのフォント",
"Code font": "コードのフォント",
"Font family": "フォントファミリー",
"Font style": "書体",
"System default": "システムの既定",
"Search fonts…": "フォントを検索…",
"Refresh fonts": "フォント一覧を更新",
"Couldn't read installed fonts": "インストール済みフォントを読み取れませんでした",
"Font unavailable; using system default": "フォントを利用できないため、システムの既定を使用中",
"Saved only on this computer": "このコンピューターにのみ保存",
"Aa 0123 · 中文": "Aa 0123 · 日本語",
"Give skills as": "スキルの渡し方",
"Links": "リンク",
"The agents get copies of their skills now": "エージェントにはスキルのコピーを渡すようになりました",
@@ -7013,6 +7037,18 @@ const I18N = {
"Main navigation": "メインナビゲーション",
},
de: {
"No installed fonts found": "Keine installierten Schriften gefunden",
"Interface font": "Oberflächenschrift",
"Code font": "Codeschrift",
"Font family": "Schriftfamilie",
"Font style": "Schriftschnitt",
"System default": "Systemstandard",
"Search fonts…": "Schriften suchen…",
"Refresh fonts": "Schriftliste aktualisieren",
"Couldn't read installed fonts": "Installierte Schriften konnten nicht gelesen werden",
"Font unavailable; using system default": "Schrift nicht verfügbar; Systemstandard wird verwendet",
"Saved only on this computer": "Nur auf diesem Computer gespeichert",
"Aa 0123 · 中文": "Aa 0123 · 中文",
"Give skills as": "Skills geben als",
"Links": "Links",
"The agents get copies of their skills now": "Die Agenten bekommen jetzt Kopien ihrer Skills",
+17
View File
@@ -18,6 +18,7 @@
<body>
<script src="compat.js"></script>
<script src="layout.js"></script>
<script src="fonts.js"></script>
<header class="top" style="--wails-draggable:drag">
<div class="brand">
<span class="logo" aria-hidden="true"><svg viewBox="0 0 44 44" width="18" height="18"><defs><path id="bird" fill="currentColor" fill-rule="evenodd" d="M1.5 5.5C1.3 5.7 1.2 6.5 1.2 6.9C1.2 6.9 1.2 7 1.2 7.1C1.1 7.4 1.3 8 1.4 8.3C1.4 8.4 1.5 8.5 1.5 8.7C1.5 8.9 1.7 9 1.8 9.3C2.2 9.9 2.2 10 2.3 10.1C2.4 10.2 2.5 10.3 2.6 10.5C2.8 10.8 3 11.1 3.2 11.3C3.3 11.3 3.4 11.6 3.6 11.8C3.8 12 4.1 12.4 4.2 12.5C4.7 13 5.2 13.7 5.4 13.9C6 14.5 6.5 15.1 6.7 15.3C6.7 15.4 6.9 15.5 6.9 15.7C7.3 15.9 7.7 16.5 7.9 16.8C7.9 16.8 8 16.9 8 16.9C8.1 16.9 8.2 17.2 8.2 17.3C8.2 17.3 8.1 17.2 8.1 17.1C7.9 16.9 7.9 16.9 7.6 16.6C7.5 16.5 7.4 16.5 7.4 16.3C7.2 16.2 6.7 15.7 6.3 15.2C6.1 15.1 6 14.9 6 14.9C5.9 14.8 5.7 14.7 5.7 14.6C5.6 14.5 5.4 14.3 5.3 14.2C5.1 14.1 4.9 13.8 4.7 13.5C4.5 13.3 4.2 13.1 4.1 12.9C4.1 12.9 3.8 12.7 3.8 12.5C3.5 12.3 3 11.8 2.8 11.4C1.8 10.5 0.9 9.6 0.7 9.6C0.6 9.7 0.6 9.8 0.5 10C0.5 10.1 0.6 10.2 0.5 10.3C0.5 10.4 0.6 10.5 0.6 10.6C0.5 10.7 0.5 10.9 0.6 11C0.5 11.1 0.6 11.4 0.6 11.5C0.9 12.9 1.3 13.8 2.8 14.9C2.9 15.1 3.3 15.3 3.4 15.4C3.4 15.5 3.6 15.6 3.7 15.7C4.2 16 6.4 17.8 7 18.4C7.4 18.8 7.4 18.8 7.5 18.9C7.6 19 7.7 19 7.7 19.1C7.7 19.2 7.8 19.2 7.9 19.2C7.9 19.3 8.1 19.4 8.1 19.5C8.2 19.6 8.2 19.7 8.3 19.7C8.3 19.7 8.3 19.7 8.2 19.7C8.2 19.6 8.1 19.6 8 19.6C7.9 19.5 7.9 19.4 7.8 19.4C7.7 19.3 7.6 19.2 7.6 19.2C7.6 19.1 7.5 19 7.3 18.9C7.2 18.9 6.9 18.6 6.7 18.4C6.1 17.8 4.5 16.6 3.5 16C3.4 15.8 3 15.6 2.9 15.5C2.6 15.3 1.5 14.6 0.9 14.3C0.7 14.1 0.6 14.1 0.6 14.2C0.5 14.5 0.8 15.9 0.9 16.1C0.9 16.2 1 16.2 0.9 16.3C1.2 16.8 1.9 17.6 2.7 18.3C2.8 18.5 4 19.2 4.7 19.5C6.2 20.5 6.6 20.7 7.4 21.5C8.2 22.2 8.7 22.7 9.3 23.6C9.5 24.1 9.9 24.4 10.3 25.3C10.4 25.5 10.5 25.8 10.5 25.8C10.7 25.8 10.8 25.7 11 25.4C11.6 25 11.9 24.6 12.3 24.3C12.8 24 13 23.8 13 23.7C13.1 23.7 13.1 23.8 13 24C12 24.8 11 26 10.2 26.9C10.1 27.1 9.9 27.5 9.7 27.7C9.5 27.8 9.4 28 9.4 28C9.4 28 9.3 28.3 9.1 28.5C8.6 29.2 7.7 30.8 7.7 30.9C7.7 31 7.8 31 8.3 30.8C9 30.4 9.5 30.2 10.4 30C11.7 29.6 12.5 29.6 11.6 30C10.5 30.2 9.7 30.6 9.2 31.2C8.8 31.7 8.8 31.7 9.3 31.7C10.1 31.8 10.9 31.9 11.6 32.2C12.8 32.7 13.5 33.1 15.5 34.8C16.4 35.5 17.3 36.2 17.8 36.4C17.9 36.5 18.1 36.7 18.2 36.8C19.1 37.3 21.2 38.1 22.3 38.3C22.4 38.3 22.7 38.3 22.8 38.3C22.8 38.5 23 38.5 23 38.5C23.1 38.5 23.2 38.5 23.3 38.5C23.6 38.6 25.9 38.6 26.1 38.5C26.2 38.5 26.3 38.5 26.4 38.5C26.5 38.5 26.6 38.5 26.7 38.3C26.7 38.3 27 38.3 27.1 38.3C28.3 38.1 30.2 37.3 31.4 36.7C31.5 36.7 31.7 36.4 31.8 36.4C32.2 36.2 33.2 35.4 33.4 35.2C34.2 34.5 35.5 33 35.8 32.6C35.8 32.5 36 32.2 36.1 32C36.4 31.8 36.6 31.4 36.6 31.2C36.7 31.1 36.8 30.9 36.9 30.8C37.2 30.4 37.5 29.6 37.7 29.2C37.7 29.1 37.8 28.7 37.9 28.7C38.1 28.5 38.4 27.1 38.5 26.8C39 25.2 39.1 23.7 39 22.4C39 22.2 38.9 21.9 38.9 21.8C38.9 21.7 38.9 21.6 38.9 21.4C38.6 20.5 38.5 19.9 38.2 19C37.9 18.5 37.5 17.5 37.3 17.2C37 16.8 36.8 15.8 36.8 15.2C36.8 13.8 37.7 12.7 39.6 12.2C40.1 12.1 41.3 11.8 42.1 11.7C43.5 11.5 43.5 11.5 43.4 11.3C42.9 10.7 40.9 9.9 39.2 9.7C37.9 9.5 38.1 9.5 37.6 9C37 8.6 36.4 8 36 7.9C36 7.8 35.8 7.8 35.7 7.7C35.1 7.3 34.2 7 33.1 6.9C32.6 6.7 31.6 6.9 31 7C29.6 7.3 28.3 8 27.3 9.2C26.7 9.8 26.2 10.8 25.7 11.7C25.5 12.1 24.8 13.4 24.7 13.7C24.6 13.9 24.5 14.1 24.1 14.7C23.5 15.7 22.1 16.9 20.6 17.8C20.5 18 20.3 18.2 20.1 18.3C19.7 18.5 19.4 18.8 19.1 19C18.9 19.1 18.7 19.3 18.5 19.4C18 19.8 17.2 20.3 17 20.5C16.9 20.5 16.8 20.6 16.7 20.7C16.3 20.8 16.1 20.9 16 20.9C15.5 20.8 14.9 20.5 14.2 20.2C13.7 19.8 12.7 18.9 12.4 18.6C12.3 18.5 12.2 18.4 12.1 18.2C11.9 18 11.2 17.1 11 16.6C10.9 16.5 10.8 16.4 10.7 16.3C10.7 16.2 10.6 16.1 10.6 15.9C10.5 15.9 10.4 15.7 10.4 15.6C10.3 15.5 10.2 15.2 10.1 15C10 14.8 9.8 14.4 9.7 14.2C9.6 14 9.5 13.7 9.4 13.6C9.4 13.4 9.3 13.2 9.2 13C9.2 12.8 9 12.6 9 12.5C8.9 12.3 8.7 12.1 8.7 12C8.7 12 8.5 11.7 8.5 11.7C8.3 11.2 8.2 10.9 7.5 10C6.4 8.3 4.2 6.4 2.7 5.8C2 5.5 1.6 5.4 1.5 5.5ZM28.4 16.7C29.2 16.9 29.7 17.2 29.9 17.7C30.1 18.2 30 18.9 29.6 19.4C29.2 19.9 28.2 20.9 27.6 21.2C26.8 21.7 25.3 22.4 24.2 22.7C23.9 22.8 23.6 22.9 23.5 22.9C22.8 23.2 20.7 23.7 19.6 24C19 24.2 18.4 24.3 18.1 24.4C17.9 24.4 16.1 24.9 15.3 25.1C14.6 25.2 14.7 25.1 15.7 23.6C16.4 22.7 18.5 20.6 19.4 19.9C20.8 18.8 21.4 18.4 23.2 17.6C23.8 17.4 25.6 16.8 25.9 16.8C25.9 16.8 26.3 16.8 26.6 16.7C26.8 16.7 28.2 16.7 28.4 16.7ZM30.4 20.2C30.5 20.3 30.6 20.6 30.6 20.7C31.7 23.1 33.4 24.6 35.6 25.3C36.1 25.5 36.7 25.7 37.2 25.8C37.7 25.8 37.8 25.9 37.8 26.1C37.7 26.2 37.7 26.3 37.7 26.5C37.7 26.6 37.7 26.8 37.6 27C37.4 28.5 36.8 29.9 36.1 31C36.1 31.2 35.9 31.4 35.9 31.6C35.7 31.9 35.2 32.6 34.7 33.3C32.6 35.6 29.7 37.3 26.6 37.8C26.5 37.8 26.3 37.9 26.2 37.9C25.8 37.9 23.7 37.9 23.3 37.9C23.2 37.9 23 37.8 22.9 37.8C21.1 37.6 19.3 36.8 18 35.7C17.7 35.4 17 34.7 16.8 34.5C16.3 33.8 16.1 33 16.3 32.5C16.5 31.8 16.8 31.6 17.9 31.1C19.1 30.5 19.4 30.4 20.2 30C21.4 29.4 23.2 28.4 23.9 27.9C23.9 27.8 24.2 27.7 24.5 27.6C26.3 26.3 28 24.6 28.9 22.9C29.5 22.2 29.7 21.6 29.9 20.7C30.1 20 30.2 19Line truncated
@@ -253,6 +254,22 @@
<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 font-row" id="uiFontRow" hidden>
<div class="who"><div class="name" data-t>Interface font</div><div class="sub font-note"></div></div>
<div class="font-controls">
<button type="button" class="proto pick font-family" aria-haspopup="menu" aria-expanded="false"></button>
<button type="button" class="proto pick font-style" aria-haspopup="menu" aria-expanded="false"></button>
<div class="font-preview">Aa 0123 · 中文</div>
</div>
</div>
<div class="row pref font-row" id="codeFontRow" hidden>
<div class="who"><div class="name" data-t>Code font</div><div class="sub font-note"></div></div>
<div class="font-controls">
<button type="button" class="proto pick font-family" aria-haspopup="menu" aria-expanded="false"></button>
<button type="button" class="proto pick font-style" aria-haspopup="menu" aria-expanded="false"></button>
<div class="font-preview">Aa 0123 · 中文</div>
</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>
+21 -16
View File
@@ -49,9 +49,10 @@ body.window .lib-head { top: -2px; margin: -2px -16px 0; padding: 8px 16px 4px;
.lib-cardhead .note { font-size: 11px; color: var(--faint); }
.lib-text {
display: block; width: 100%; box-sizing: border-box; resize: none; border: 0; outline: none; background: none; color: var(--fg);
padding: 10px 14px 14px; font: 12.5px/1.6 var(--code); tab-size: 2; min-height: 150px;
padding: 10px 14px 14px; font: var(--code-style, normal) var(--code-weight, 400) 12.5px/1.6 var(--code-font, var(--code)); font-stretch: var(--code-stretch, 100%); tab-size: 2; min-height: 150px;
}
.lib-text::placeholder { color: var(--faint); font-family: var(--font); font-size: 12.5px; }
.lib-text::placeholder { color: var(--faint); font-family: var(--ui-font, var(--font)); font-size: 12.5px; }
:where(html[data-fonts]) .lib-text::placeholder { font-style: var(--ui-style, inherit); font-weight: var(--ui-weight, inherit); font-stretch: var(--ui-stretch, 100%); }
.lib-text.small { min-height: 64px; border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; background: var(--card); transition: border-color .12s, box-shadow .12s; }
.lib-text.small:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); }
@@ -77,9 +78,9 @@ body.window .lib-head { top: -2px; margin: -2px -16px 0; padding: 8px 16px 4px;
.lib-row.with-agents > .who { min-width: min(140px, 32%); }
/* a server's or skill's picture sits level with the top of its name */
.lib-row > .lib-logo, .lib-row > .lib-glyph { align-self: flex-start; margin-top: 1px; }
.lib-row .name.mono, .lib-pick .name.mono { font: 500 12.5px var(--code); }
.lib-row .name.mono, .lib-pick .name.mono { font: var(--code-style, normal) 500 12.5px var(--code-font, var(--code)); font-stretch: var(--code-stretch, 100%); }
.lib-row .sub { max-width: 100%; }
.lib-row .sub.mono, .lib-pick .sub.mono { font: 11px var(--code); color: var(--muted); }
.lib-row .sub.mono, .lib-pick .sub.mono { font: var(--code-style, normal) var(--code-weight, 400) 11px var(--code-font, var(--code)); font-stretch: var(--code-stretch, 100%); color: var(--muted); }
.lib-row .chev { flex: none; color: var(--faint); transition: transform .2s var(--ease); display: grid; place-items: center; }
.lib-row.open .chev { transform: rotate(90deg); }
.lib-row.open { background: var(--card-2); }
@@ -92,11 +93,11 @@ body.window .lib-head { top: -2px; margin: -2px -16px 0; padding: 8px 16px 4px;
them and the row, a fixed height in a long list, lost its name (#257) */
.lib-src > span { flex: none; white-space: nowrap; }
.lib-src > .lib-path, .lib-src > .lib-srclink { flex: 0 1 auto; min-width: 0; }
.lib-srclink { font: 11px var(--code); color: var(--muted); text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lib-srclink { font: var(--code-style, normal) var(--code-weight, 400) 11px var(--code-font, var(--code)); font-stretch: var(--code-stretch, 100%); color: var(--muted); text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lib-srclink:hover { color: var(--fg); text-decoration: underline; text-underline-offset: 2px; }
/* a path: shown in the file manager on a click */
.lib-path { font: 11px var(--code); color: var(--muted); padding: 0; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: left; border-radius: 3px; }
.lib-path { font: var(--code-style, normal) var(--code-weight, 400) 11px var(--code-font, var(--code)); font-stretch: var(--code-stretch, 100%); color: var(--muted); padding: 0; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: left; border-radius: 3px; }
.lib-path:hover { color: var(--fg); text-decoration: underline; text-underline-offset: 2px; }
/* tags beside a name */
@@ -157,7 +158,7 @@ input.lib-setname:focus { border-color: var(--accent); box-shadow: 0 0 0 3px col
.lib-problems ul { list-style: none; margin: 6px 0 0; padding: 0 0 0 15px; display: grid; gap: 4px; }
.lib-problems li { min-width: 0; }
.lib-problems-sep { color: var(--muted); }
.lib-problems-err { display: block; color: var(--fg-2); font: 11px var(--code); margin-top: 1px; }
.lib-problems-err { display: block; color: var(--fg-2); font: var(--code-style, normal) var(--code-weight, 400) 11px var(--code-font, var(--code)); font-stretch: var(--code-stretch, 100%); margin-top: 1px; }
.lib-problems-fix { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 5px; }
/* agents a server or skill is given to: an icon each, lit when it has it */
@@ -302,10 +303,12 @@ input.lib-setname:focus { border-color: var(--accent); box-shadow: 0 0 0 3px col
.lib-editor .ehead .note { font-size: 11.5px; color: var(--faint); margin-left: auto; }
.lib-editor .lib-slot { display: contents; }
.lib-editor .lib-grid { display: contents; }
.lib-editor input.mono { font-family: var(--code); font-size: 12px; }
.lib-editor input.mono { font-family: var(--code-font, var(--code)); font-size: 12px; }
:where(html[data-fonts]) .lib-editor input.mono { font-style: var(--code-style, inherit); font-weight: var(--code-weight, inherit); font-stretch: var(--code-stretch, 100%); }
.lib-pairs { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; }
.lib-pair { display: flex; gap: 6px; width: 100%; align-items: center; }
.lib-pair input { flex: 1; min-width: 0; font-family: var(--code); font-size: 12px; }
.lib-pair input { flex: 1; min-width: 0; font-family: var(--code-font, var(--code)); font-size: 12px; }
:where(html[data-fonts]) .lib-pair input { font-style: var(--code-style, inherit); font-weight: var(--code-weight, inherit); font-stretch: var(--code-stretch, 100%); }
.lib-pair input:first-child { flex: .8; }
.lib-x { width: 24px; height: 24px; padding: 0; display: inline-grid; place-items: center; flex: none; }
.lib-addpair { padding-left: 0; }
@@ -330,7 +333,7 @@ input.lib-setname:focus { border-color: var(--accent); box-shadow: 0 0 0 3px col
.lib-skillhow .note { font-size: 11px; color: var(--faint); line-height: 1.45; min-width: 0; flex: 1 0 100%; margin-top: -4px; }
.lib-how-row .segs { flex: none; align-self: center; }
.lib-reader { grid-template-columns: minmax(0, 1fr); }
.lib-md { margin: 0; max-height: min(60vh, 520px); overflow: auto; font: 12px/1.6 var(--code); color: var(--fg-2); white-space: pre-wrap; overflow-wrap: anywhere; background: var(--card-2); border-radius: 8px; padding: 12px 14px; user-select: text; -webkit-user-select: text; }
.lib-md { margin: 0; max-height: min(60vh, 520px); overflow: auto; font: var(--code-style, normal) var(--code-weight, 400) 12px/1.6 var(--code-font, var(--code)); font-stretch: var(--code-stretch, 100%); color: var(--fg-2); white-space: pre-wrap; overflow-wrap: anywhere; background: var(--card-2); border-radius: 8px; padding: 12px 14px; user-select: text; -webkit-user-select: text; }
.lib-reader .bar .lib-path { max-width: 70%; }
.editor .editor-error:empty { display: none; }
@@ -384,13 +387,14 @@ input.lib-setname:focus { border-color: var(--accent); box-shadow: 0 0 0 3px col
.mk-name.big { font-size: 15px; }
.mk-meta { display: flex; align-items: center; gap: 6px; min-width: 0; font-size: 11px; color: var(--muted); }
.mk-pub { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mk-badge { flex: none; display: inline-flex; align-items: center; gap: 3px; height: 17px; padding: 0 6px; border-radius: 5px; background: var(--pill); color: var(--fg-2); font-size: 10.5px; font-family: var(--code); }
.mk-badge { flex: none; display: inline-flex; align-items: center; gap: 3px; height: 17px; padding: 0 6px; border-radius: 5px; background: var(--pill); color: var(--fg-2); font-size: 10.5px; font-family: var(--code-font, var(--code)); }
:where(html[data-fonts]) .mk-badge { font-style: var(--code-style, inherit); font-weight: var(--code-weight, inherit); font-stretch: var(--code-stretch, 100%); }
.mk-badge.mk-needs { width: 17px; padding: 0; justify-content: center; }
.mk-official { flex: none; display: inline-grid; place-items: center; width: 13px; height: 13px; border-radius: 50%; background: var(--fg); color: var(--card); }
.mk-desc { margin: 0; min-height: 2.9em; font-size: 12px; line-height: 1.45; color: var(--fg-2); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.mk-desc.wait { background: linear-gradient(90deg, var(--pill) 25%, var(--pill-hover) 45%, var(--pill) 65%); background-size: 240% 100%; animation: skeleton-shift 1.4s ease-in-out infinite; border-radius: 5px; min-height: 0; height: 8px; margin: 6px 20% 10px 0; }
.mk-foot { display: flex; align-items: center; gap: 8px; min-height: 26px; margin-top: auto; }
.mk-id { min-width: 0; font: 11px var(--code); color: var(--faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mk-id { min-width: 0; font: var(--code-style, normal) var(--code-weight, 400) 11px var(--code-font, var(--code)); font-stretch: var(--code-stretch, 100%); color: var(--faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mk-installs { display: inline-flex; align-items: center; gap: 3px; font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
.mk-add { flex: none; height: 26px; }
.mk-add.busy { color: var(--muted); }
@@ -409,7 +413,8 @@ input.lib-setname:focus { border-color: var(--accent); box-shadow: 0 0 0 3px col
.mk-sheet .mk-hint { grid-column: 1 / -1; display: flex; align-items: center; gap: 6px; margin: 0; font-size: 11.5px; color: var(--muted); }
.mk-ext { flex: none; display: inline-flex; align-items: center; gap: 4px; font-size: 12px; color: var(--muted); text-decoration: none; padding: 3px 6px; border-radius: 6px; }
.mk-ext:hover { color: var(--fg); background: var(--pill); }
.mk-sheet .mk-hint .mk-ext { padding: 0; font-family: var(--code); font-size: 11.5px; }
.mk-sheet .mk-hint .mk-ext { padding: 0; font-family: var(--code-font, var(--code)); font-size: 11.5px; }
:where(html[data-fonts]) .mk-sheet .mk-hint .mk-ext { font-style: var(--code-style, inherit); font-weight: var(--code-weight, inherit); font-stretch: var(--code-stretch, 100%); }
.mk-sheet .mk-hint .mk-ext:hover { background: none; text-decoration: underline; text-underline-offset: 2px; }
.mk-sheet .bar .mk-id { margin-left: 2px; }
.lib-rtk-gain { margin: 6px 12px 10px; font-size: 12px; line-height: 1.5; color: var(--muted); }
@@ -425,7 +430,7 @@ input.lib-setname:focus { border-color: var(--accent); box-shadow: 0 0 0 3px col
.lib-rtk-key i { width: 8px; height: 8px; border-radius: 2px; background: var(--accent); }
.lib-rtk-key i.in { opacity: .38; }
.lib-cardhead button.text { height: 22px; font-size: 11.5px; }
.lib-rtk-cmd { margin: -4px 12px 10px; font: 11.5px var(--code); color: var(--muted); user-select: text; }
.lib-rtk-cmd { margin: -4px 12px 10px; font: var(--code-style, normal) var(--code-weight, 400) 11.5px var(--code-font, var(--code)); font-stretch: var(--code-stretch, 100%); color: var(--muted); user-select: text; }
/* projects: a card each, its skills shown when it is opened */
.lib-project { margin-bottom: 8px; }
@@ -438,7 +443,7 @@ input.lib-setname:focus { border-color: var(--accent); box-shadow: 0 0 0 3px col
.lib-srvwho > .lib-health { justify-self: start; max-width: min(220px, 100%); }
.lib-srvwho > :not(.name):not(.lib-health) { grid-column: 1 / -1; }
.lib-health { display: inline-flex; flex: 0 1 auto; min-width: 0; align-items: center; gap: 5px; max-width: 220px; height: 17px; margin-left: 7px; padding: 0 6px; border-radius: 6px;
font: 400 11px var(--font); color: var(--muted); white-space: nowrap; transition: background .14s var(--ease); }
font: var(--ui-style, normal) 400 11px var(--ui-font, var(--font)); font-stretch: var(--ui-stretch, 100%); color: var(--muted); white-space: nowrap; transition: background .14s var(--ease); }
.lib-health > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.lib-health[hidden] { display: none; }
.lib-health:hover { background: var(--pill); }
@@ -506,7 +511,7 @@ input.lib-setname:focus { border-color: var(--accent); box-shadow: 0 0 0 3px col
.lib-vl > .row.lib-subhead { height: 36px; min-height: 0; padding: 0 12px 0 14px; gap: 8px; }
.lib-subhead { background: var(--card-2); }
.lib-subhead .name { font-size: 12px; color: var(--fg-2); }
.lib-subhead .name.mono { font: 500 11.5px var(--code); }
.lib-subhead .name.mono { font: var(--code-style, normal) 500 11.5px var(--code-font, var(--code)); font-stretch: var(--code-stretch, 100%); }
.lib-subhead .sub { margin: 0; font-size: 11px; color: var(--faint); font-variant-numeric: tabular-nums; }
.lib-subhead .lib-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); flex: none; }
.lib-row.lib-subhead:hover { background: color-mix(in srgb, var(--card-2) 70%, var(--pill)); }
+2 -1
View File
@@ -14,7 +14,8 @@ html.omarchy .om-hide { display: none !important; }
html.omarchy .om-only { display: revert !important; }
html.omarchy .om-theme-name { color: var(--accent); font-size: 12px; white-space: nowrap; }
html.omarchy, html.omarchy body { font-family: var(--font); font-size: var(--om-size); }
html.omarchy, html.omarchy body { font-family: var(--ui-font, var(--font)); font-size: var(--om-size); }
html:where([data-fonts]).omarchy, html:where([data-fonts]).omarchy body { font-style: var(--ui-style, inherit); font-weight: var(--ui-weight, inherit); font-stretch: var(--ui-stretch, 100%); }
html.omarchy body { -webkit-font-smoothing: antialiased; }
/* square, or rounded as far as Hyprland rounds its windows; what spins or
+4 -4
View File
@@ -69,7 +69,7 @@
}
.pm-meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; min-height: 18px; }
.pm-dl { display: inline-flex; align-items: center; gap: 3px; }
.pm-ver { font: 10.5px var(--code); color: var(--muted); }
.pm-ver { font: var(--code-style, normal) var(--code-weight, 400) 10.5px var(--code-font, var(--code)); font-stretch: var(--code-stretch, 100%); color: var(--muted); }
.pm-chip { display: inline-flex; align-items: center; height: 17px; padding: 0 6px; border-radius: 5px; font-size: 10.5px; font-weight: 500; background: var(--pill); color: var(--fg-2); }
.pm-chip.up { background: var(--accent-soft); color: var(--accent); }
button.pm-chip { border: 0; font-family: inherit; cursor: pointer; }
@@ -123,7 +123,7 @@ button.pm-chip:disabled { cursor: default; opacity: .6; }
/* a package the market doesn't list */
.pm-manual { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-radius: 12px; border: 1px dashed var(--line); flex-wrap: wrap; }
.pm-mlabel { font-size: 12px; color: var(--muted); }
.pm-minput { flex: 1; min-width: 180px; height: 26px; padding: 0 9px; border-radius: 7px; border: 1px solid var(--line); background: var(--card); color: var(--fg); font: 12px var(--code); outline: none; }
.pm-minput { flex: 1; min-width: 180px; height: 26px; padding: 0 9px; border-radius: 7px; border: 1px solid var(--line); background: var(--card); color: var(--fg); font: var(--code-style, normal) var(--code-weight, 400) 12px var(--code-font, var(--code)); font-stretch: var(--code-stretch, 100%); outline: none; }
.pm-minput:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent); }
/* the system's folder picker, beside the field it fills */
.pm-browse { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; flex: none; padding: 0; border-radius: 7px; border: 1px solid var(--line); background: var(--card); color: var(--muted); cursor: pointer; transition: color .14s var(--ease), border-color .14s var(--ease); }
@@ -148,7 +148,7 @@ button.pm-chip:disabled { cursor: default; opacity: .6; }
.pm-opts { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
.pm-opts textarea {
width: 100%; box-sizing: border-box; resize: vertical; padding: 8px 10px; border-radius: 8px;
border: 1px solid var(--line); background: var(--card); color: var(--fg); font: 12px/1.5 var(--code); tab-size: 2; outline: none;
border: 1px solid var(--line); background: var(--card); color: var(--fg); font: var(--code-style, normal) var(--code-weight, 400) 12px/1.5 var(--code-font, var(--code)); font-stretch: var(--code-stretch, 100%); tab-size: 2; outline: none;
}
.pm-opts textarea:focus { border-color: var(--accent); }
.pm-opts-err { font-size: 11.5px; color: var(--red); }
@@ -193,7 +193,7 @@ button.pm-chip:disabled { cursor: default; opacity: .6; }
.pm-md li + li { margin-top: 2px; }
.pm-md a { color: var(--accent); text-decoration: none; }
.pm-md a:hover { text-decoration: underline; }
.pm-md code { font: 11.5px var(--code); padding: 1px 4px; border-radius: 4px; background: var(--pill); color: var(--fg); }
.pm-md code { font: var(--code-style, normal) var(--code-weight, 400) 11.5px var(--code-font, var(--code)); font-stretch: var(--code-stretch, 100%); padding: 1px 4px; border-radius: 4px; background: var(--pill); color: var(--fg); }
.pm-md pre { margin: 0 0 10px; padding: 9px 11px; border-radius: 8px; background: var(--card-2); border: 1px solid var(--line-2); overflow-x: auto; }
.pm-md pre code { padding: 0; background: none; font-size: 11.5px; line-height: 1.35; white-space: pre; } /* close enough for a box drawn in │ and ─ to join */
.pm-md blockquote { margin: 0 0 9px; padding: 7px 11px; border-radius: 8px; background: var(--card-2); color: var(--fg-2); }
+7 -6
View File
@@ -64,7 +64,7 @@
.rt-accts b .ic { display: inline-flex; width: 16px; height: 16px; margin-right: 7px; vertical-align: -3px; }
.rt-accts b .ic img, .rt-accts b .ic svg { width: 16px; height: 16px; }
.rt-accts b .fb { margin-left: 6px; font-size: 10.5px; font-weight: 560; padding: 0 6px; border-radius: 999px; border: 1px solid var(--line); color: var(--muted); }
.rt-accts b code.mdl, .rt-req code, .rt-act code.mdl { font: 500 11px/1.5 var(--code); color: var(--fg-2); background: var(--card-2); border-radius: 5px; padding: 0 5px; }
.rt-accts b code.mdl, .rt-req code, .rt-act code.mdl { font: var(--code-style, normal) 500 11px/1.5 var(--code-font, var(--code)); font-stretch: var(--code-stretch, 100%); color: var(--fg-2); background: var(--card-2); border-radius: 5px; padding: 0 5px; }
.rt-accts b code.mdl { margin-left: 7px; font-weight: 450; }
.rt-accts em { font-style: normal; font-size: 12px; color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
.rt-accts .bar { grid-column: 2 / 4; height: 3px; border-radius: 2px; background: var(--card-2); overflow: hidden; }
@@ -85,7 +85,7 @@
.rt-accts li.hit { animation: rthit .45s; }
.rt-accts li.new { animation: rtin .4s ease-out; }
@keyframes rthit { 20% { translate: -5px; } 40% { translate: 4px; } 60% { translate: -2px; } 80% { translate: 1px; } }
.rt-accts .tag { position: absolute; right: 10px; top: -10px; padding: 1px 7px; border-radius: 6px; background: var(--red); color: #fff; font: 600 10.5px/1.5 var(--code); opacity: 0; transform: translateY(4px) scale(.9); transition: opacity .25s, transform .25s; pointer-events: none; }
.rt-accts .tag { position: absolute; right: 10px; top: -10px; padding: 1px 7px; border-radius: 6px; background: var(--red); color: #fff; font: var(--code-style, normal) 600 10.5px/1.5 var(--code-font, var(--code)); font-stretch: var(--code-stretch, 100%); opacity: 0; transform: translateY(4px) scale(.9); transition: opacity .25s, transform .25s; pointer-events: none; }
.rt-accts li.tagged .tag { opacity: 1; transform: none; }
.rt-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px 20px; flex-wrap: wrap; padding-top: 10px; border-top: 1px solid var(--line); }
.rt-cap { flex: 1 1 20em; margin: 0; min-height: 3em; display: flex; align-items: center; font-size: 13px; line-height: 1.45; color: var(--fg); }
@@ -103,7 +103,8 @@
.rt-steps li.why { color: var(--fg); }
.rt-steps li.why::before { background: var(--fg); }
.rt-steps li.aside { color: var(--muted); }
.rt-steps li.said { font-family: var(--code); font-size: 11.5px; overflow-wrap: anywhere; white-space: pre-wrap; }
.rt-steps li.said { font-family: var(--code-font, var(--code)); font-size: 11.5px; overflow-wrap: anywhere; white-space: pre-wrap; }
:where(html[data-fonts]) .rt-steps li.said { font-style: var(--code-style, inherit); font-weight: var(--code-weight, inherit); font-stretch: var(--code-stretch, 100%); }
.rt-stats > span.rt-errs:not([aria-disabled="true"]) { cursor: pointer; }
.rt-stats > span.rt-errs:not([aria-disabled="true"]):hover b { color: var(--red); }
.rt-steps li.aside::before { background: none; border: 1px solid var(--muted); width: 4px; height: 4px; }
@@ -323,11 +324,11 @@ button.rt-session { cursor: pointer; }
.rt-group .main { flex: 1; min-width: 0; }
.rt-group .nm { display: flex; align-items: center; gap: 8px; min-width: 0; font-size: 13px; }
.rt-group .nm b { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rt-group .nm code.mdl { font: 500 11px/1.5 var(--code); color: var(--fg-2); background: var(--card-2); border-radius: 5px; padding: 0 5px; white-space: nowrap; }
.rt-group .nm code.mdl { font: var(--code-style, normal) 500 11px/1.5 var(--code-font, var(--code)); font-stretch: var(--code-stretch, 100%); color: var(--fg-2); background: var(--card-2); border-radius: 5px; padding: 0 5px; white-space: nowrap; }
.rt-group .nm small.auto { font-size: 11px; color: var(--faint); white-space: nowrap; }
.rt-group .mem { margin-top: 2px; font-size: 11.5px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* a pattern the group finds models by, and how many it matches now */
.rt-group .mem.pat code, .rt-pats code { font: 500 11px/1.5 var(--code); color: var(--fg-2); }
.rt-group .mem.pat code, .rt-pats code { font: var(--code-style, normal) 500 11px/1.5 var(--code-font, var(--code)); font-stretch: var(--code-stretch, 100%); color: var(--fg-2); }
.rt-group .mem.pat.none, .rt-pats .fbrow.none small { color: var(--amber); }
.rt-patadd { display: flex; gap: 6px; align-items: center; margin-top: 4px; }
.rt-patadd input { flex: 1; min-width: 0; }
@@ -575,7 +576,7 @@ button.rt-session { cursor: pointer; }
.pr-a, .pr-to { display: flex; align-items: center; gap: 6px; min-width: 0; }
.pr-a .ic, .pr-a .ic img, .pr-a .ic svg { width: 14px; height: 14px; flex: none; }
.pr-who { flex: none; color: var(--fg); font-weight: 550; }
.pr-a .m { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 500 11px/1.5 var(--code); color: var(--fg-2); background: var(--card-2); border-radius: 5px; padding: 0 5px; }
.pr-a .m { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--code-style, normal) 500 11px/1.5 var(--code-font, var(--code)); font-stretch: var(--code-stretch, 100%); color: var(--fg-2); background: var(--card-2); border-radius: 5px; padding: 0 5px; }
.pr-a .kind { flex: 0 100 auto; min-width: 3.2em; max-width: 8em; overflow: hidden; text-overflow: ellipsis; font-size: 10.5px; line-height: 16px; padding: 0 5px; border-radius: 5px; color: var(--muted); background: var(--card-2); border: 1px solid var(--line); white-space: nowrap; }
.pr-req .at { justify-self: end; font-variant-numeric: tabular-nums; color: var(--muted); font-size: 11px; }
.pr-to { padding-left: 20px; gap: 5px; color: var(--fg-2); }
+6 -3
View File
@@ -31,7 +31,8 @@
.row.sm-folder { min-height: 34px; gap: 8px; }
.row.sm-folder .fold { color: var(--fg-2); font-weight: 500; flex: none; max-width: 45%; }
.row.sm-folder .fold .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.row.sm-folder .sm-path { flex: 0 1 auto; min-width: 0; margin: 0; font-family: var(--code); font-size: 10.5px; color: var(--faint); }
.row.sm-folder .sm-path { flex: 0 1 auto; min-width: 0; margin: 0; font-family: var(--code-font, var(--code)); font-size: 10.5px; color: var(--faint); }
:where(html[data-fonts]) .row.sm-folder .sm-path { font-style: var(--code-style, inherit); font-weight: var(--code-weight, inherit); font-stretch: var(--code-stretch, 100%); }
.row.sm-folder .grow { flex: 1; }
.row.sm-folder .note { flex: none; font-size: 11px; color: var(--faint); font-variant-numeric: tabular-nums; }
.row.sm-sess { min-height: 44px; }
@@ -46,7 +47,8 @@
.sm-ask-list { grid-column: 1 / -1; min-width: 0; margin: 0; padding: 0; list-style: none; font-size: 12.5px; color: var(--fg); line-height: 1.6; }
.sm-ask-list li { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sm-ask-list li.more { color: var(--muted); }
.sm-ask-path { grid-column: 1 / -1; min-width: 0; margin: 0; font-family: var(--code); font-size: 11px; color: var(--muted); overflow-wrap: anywhere; }
.sm-ask-path { grid-column: 1 / -1; min-width: 0; margin: 0; font-family: var(--code-font, var(--code)); font-size: 11px; color: var(--muted); overflow-wrap: anywhere; }
:where(html[data-fonts]) .sm-ask-path { font-style: var(--code-style, inherit); font-weight: var(--code-weight, inherit); font-stretch: var(--code-stretch, 100%); }
@media (max-width: 560px) {
.sm-filter { width: 130px; flex-basis: 130px; }
.row.sm-folder .sm-path { display: none; }
@@ -69,7 +71,8 @@
.sm-provider-text { min-width: 0; font-size: 12px; color: var(--fg-2); line-height: 1.45; }
.sm-provider .grow { flex: 1; }
.sm-provider .text { flex: none; display: inline-flex; align-items: center; gap: 5px; }
.sm-prov { display: inline-block; padding: 0 5px; border-radius: 4px; background: var(--pill); color: var(--muted); font-family: var(--code); font-size: 10.5px; line-height: 16px; }
.sm-prov { display: inline-block; padding: 0 5px; border-radius: 4px; background: var(--pill); color: var(--muted); font-family: var(--code-font, var(--code)); font-size: 10.5px; line-height: 16px; }
:where(html[data-fonts]) .sm-prov { font-style: var(--code-style, inherit); font-weight: var(--code-weight, inherit); font-stretch: var(--code-stretch, 100%); }
.sm-provider-list li label { display: flex; align-items: center; gap: 8px; min-width: 0; cursor: pointer; }
.sm-provider-list li .name { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sm-provider-list li .note { flex: none; font-size: 11px; color: var(--faint); }
+49
View File
@@ -0,0 +1,49 @@
package gui
import (
"encoding/json"
"net/http"
"sync"
"github.com/yetone/magpie/internal/fonts"
"github.com/yetone/magpie/internal/settings"
)
// onFonts updates existing webviews even while the other one has focus.
var onFonts func()
func sameFont(a, b *fonts.Face) bool {
return a == nil && b == nil || a != nil && b != nil && *a == *b
}
func fontSettingsJS(s settings.Settings) string {
b, _ := json.Marshal(map[string]any{"uiFont": s.UIFont, "codeFont": s.CodeFont})
return "window.receiveFonts?.(" + string(b) + ");"
}
// Font discovery belongs to the desktop, never to a remote browser's
// server. A failed refresh leaves the last successful collection intact.
func fontRoutes(mux *http.ServeMux, web bool, discover func() ([]fonts.Family, error)) {
var mu sync.Mutex
var cached []fonts.Family
mux.HandleFunc("GET /api/fonts", func(rw http.ResponseWriter, r *http.Request) {
if web {
http.Error(rw, "installed fonts are available only in the desktop app", http.StatusNotFound)
return
}
mu.Lock()
defer mu.Unlock()
if cached == nil || r.URL.Query().Get("refresh") == "1" {
found, err := discover()
if err != nil {
http.Error(rw, "could not read installed fonts", http.StatusServiceUnavailable)
return
}
cached = found
if cached == nil {
cached = []fonts.Family{}
}
}
writeJSON(rw, cached)
})
}
+22
View File
@@ -0,0 +1,22 @@
//go:build !nogui
package gui
import (
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/yetone/magpie/internal/settings"
)
func (h *host) fontsChanged() {
application.InvokeSync(func() {
// Read the latest saved choice here, so simultaneous saves cannot
// deliver an older notification after a newer one. A released
// lightweight webview will get its choices from boot.js instead.
js := fontSettingsJS(settings.Load())
for _, w := range []*application.WebviewWindow{h.main, h.panel} {
if w != nil {
w.ExecJS(js)
}
}
})
}
+127
View File
@@ -0,0 +1,127 @@
package gui
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"github.com/yetone/magpie/internal/fonts"
"github.com/yetone/magpie/internal/settings"
"github.com/yetone/magpie/internal/testenv"
)
func TestFontDiscoveryCacheAndFailure(t *testing.T) {
face := fonts.Face{Family: "HarmonyOS Sans SC", Name: "Medium", Weight: 500, Style: "normal", Stretch: 100}
want := []fonts.Family{{Name: face.Family, Styles: []fonts.Face{face}}}
calls, broken := 0, false
mux := http.NewServeMux()
fontRoutes(mux, false, func() ([]fonts.Family, error) {
calls++
if broken {
return nil, errors.New("font service unavailable")
}
return want, nil
})
read := func(url string, status int) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRecorder()
mux.ServeHTTP(r, httptest.NewRequest("GET", url, nil))
if r.Code != status {
t.Fatalf("%s: %d %s", url, r.Code, r.Body)
}
return r
}
r := read("/api/fonts", 200)
var got []fonts.Family
if err := json.Unmarshal(r.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("catalogue = %#v", got)
}
read("/api/fonts", 200)
if calls != 1 {
t.Fatalf("cached list rediscovered %d times", calls)
}
broken = true
read("/api/fonts?refresh=1", 503)
if r = read("/api/fonts", 200); !strings.Contains(r.Body.String(), face.Family) {
t.Fatal("failure erased the cached list")
}
broken = false
read("/api/fonts?refresh=1", 200)
if calls != 3 {
t.Fatalf("refresh did not re-read fonts: %d", calls)
}
web := http.NewServeMux()
fontRoutes(web, true, func() ([]fonts.Family, error) { t.Fatal("browser enumerated server fonts"); return nil, nil })
r = httptest.NewRecorder()
web.ServeHTTP(r, httptest.NewRequest("GET", "/api/fonts", nil))
if r.Code != 404 {
t.Fatalf("web font discovery returned %d", r.Code)
}
}
func TestFontSettingsAPIAndBoot(t *testing.T) {
testenv.SetHome(t, t.TempDir())
notices := 0
onFonts = func() { notices++ }
t.Cleanup(func() { onFonts = nil })
h := Handler(nil, nil)
face := &fonts.Face{Family: `Reader's "中文"`, Name: "Medium", Weight: 500, Style: "normal", Stretch: 100}
post := func(handler http.Handler, body any, status int) {
t.Helper()
b, _ := json.Marshal(body)
r := httptest.NewRecorder()
handler.ServeHTTP(r, httptest.NewRequest("POST", "/api/settings", strings.NewReader(string(b))))
if r.Code != status {
t.Fatalf("settings returned %d: %s", r.Code, r.Body)
}
}
post(h, map[string]any{"uiFont": face, "codeFont": face}, 200)
post(h, map[string]any{"theme": "dark", "lang": "zh"}, 200)
if notices != 1 {
t.Fatalf("font change notifications = %d, want 1", notices)
}
if !reflect.DeepEqual(settings.Load().UIFont, face) || !reflect.DeepEqual(settings.Load().CodeFont, face) {
t.Fatal("an unrelated settings save lost the fonts")
}
boot := func(handler http.Handler) map[string]json.RawMessage {
t.Helper()
r := httptest.NewRecorder()
handler.ServeHTTP(r, httptest.NewRequest("GET", "/boot.js", nil))
body := strings.TrimSuffix(strings.TrimPrefix(r.Body.String(), "window.bootPrefs = "), ";\n")
var got map[string]json.RawMessage
if err := json.Unmarshal([]byte(body), &got); err != nil {
t.Fatal(err)
}
return got
}
var first fonts.Face
if err := json.Unmarshal(boot(h)["uiFont"], &first); err != nil || !reflect.DeepEqual(&first, face) {
t.Fatalf("first paint font = %#v: %v", first, err)
}
web := Handler(webHost{}, nil)
post(web, map[string]any{"uiFont": nil, "codeFont": nil}, 200)
if !reflect.DeepEqual(settings.Load().UIFont, face) {
t.Fatal("browser save replaced a desktop font")
}
if _, ok := boot(web)["uiFont"]; ok {
t.Fatal("browser received a desktop font override")
}
post(h, map[string]any{"uiFont": map[string]any{"family": "bad", "name": "Regular", "weight": 1001}}, 400)
if !reflect.DeepEqual(settings.Load().UIFont, face) {
t.Fatal("invalid choice replaced the saved font")
}
post(h, map[string]any{"uiFont": nil}, 200)
if notices != 2 {
t.Fatalf("only successful font changes should notify: %d", notices)
}
if settings.Load().UIFont != nil || !reflect.DeepEqual(settings.Load().CodeFont, face) {
t.Fatal("reset did not affect just the interface font")
}
}
+24
View File
@@ -1,5 +1,29 @@
# Dropdown browser regression
## Installed desktop fonts
`fonts.test.cjs` exercises the interface/code font controls with installed
family/style fixtures: independent traits, literal names, search and keyboard
selection, rapid queued saves and rollback, reload, unavailable faces,
discovery failure and refresh, empty collections, platform fallback,
Omarchy precedence and browser-mode isolation. It checks narrow windows and
no scroll on click in Chromium and WebKit, in English and Chinese.
`ARTIFACT_DIR` retains screenshots; `MAGPIE_FONT_ASSETS` can point to an older
asset directory to verify that the test fails on the missing picker.
`font-cascade.test.cjs` checks that a reset restores the original weights
under bold parents, while explicit component emphasis survives a choice.
```sh
node --test internal/gui/tests/fonts.test.cjs internal/gui/tests/font-cascade.test.cjs internal/gui/tests/win-fonts.test.cjs
go test -v ./internal/fonts
go test -tags nogui ./internal/settings ./internal/gui -run '^TestFont'
```
The native test reads the operating system's actual installed font metadata.
Run `go test -v ./internal/fonts` without `nogui` on each desktop platform.
Browser fixtures never read or write user settings, install fonts or contact
a live gateway.
## Gateway Caller Keys
`gateway-caller-keys.test.cjs` checks the named caller-key list on the
+45
View File
@@ -0,0 +1,45 @@
// Defaults keep the authored cascade, including a code span under bold UI.
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 choice = (family, weight, style) => ({ family, name: "Chosen", weight, style, stretch: 100 });
for (const [name, engine] of [["chromium", chromium], ["webkit", webkit]]) {
test(`${name}: font reset restores the original cascade and explicit emphasis`, async () => {
const css = (await Promise.all(["app.css", "library.css", "routing.css", "sessions.css", "plugins.css"].map(f => fs.readFile(path.join(assets, f), "utf8")))).join("\n");
const js = await fs.readFile(path.join(assets, "fonts.js"), "utf8");
const browser = await engine.launch();
try {
const page = await browser.newPage();
await page.setContent(`<style>${css}</style>
<div class="sess-line"><b><code id="code">model-id</code></b></div>
<b><span class="key none" id="ui">No key</span></b>
<div class="acc"><input id="emphasis" class="rename mono" value="Account"></div>
<span id="badge" class="mk-badge">Code badge</span>`);
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(); }
});
}
+292
View File
@@ -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(); }
});
}
+9
View File
@@ -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();
+45
View File
@@ -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")
}
}
+11
View File
@@ -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)
}