diff --git a/cmd/webview/main_webview.go b/cmd/webview/main_webview.go index fe5ba75d..4f949575 100644 --- a/cmd/webview/main_webview.go +++ b/cmd/webview/main_webview.go @@ -138,17 +138,23 @@ func run(dataDir string) error { }() w.SetTitle(appName) - // The page otherwise cannot distinguish the small native WebKit shell from a - // browser tab. Terminal renderer selection uses this marker to avoid WebGL's - // delayed frame presentation in the Linux WebKitGTK/X11 path. - w.Init(fmt.Sprintf("window.__chartrNativePlatform=%q;", runtime.GOOS)) w.SetSize(1280, 840, webview.HintNone) // Below this the cockpit's own panes (sidebar, terminal, docked star-map) // can no longer all keep their individual min-widths — the layout starts // squeezing rather than the window scrolling, so the platform enforces the // floor instead of the CSS. w.SetSize(800, 500, webview.HintMin) - installNativeMenu(appName) + initialPageZoom := installNativeMenu(appName, w.Window()) + // The page otherwise cannot distinguish the small native shell from a + // browser tab. Terminal renderer selection uses the platform marker to avoid + // WebGL's delayed frame presentation in the Linux WebKitGTK/X11 path. macOS + // also receives the restored native page zoom at document start, before any + // title-bar geometry is measured; the navigation delegate publishes the same + // value again after the page finishes loading. + w.Init(fmt.Sprintf( + "window.__chartrNativePlatform=%q;window.__chartrPageZoom=%.17g;", + runtime.GOOS, initialPageZoom, + )) // After the window, because the NSApplication it dresses is created with it, // and because the Linux path needs the GtkWindow that newWindow() just made. applyAppIcon(w) diff --git a/cmd/webview/menu_darwin.go b/cmd/webview/menu_darwin.go index 4c8570e9..4d5e0b10 100644 --- a/cmd/webview/menu_darwin.go +++ b/cmd/webview/menu_darwin.go @@ -4,14 +4,143 @@ package main /* #cgo CFLAGS: -x objective-c -Wno-deprecated-declarations -#cgo LDFLAGS: -framework Cocoa +#cgo LDFLAGS: -framework Cocoa -framework WebKit #import +#import +#include -// wfItem appends one menu item. Every action here is a standard responder-chain -// selector with a target of nil, which is why the menu needs no callback into -// Go: NSApplication handles the app items, and WKWebView — the first responder -// inside the window — handles reload: and the edit items itself. +static NSString *const WFPageZoomDefaultsKey = @"chartr.pageZoom.v1"; +static const CGFloat WFPageZoomDefault = 1.0; +static const CGFloat WFPageZoomMinimum = 0.5; +static const CGFloat WFPageZoomMaximum = 3.0; +static const CGFloat WFPageZoomStep = 1.2; + +// WFPageZoomController owns the native View-menu actions and is also the +// WKWebView's navigation delegate. Keeping those roles together makes one zoom +// value authoritative across menu validation, reloads and page notifications. +// WKWebView keeps its navigation delegate weakly, so the process-global pointer +// below deliberately retains this controller for the window's lifetime. +@interface WFPageZoomController : NSObject +@property(nonatomic, assign) WKWebView *webView; +@property(nonatomic) CGFloat desiredZoom; +- (instancetype)initWithWebView:(WKWebView *)webView; +- (void)zoomIn:(id)sender; +- (void)zoomOut:(id)sender; +- (void)actualSize:(id)sender; +@end + +static WFPageZoomController *gWFPageZoomController = nil; + +static BOOL wfReadPageZoom(id value, CGFloat *result) { + if (![value isKindOfClass:[NSNumber class]]) { + return NO; + } + double zoom = [(NSNumber *)value doubleValue]; + if (!isfinite(zoom) || zoom < WFPageZoomMinimum || + zoom > WFPageZoomMaximum) { + return NO; + } + *result = (CGFloat)zoom; + return YES; +} + +@implementation WFPageZoomController + +- (instancetype)initWithWebView:(WKWebView *)webView { + self = [super init]; + if (self == nil) { + return nil; + } + + self.webView = webView; + NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; + id stored = [defaults objectForKey:WFPageZoomDefaultsKey]; + CGFloat zoom = WFPageZoomDefault; + if (stored != nil && !wfReadPageZoom(stored, &zoom)) { + // A corrupt or obsolete preference must not make the cockpit unreadable. + // Removing it also prevents every subsequent launch repeating the repair. + [defaults removeObjectForKey:WFPageZoomDefaultsKey]; + zoom = WFPageZoomDefault; + } + self.desiredZoom = zoom; + [self.webView setPageZoom:zoom]; + return self; +} + +- (void)publishPageZoom { + if (self.webView == nil) { + return; + } + NSString *script = [NSString stringWithFormat: + @"window.__chartrPageZoom=%.17g;window.dispatchEvent(new CustomEvent('chartr:page-zoom',{detail:%.17g}));", + (double)self.desiredZoom, (double)self.desiredZoom]; + [self.webView evaluateJavaScript:script completionHandler:nil]; +} + +- (void)applyPageZoom:(CGFloat)zoom persist:(BOOL)persist publish:(BOOL)publish { + if (!isfinite((double)zoom)) { + zoom = WFPageZoomDefault; + } + zoom = MIN(WFPageZoomMaximum, MAX(WFPageZoomMinimum, zoom)); + self.desiredZoom = zoom; + [self.webView setPageZoom:zoom]; + if (persist) { + [[NSUserDefaults standardUserDefaults] setDouble:zoom + forKey:WFPageZoomDefaultsKey]; + } + if (publish) { + [self publishPageZoom]; + } +} + +- (void)zoomIn:(id)sender { + [self applyPageZoom:self.desiredZoom * WFPageZoomStep + persist:YES + publish:YES]; +} + +- (void)zoomOut:(id)sender { + [self applyPageZoom:self.desiredZoom / WFPageZoomStep + persist:YES + publish:YES]; +} + +- (void)actualSize:(id)sender { + [self applyPageZoom:WFPageZoomDefault persist:YES publish:YES]; +} + +- (BOOL)validateMenuItem:(NSMenuItem *)item { + if (self.webView == nil) { + return NO; + } + SEL action = [item action]; + if (action == @selector(zoomIn:)) { + return self.desiredZoom < WFPageZoomMaximum; + } + if (action == @selector(zoomOut:)) { + return self.desiredZoom > WFPageZoomMinimum; + } + if (action == @selector(actualSize:)) { + return self.desiredZoom != WFPageZoomDefault; + } + return YES; +} + +- (void)webView:(WKWebView *)webView + didFinishNavigation:(WKNavigation *)navigation { + // WebKit normally carries pageZoom through a reload. Re-applying it here also + // covers a future navigation that replaces the page, then tells that document + // the authoritative native value after its own listeners have been installed. + self.webView = webView; + [self applyPageZoom:self.desiredZoom persist:NO publish:YES]; +} + +@end + +// wfItem appends one menu item. Most callers leave the target nil so AppKit or +// WKWebView handles its standard responder-chain selector; the zoom items set +// their retained native controller as an explicit target after creation. static NSMenuItem *wfItem(NSMenu *menu, NSString *title, SEL action, NSString *key, NSUInteger mask) { NSMenuItem *item = [menu addItemWithTitle:title action:action keyEquivalent:key]; [item setKeyEquivalentModifierMask:mask]; @@ -26,11 +155,25 @@ static NSMenu *wfSubmenu(NSMenu *bar, NSString *title) { } // wfInstallMenu gives the bare webview window back the OS affordances a browser -// tab had for free: Quit, Reload, and the edit items. Deliberately minimal — -// ADR 0013 declines a dock badge and a URL scheme, and this menu is the whole -// of the shell's native integration beyond the window itself. -static void wfInstallMenu(const char *cname) { +// tab had for free: Quit, whole-page zoom, Reload, and the edit items. The return +// value is the restored zoom factor so Go can seed the same value into the page +// at document start, before the navigation delegate publishes its ready event. +static double wfInstallMenu(const char *cname, void *ptr) { NSString *name = [NSString stringWithUTF8String:cname]; + NSWindow *win = (NSWindow *)ptr; + WKWebView *webView = nil; + if ([[win contentView] isKindOfClass:[WKWebView class]]) { + webView = (WKWebView *)[win contentView]; + } + + if (gWFPageZoomController != nil) { + [[gWFPageZoomController webView] setNavigationDelegate:nil]; + [gWFPageZoomController release]; + } + gWFPageZoomController = + [[WFPageZoomController alloc] initWithWebView:webView]; + [webView setNavigationDelegate:gWFPageZoomController]; + NSApplication *app = [NSApplication sharedApplication]; NSMenu *bar = [[NSMenu alloc] init]; @@ -58,9 +201,21 @@ static void wfInstallMenu(const char *cname) { wfItem(editMenu, @"Select All", @selector(selectAll:), @"a", NSEventModifierFlagCommand); NSMenu *viewMenu = wfSubmenu(bar, @"View"); + NSMenuItem *zoomIn = wfItem(viewMenu, @"Zoom In", @selector(zoomIn:), @"+", + NSEventModifierFlagCommand); + [zoomIn setTarget:gWFPageZoomController]; + NSMenuItem *zoomOut = wfItem(viewMenu, @"Zoom Out", @selector(zoomOut:), @"-", + NSEventModifierFlagCommand); + [zoomOut setTarget:gWFPageZoomController]; + NSMenuItem *actualSize = wfItem(viewMenu, @"Actual Size", + @selector(actualSize:), @"0", + NSEventModifierFlagCommand); + [actualSize setTarget:gWFPageZoomController]; + [viewMenu addItem:[NSMenuItem separatorItem]]; wfItem(viewMenu, @"Reload", @selector(reload:), @"r", NSEventModifierFlagCommand); [app setMainMenu:bar]; + return (double)[gWFPageZoomController desiredZoom]; } // wfSetAppName names the process. Launched loose the shell is a bare binary with @@ -110,10 +265,10 @@ func setAppName(name string) { C.wfSetAppName(cname) } -func installNativeMenu(name string) { +func installNativeMenu(name string, window unsafe.Pointer) float64 { cname := C.CString(name) defer C.free(unsafe.Pointer(cname)) - C.wfInstallMenu(cname) + return float64(C.wfInstallMenu(cname, window)) } // raiseInstance brings the already-running shell forward. Reporting false is diff --git a/cmd/webview/menu_linux.go b/cmd/webview/menu_linux.go index 355940b1..c55668d7 100644 --- a/cmd/webview/menu_linux.go +++ b/cmd/webview/menu_linux.go @@ -30,6 +30,6 @@ func setAppName(name string) { C.wfSetAppName(cname) } -func installNativeMenu(string) {} +func installNativeMenu(string, unsafe.Pointer) float64 { return 1 } func raiseInstance(int) bool { return false } diff --git a/cmd/webview/menu_other.go b/cmd/webview/menu_other.go index b75f37ed..fd30f6c3 100644 --- a/cmd/webview/menu_other.go +++ b/cmd/webview/menu_other.go @@ -2,7 +2,10 @@ package main -import "runtime" +import ( + "runtime" + "unsafe" +) // missingRuntime names what a failed window creation means on this platform. // The whole point of naming it is that a missing dependency is never papered @@ -20,7 +23,7 @@ func setAppName(string) {} // bare window losing the browser's menu bar; GTK and Win32 windows keep their // own window controls, and inventing a menu bar for them is not this ticket's // work (ADR 0013). -func installNativeMenu(string) {} +func installNativeMenu(string, unsafe.Pointer) float64 { return 1 } // raiseInstance always reports false here: raising another process's window is // exactly the "flaky" case the spec names, so these platforms take the diff --git a/cmd/webview/titlebar_darwin.go b/cmd/webview/titlebar_darwin.go index 623c8d1b..d9745d62 100644 --- a/cmd/webview/titlebar_darwin.go +++ b/cmd/webview/titlebar_darwin.go @@ -86,9 +86,11 @@ static WFDragView *gWFDragView = nil; @end -// Replace the drag view's passthrough list from CSS-pixel rectangles measured -// from the viewport's top-left. NSView coordinates start at the bottom-left, so -// each y value is flipped inside the title strip as it is copied. +// Replace the drag view's passthrough list from AppKit-point rectangles measured +// from the viewport's top-left. The page has already applied WKWebView.pageZoom +// to its DOM rectangles before crossing the binding, so scaling them again here +// would double the offset. NSView coordinates start at the bottom-left, so each +// y value is flipped inside the title strip as it is copied. static void wfSetTitleBarButtonRects(double *values, int count) { WFDragView *drag = gWFDragView; if (drag == nil) { @@ -202,16 +204,17 @@ import ( webview "github.com/webview/webview_go" ) -// installTitleBar removes the native title bar and reports the height, in CSS -// pixels, of the strip the cockpit must fill in its place. Zero means the window -// keeps its native title bar and the cockpit renders no bar of its own. +// installTitleBar removes the native title bar and reports the height, in AppKit +// points, of the strip the cockpit must fill in its place. The page converts that +// fixed native height through the live page zoom. Zero means the window keeps its +// native title bar and the cockpit renders no bar of its own. func installTitleBar(w webview.WebView) int { h := float64(C.wfInstallTitleBar(unsafe.Pointer(w.Window()))) if h <= 0 { return 0 } - // Points are CSS pixels on macOS whatever the backing scale, so this rounds - // rather than converts. + // The JS seam takes an integral native height and owns page-zoom conversion, + // so this rounds rather than changing coordinate systems here. return int(math.Round(h)) } diff --git a/cmd/webview/titlebar_regions.go b/cmd/webview/titlebar_regions.go index ddd5ce0d..8817b18f 100644 --- a/cmd/webview/titlebar_regions.go +++ b/cmd/webview/titlebar_regions.go @@ -3,9 +3,9 @@ package main // titleBarButtonRect is one live clickable rectangle in the page's top strip, -// measured in CSS pixels from the viewport's top-left corner. The page reports -// these whenever its header changes; the macOS drag overlay uses them as its -// exact passthrough regions. +// measured in AppKit points from the viewport's top-left corner. The page scales +// its CSS-pixel DOM rectangle by the live WKWebView page zoom before reporting +// it; the macOS drag overlay uses the result as its exact passthrough region. type titleBarButtonRect struct { X float64 `json:"x"` Y float64 `json:"y"` diff --git a/docs/adr/0013-webview-shell-architecture.md b/docs/adr/0013-webview-shell-architecture.md index b6ce45e7..d2d9d60b 100644 --- a/docs/adr/0013-webview-shell-architecture.md +++ b/docs/adr/0013-webview-shell-architecture.md @@ -8,7 +8,7 @@ The package is **split by build tag** so the cgo is invisible to every default b **Single-instance is a lock file, not a window handle.** `/.chartr/shell.lock` records the live instance's pid and loopback URL; the claim is an `O_EXCL` create. Keying it to the data dir is what makes distinct `--data-dir` roots distinct instances *by construction* — there is no global lock to contend for. A second launch raises the running window and exits 0; where raising is not possible it refuses with the running URL rather than pretend (the operator can still open that URL in a browser). **The mechanism is a pid, not `webview.Window()`**: the planning map named the native handle, but a second launch is a *different process*, and a window handle is meaningless across the process boundary. macOS raises via `NSRunningApplication`; Linux and Windows take the refuse-with-message path. A lock whose pid is dead is **stale and taken over** — `⌘Q` routes through AppKit's `terminate:` and runs no deferred cleanup, so a lock outliving its process is the normal case, not the exceptional one, and it must never lock the operator out of their own cockpit. -**Native integration is a window, a dock icon, and a minimal menu** — Quit (⌘Q), Reload (⌘R), and the standard edit items, every one of them a responder-chain selector so the menu needs no callback into Go: `NSApplication` answers the app items and `WKWebView` answers `reload:` and the edit items itself. The menu is macOS-only; GTK and Win32 windows keep their own controls. Because a bare binary is not a `.app` bundle, the shell seeds `CFBundleName` before `NSApplication` exists, so the menu bar reads `chartr` rather than the executable's name. **Declined**: a dock badge for the "Needs you" queue, and a `chartr://` URL scheme — each returns only on a concrete trigger. +**Native integration is a window, a dock icon, and a minimal menu** — Quit (`⌘Q`), whole-page Zoom In/Out/Actual Size (`⌘+`, `⌘−`, and `⌘0`), Reload (`⌘R`), and the standard edit items. App and edit commands remain responder-chain selectors; a retained macOS controller owns zoom, persists it in `NSUserDefaults`, applies it through `WKWebView.pageZoom`, validates its menu bounds, and republishes it to each loaded page for zoom-aware native-title-bar hit testing. The menu is macOS-only; GTK and Win32 windows keep their own controls and stay at 100%. Because a bare binary is not a `.app` bundle, the shell seeds `CFBundleName` before `NSApplication` exists, so the menu bar reads `chartr` rather than the executable's name. **Declined**: a dock badge for the "Needs you" queue, and a `chartr://` URL scheme — each returns only on a concrete trigger. **A missing native runtime is a hard error** that names what is missing and points at `chartr`. `webview_create` returns NULL on failure and the Go wrapper hands that NULL back inside a non-nil interface, so the shell reads the wrapper's handle field directly to detect it — the one unsafe corner, confined to one function. There is **no auto browser fallback and no `--browser`/`--shell` force flag**: the two binaries *are* the choice, and neither impersonates the other. diff --git a/web/src/App.svelte b/web/src/App.svelte index e9bd4183..4c538518 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -37,7 +37,10 @@ import { forgetSpace } from "./lib/mapstate"; import { hasNativeTitleBar, + nativePageZoom, nativeTitleBarHeight, + nativeTitleBarLayout, + trackPageZoom, trackTitleBarButtons, } from "./lib/titlebar"; import { visibleSpaces } from "./lib/spacevisibility"; @@ -61,6 +64,13 @@ // browser tab entirely. const titleBarH = nativeTitleBarHeight(); + // WKWebView page zoom is native state, mirrored into the page before boot and + // after every menu command. Keeping this one reactive copy compensates the + // custom top strip for that scaling (subject to the cockpit's 40px tier + // minimum) and keeps the real traffic-light buttons' clearance constant. + let pageZoom = $state(nativePageZoom()); + const titleBarLayout = $derived(nativeTitleBarLayout(titleBarH, pageZoom)); + // The other half of that same question: a window whose title bar stayed the // OS's (the Linux and Windows shells). There the window already carries the // application's name above the page, so the sidebar drops its wordmark and @@ -99,6 +109,7 @@ onMount(() => { control.connect(); + const stopTrackingPageZoom = trackPageZoom((zoom) => (pageZoom = zoom)); const stopTrackingTitleBarButtons = trackTitleBarButtons(titleBarH); // A deep link names its space (#s=&…); select it up front so the linked // star seats as soon as the space arrives over the socket (ticket 07). The @@ -110,6 +121,7 @@ return () => { window.removeEventListener("hashchange", onHash); stopTrackingTitleBarButtons(); + stopTrackingPageZoom(); control.close(); }; }); @@ -632,7 +644,7 @@