diff --git a/internal/gui/api.go b/internal/gui/api.go index 4847759c..217faa9c 100644 --- a/internal/gui/api.go +++ b/internal/gui/api.go @@ -40,6 +40,10 @@ type Windows interface { OpenFolder(path string) // FitPanel asks for the panel to be tall enough for its content. FitPanel(height int, g Glide) + // TintPanel paints the panel's tint behind the page, where the system + // keeps up with the panel's size; false when it can't, for the page to + // go on painting it itself. + TintPanel(rgba [4]uint8, ms int) bool } type fieldJSON struct { @@ -205,6 +209,11 @@ func Handler(w Windows, gw *gateway.Server) http.Handler { if h, g, ok := parseFit(r.URL.Query()); ok { w.FitPanel(h, g) } + case "tint": + if c, ms, ok := parseTint(r.URL.Query()); ok && w.TintPanel(c, ms) { + writeJSON(rw, map[string]bool{"ok": true}) + return + } } rw.WriteHeader(http.StatusNoContent) }) diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index 7fb74ca8..ac3dc527 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -83,7 +83,11 @@ body { cursor: default; } /* The tray panel sits on a frosted NSVisualEffectView, so its own paint is thin. */ -body.panel { background: color-mix(in srgb, var(--bg) 93%, transparent); border-radius: 12px; } +body.panel { --panel-tint: color-mix(in srgb, var(--bg) 93%, transparent); background: var(--panel-tint); border-radius: 12px; } +/* on the Mac the panel's colour is painted under the page, where it keeps up + with the panel as it grows (see tintPanel) */ +body.panel.tinted { background: transparent; } +.tint-probe { display: none; background: var(--panel-tint); } /* on the frosted panel the surfaces are tints of the backdrop, not flat paint */ @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) body.panel { --bg: #1b1c20; --card: #25262c; --card-2: #212227; --line: rgba(255, 255, 255, .07); --line-2: rgba(255, 255, 255, .06); --pill: #2e2f36; --pill-hover: #383942; --pill-line: rgba(255, 255, 255, .11); --pill-shadow: 0 1px 2px rgba(0, 0, 0, .28); --muted: #9c9ca6; --faint: #7c7c87; --card-edge: inset 0 0 0 1px rgba(255, 255, 255, .06); } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 1d9ae6ac..8067e421 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -266,6 +266,40 @@ function tierMenu(a) { const UNROLL = { ms: 620, ease: ".22,1,.36,1" }; const ROLLUP = { ms: 420, ease: ".4,0,.2,1" }; +// tintPanel hands the colour the panel's page shows to the system, to paint +// under the page, which then leaves its own background clear: the page is +// drawn a frame or two after the panel grows, and what shows at the new edge +// meanwhile is that colour, not a dark band. ms is how long a change of theme +// fades. +async function tintPanel(ms = 0) { + if (mode !== "panel") return; + const probe = tintPanel.probe || (tintPanel.probe = document.body.appendChild(el("div", "tint-probe"))); + const c = document.createElement("canvas").getContext("2d", { willReadFrequently: true }); + c.fillStyle = "#010203"; + const none = c.fillStyle; + c.fillStyle = getComputedStyle(probe).backgroundColor; + if (c.fillStyle === none) return; // a colour the canvas can't read + // what the page shows: its thin paint over the webview's white + const paint = c.fillStyle; + c.fillStyle = "#fff"; + c.fillRect(0, 0, 1, 1); + c.fillStyle = paint; + c.fillRect(0, 0, 1, 1); + const rgba = [...c.getImageData(0, 0, 1, 1).data].join(","); + if (rgba === tintPanel.last) return; + tintPanel.last = rgba; + try { + const r = await api("window/tint?c=" + rgba + "&ms=" + ms, {}); + document.body.classList.toggle("tinted", !!r?.ok); + } catch { + tintPanel.last = null; + document.body.classList.remove("tinted"); + } +} +if (mode === "panel") { + matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => tintPanel(450)); +} + // extra is room about to be taken (or given back), e.g. by agents unrolling; // glide moves the panel's edge there over time instead of at once. function fit(extra = 0, glide) { @@ -282,6 +316,7 @@ async function load() { try { state = await api("state"); applyPrefs(state.settings); + tintPanel(); renderAgents(); // an open provider editor is someone typing: coming back to the window // must not rebuild it under them @@ -2999,6 +3034,7 @@ function applyPrefs(s) { applyPrefs.t = setTimeout(() => root.classList.remove("theming"), 450); } if (want) root.dataset.theme = want; else delete root.dataset.theme; + if (applyPrefs.ready) tintPanel(450); } } applyPrefs.ready = true; diff --git a/internal/gui/dev_on.go b/internal/gui/dev_on.go index 526769e2..ff638c82 100644 --- a/internal/gui/dev_on.go +++ b/internal/gui/dev_on.go @@ -5,6 +5,7 @@ package gui import ( "bytes" "context" + "fmt" "io" "io/fs" "log" @@ -144,13 +145,16 @@ type remoteWindows string // the shell's control address func (c remoteWindows) do(op, arg string) { c.post(op, url.Values{"arg": {arg}}) } -func (c remoteWindows) post(op string, form url.Values) { +// post says what the shell answered; a shell from before an op answers nothing. +func (c remoteWindows) post(op string, form url.Values) string { res, err := backendClient.PostForm("http://"+string(c)+"/"+op, form) if err != nil { log.Println("dev shell:", err) - return + return "" } - res.Body.Close() + defer res.Body.Close() + b, _ := io.ReadAll(io.LimitReader(res.Body, 64)) + return string(b) } func (c remoteWindows) HidePanel() { c.do("hide", "") } @@ -162,6 +166,10 @@ func (c remoteWindows) OpenFolder(path string) { c.do("reveal", path) } func (c remoteWindows) FitPanel(height int, g Glide) { c.post("fit", url.Values{"arg": {strconv.Itoa(height)}, "ms": {strconv.Itoa(g.MS)}, "ease": {g.ease()}}) } +func (c remoteWindows) TintPanel(rgba [4]uint8, ms int) bool { + q := url.Values{"c": {fmt.Sprintf("%d,%d,%d,%d", rgba[0], rgba[1], rgba[2], rgba[3])}, "ms": {strconv.Itoa(ms)}} + return c.post("tint", url.Values{"arg": {q.Encode()}}) == "ok" +} // backendClient waits out a backend's restart: a request made while the old // one is gone connects to the new one once it listens. @@ -209,6 +217,12 @@ func devShell(h *host) http.Handler { if n, g, ok := parseFit(q); ok { h.FitPanel(n, g) } + case "tint": + q, _ := url.ParseQuery(arg) + if c, ms, ok := parseTint(q); ok && h.TintPanel(c, ms) { + rw.Write([]byte("ok")) + return + } } rw.WriteHeader(http.StatusNoContent) }) diff --git a/internal/gui/glide.go b/internal/gui/glide.go index e5c7519c..6b2c276b 100644 --- a/internal/gui/glide.go +++ b/internal/gui/glide.go @@ -103,3 +103,21 @@ func (g Glide) ease() string { } return strings.Join(s, ",") } + +// parseTint reads a tint request: c as "r,g,b,a" (0…255 each) and ms, how +// long the change of colour takes. +func parseTint(q url.Values) (c [4]uint8, ms int, ok bool) { + parts := strings.Split(q.Get("c"), ",") + if len(parts) != 4 { + return c, 0, false + } + for i, p := range parts { + v, err := strconv.Atoi(strings.TrimSpace(p)) + if err != nil || v < 0 || v > 255 { + return c, 0, false + } + c[i] = uint8(v) + } + ms, _ = strconv.Atoi(q.Get("ms")) + return c, max(0, min(2000, ms)), true +} diff --git a/internal/gui/glide_darwin.go b/internal/gui/glide_darwin.go index caba90e7..d5b9f20f 100644 --- a/internal/gui/glide_darwin.go +++ b/internal/gui/glide_darwin.go @@ -21,6 +21,61 @@ static void glidePanel(void *w, int height, int ms, double x1, double y1, double } completionHandler:nil]; }); } + +// The page is drawn a frame or two after the panel grows, and until then +// WebKit fills the new edge with its own dark background. So the webview +// draws no background of its own (a key WebKit has long honoured, though not +// a public one) and the page's colour is painted in a view under it, which the +// system sizes along with the panel: the new edge shows that colour until the +// page catches up. Says 0 when the webview can't be made to draw none, and +// the page keeps painting its own. +static int tintPanel(void *w, int r, int g, int b, int a, int ms) { + NSWindow *win = (NSWindow *)w; + if (![win respondsToSelector:@selector(webView)]) return 0; + __block int ok = 0; + dispatch_sync(dispatch_get_main_queue(), ^{ + NSView *web = [win performSelector:@selector(webView)]; + NSView *host = web.superview; + if (host == nil) return; + @try { + [web setValue:@NO forKey:@"drawsBackground"]; + } @catch (NSException *e) { + return; + } + NSView *tint = nil; + for (NSView *v in host.subviews) { + if ([v.identifier isEqualToString:@"magpie-tint"]) tint = v; + } + CGColorRef colour = CGColorCreateSRGB(r / 255.0, g / 255.0, b / 255.0, a / 255.0); + if (tint == nil) { + tint = [[NSView alloc] initWithFrame:host.bounds]; + tint.identifier = @"magpie-tint"; + tint.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + // a layer of its own, which AppKit leaves alone + tint.layer = [CALayer layer]; + tint.wantsLayer = YES; + tint.layer.cornerRadius = 12; + tint.layer.masksToBounds = YES; + tint.layer.backgroundColor = colour; + [host addSubview:tint positioned:NSWindowBelow relativeTo:web]; + [tint release]; + } else { + // a change of theme fades as the page's own colours do + [CATransaction begin]; + CABasicAnimation *fade = [CABasicAnimation animationWithKeyPath:@"backgroundColor"]; + fade.fromValue = (id)tint.layer.backgroundColor; + fade.toValue = (__bridge id)colour; + fade.duration = ms / 1000.0; + fade.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]; + tint.layer.backgroundColor = colour; + if (ms > 0) [tint.layer addAnimation:fade forKey:@"tint"]; + [CATransaction commit]; + } + CGColorRelease(colour); + ok = 1; + }); + return ok; +} */ import "C" @@ -35,3 +90,12 @@ func (h *host) glidePanel(height int, g Glide) bool { C.glidePanel(w, C.int(height), C.int(g.MS), C.double(c[0]), C.double(c[1]), C.double(c[2]), C.double(c[3])) return true } + +// TintPanel paints the panel's tint under the page, fading to it over ms. +func (h *host) TintPanel(c [4]uint8, ms int) bool { + w := h.panel.NativeWindow() + if w == nil { + return false + } + return C.tintPanel(w, C.int(c[0]), C.int(c[1]), C.int(c[2]), C.int(c[3]), C.int(ms)) == 1 +} diff --git a/internal/gui/glide_other.go b/internal/gui/glide_other.go index d094e841..f08f259c 100644 --- a/internal/gui/glide_other.go +++ b/internal/gui/glide_other.go @@ -13,3 +13,6 @@ func (h *host) glidePanel(height int, g Glide) bool { }) return true } + +// TintPanel: the page paints the panel's tint itself here. +func (h *host) TintPanel(c [4]uint8, ms int) bool { return false } diff --git a/internal/gui/glide_test.go b/internal/gui/glide_test.go index 6191651f..8b8a3108 100644 --- a/internal/gui/glide_test.go +++ b/internal/gui/glide_test.go @@ -48,3 +48,18 @@ func TestGlideCurve(t *testing.T) { } } } + +func TestParseTint(t *testing.T) { + c, ms, ok := parseTint(url.Values{"c": {"27, 28, 32, 255"}, "ms": {"450"}}) + if !ok || c != [4]uint8{27, 28, 32, 255} || ms != 450 { + t.Fatalf("got %v %d %v", c, ms, ok) + } + if _, ms, _ := parseTint(url.Values{"c": {"0,0,0,255"}, "ms": {"99999"}}); ms != 2000 { + t.Errorf("ms not capped: %d", ms) + } + for _, bad := range []string{"", "1,2,3", "1,2,3,256", "1,2,x,4", "-1,0,0,0"} { + if _, _, ok := parseTint(url.Values{"c": {bad}}); ok { + t.Errorf("%q read as a colour", bad) + } + } +}