gui(panel): no dark band at the bottom while the tray panel grows — WebKit draws the page a frame or two after the panel's frame moves and filled the new edge with its own dark background; on the Mac the panel's webview now draws no background of its own and the page's colour is painted in a view under it that the system sizes with the panel, so the new edge comes in the page's colour until the page catches up. It looks as before; a change of theme fades it along; elsewhere, or when the webview won't, the page paints itself as it did

This commit is contained in:
yetone committed 2026-09-25 02:19:32 +08:00
1 parent 497f2cac6e
commit b225ace50c
8 files changed
+167 -4

No files matched your search

+9
View File
@@ -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)
})
+5 -1
View File
@@ -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); }
+36
View File
@@ -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;
+17 -3
View File
@@ -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)
})
+18
View File
@@ -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
}
+64
View File
@@ -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
}
+3
View File
@@ -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 }
+15
View File
@@ -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)
}
}
}