diff --git a/internal/gui/assets/library.css b/internal/gui/assets/library.css index 4503a317..68230b5f 100644 --- a/internal/gui/assets/library.css +++ b/internal/gui/assets/library.css @@ -135,6 +135,16 @@ input.lib-setname:focus { border-color: var(--accent); box-shadow: 0 0 0 3px col .lib-ag:hover .ic { opacity: .7; } .lib-ag.on { background: var(--card); border-color: var(--line); color: var(--fg); box-shadow: 0 1px 1px rgba(0, 0, 0, .05); } .lib-ag.on .ic { filter: none; opacity: 1; } +/* an icon made grey once rather than through the filter on every paint + (library.js, greyIcon): a picture's grey copy stands in for it, and one + drawn in the text's colour takes that colour grey — the same pixels */ +.lib-ag .ic > img.grey { display: none; } +.lib-ag:not(.on) .ic.baked { filter: none; } +.lib-ag:not(.on) .ic.baked > img:not(.grey) { display: none; } +.lib-ag:not(.on) .ic.baked > img.grey { display: block; } +@supports (color: rgb(from currentColor r g b)) { + .lib-ag:not(.on) .ic.flat { filter: none; color: rgb(from currentColor calc(r * .2126 + g * .7152 + b * .0722) calc(r * .2126 + g * .7152 + b * .0722) calc(r * .2126 + g * .7152 + b * .0722)); } +} .lib-ag.via { border: 1px dashed var(--line); } .lib-ag.via .ic { opacity: .6; } .lib-ag.warn::after { content: ""; position: absolute; top: -2px; right: -2px; width: 7px; height: 7px; border-radius: 50%; background: var(--amber); box-shadow: 0 0 0 2px var(--card); } @@ -149,16 +159,22 @@ input.lib-setname:focus { border-color: var(--accent); box-shadow: 0 0 0 3px col /* in a list row they sit stacked like a hand of cards, on one line, and fan out when the pointer or focus comes to them */ .lib-row > .lib-agents { flex-wrap: nowrap; gap: 0; max-width: none; padding-left: 4px; } -.lib-row > .lib-agents .lib-ag { flex: none; width: 24px; min-width: 0; height: 24px; padding: 0 0 0 4px; justify-content: flex-start; background: var(--card); border-color: var(--line-2); box-shadow: 0 0 0 1.5px var(--card); transition: margin .22s var(--ease), background .14s, border-color .14s, transform .14s var(--ease); } +.lib-row > .lib-agents .lib-ag { flex: none; width: 24px; min-width: 0; height: 24px; padding: 0 0 0 4px; justify-content: flex-start; background: var(--card); border-color: var(--line-2); box-shadow: none; transition: margin .22s var(--ease), background .14s, border-color .14s, transform .14s var(--ease); } .lib-row > .lib-agents .lib-ag .ic { width: 14px; height: 14px; } +/* the ring in the card's colour that cuts each chip out of the one under + it: an outline rather than a spread shadow, the same ring cheaper to + paint; a chip with the focus shows the focus ring instead */ +.lib-row > .lib-agents .lib-ag:not(:focus-visible) { outline: 1.5px solid var(--card); outline-offset: 0; } .lib-row > .lib-agents .lib-ag + .lib-ag { margin-left: -7px; } -.lib-row > .lib-agents .lib-ag.on { border-color: var(--line); box-shadow: 0 0 0 1.5px var(--card), 0 1px 2px rgba(0, 0, 0, .08); } +.lib-row > .lib-agents .lib-ag.on { border-color: var(--line); box-shadow: 0 1px 2px rgba(0, 0, 0, .08); } .lib-row > .lib-agents .lib-ag.via { border-style: dashed; } .lib-row > .lib-agents:hover .lib-ag + .lib-ag, .lib-row > .lib-agents:focus-within .lib-ag + .lib-ag { margin-left: 3px; } .lib-row > .lib-agents:hover .lib-ag, .lib-row > .lib-agents:focus-within .lib-ag { box-shadow: none; } +.lib-row > .lib-agents:hover .lib-ag:not(:focus-visible), .lib-row > .lib-agents:focus-within .lib-ag:not(:focus-visible) { outline-color: transparent; } .lib-row > .lib-agents .lib-ag:hover { background: var(--pill); z-index: 1; } -.lib-row.click:hover > .lib-agents .lib-ag { box-shadow: 0 0 0 1.5px var(--card-2); } -.lib-row.click:hover > .lib-agents:hover .lib-ag { box-shadow: none; } +.lib-row.click:hover > .lib-agents .lib-ag { box-shadow: none; } +.lib-row.click:hover > .lib-agents .lib-ag:not(:focus-visible) { outline-color: var(--card-2); } +.lib-row.click:hover > .lib-agents:hover .lib-ag:not(:focus-visible) { outline-color: transparent; } /* agents that have one of their own, not in the library */ .lib-have { display: flex; gap: 4px; flex: none; } @@ -324,12 +340,18 @@ input.lib-setname:focus { border-color: var(--accent); box-shadow: 0 0 0 3px col /* a project's skills out of view are neither laid out nor painted until they near it */ .lib-projskill { content-visibility: auto; contain-intrinsic-size: auto 76px; } .lib-grouphead .lib-rowacts { min-width: 26px; } -/* A group's skills are a window on its list (library.js, windowed): the - rows near the view are drawn, room is kept for the others. Every row is - held to the height the list counts on — its description one line, the - whole of it in its tooltip — so what's kept is exactly what's missing. */ +/* A group's skills are drawn near the view first (library.js, windowed), + room kept for the rest, which are drawn in while nothing else is going + on and then stay. Every row is held to the height the list counts on — + its description one line, the whole of it in its tooltip — so the room + kept is exactly what's missing. */ .lib-vl { flex: none; } -.lib-vl > .row { border-top: 1px solid var(--line-2); overflow: hidden; flex-wrap: nowrap; } +/* each row kept to itself, as a skipped one was; and a row the pointer + passes over in a scroll shows its buttons at once rather than fading them + in — in WebKit each fade, given a layer of its own while it ran, cost a + long frame (library.js marks a list .scrolling while it scrolls) */ +.lib-vl > .row { border-top: 1px solid var(--line-2); overflow: hidden; flex-wrap: nowrap; contain: layout paint style; } +.lib-vl.scrolling > .row .lib-rowacts { transition: none; } .lib-vl.bare > .row.first { border-top: 0; } .lib-vl > .row.lib-skill { height: 51px; min-height: 0; padding: 7px 10px 7px 12px; gap: 11px; } .lib-vl > .row.lib-skill.src { height: 67px; } diff --git a/internal/gui/assets/library.js b/internal/gui/assets/library.js index 75270440..886e26e7 100644 --- a/internal/gui/assets/library.js +++ b/internal/gui/assets/library.js @@ -99,10 +99,94 @@ const icons = new Map(); function agentIcon(name) { let i = icons.get(name); - if (!i) icons.set(name, (i = icon(name))); + if (!i) { icons.set(name, (i = icon(name))); greyIcon(i); } return i.cloneNode(true); } + // A chip's icon is grey while its agent hasn't the item. A filter made it + // so on every paint of every chip — most of what a scroll through + // hundreds of skills painted — so it's made grey once instead: a picture + // gets a grey copy (by the filter's own sum), which stands in for + // it once ready, and an icon drawn in the text's colour takes that colour + // grey (library.css). Until then, or if it can't be, the filter does it. + function greyIcon(i) { + if (i.querySelector(":scope > .mask, :scope > svg")) { i.classList.add("flat"); return; } + const src = i.querySelector(":scope > img")?.getAttribute("src"); + if (!src) return; + greyCopy(src).then((url) => { + if (!url) return; + const add = (ic) => { + if (ic.classList.contains("baked")) return; + const g = el("img", "grey"); + g.src = url; + g.alt = ""; + g.draggable = false; + ic.append(g); + ic.classList.add("baked"); + }; + add(i); + for (const x of document.querySelectorAll(".lib-ag > .ic:not(.baked) > img")) if (x.getAttribute("src") === src) add(x.parentElement); + }, () => {}); + } + // grayscale(1)'s own sum: each colour's red, green and blue become this + const lum = (r, g, b) => Math.round(r * .2126 + g * .7152 + b * .0722); + let tint; + function greyColour(v) { + tint ||= document.createElement("canvas").getContext("2d"); + // a colour in any spelling, read back as #rrggbb or rgba(…); anything + // else (none, url(#…), currentColor) is left as it is + if (/^\s*(none|currentcolor|inherit|transparent|url\()/i.test(v)) return v; + tint.fillStyle = "#010203"; + tint.fillStyle = v; + const c = tint.fillStyle; + let m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(c); + if (m) { + if (c === "#010203" && !/010203/.test(v)) return v; + const y = lum(parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)).toString(16).padStart(2, "0"); + return "#" + y + y + y; + } + m = /^rgba?\(\s*([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\s*\)$/.exec(c); + if (!m) return v; + const y = lum(+m[1], +m[2], +m[3]); + return `rgba(${y}, ${y}, ${y}, ${m[4] ?? 1})`; + } + async function greyCopy(src) { + const r = await fetch(src); + if (!r.ok) return null; + let blob; + if (/svg/.test(r.headers.get("content-type") || "") || /\.svg$/.test(src)) { + // the drawing with each of its colours made grey — the same as the + // filter, which is a sum over each colour (blending and gradients + // mix colours in sRGB, as the filter does, so they come out the same) + // and drawn as sharp as the drawing itself + const s = (await r.text()).replace(/(? k + eq + q + greyColour(v) + q) + .replace(/(? k + eq + greyColour(v)); + if (/<(image|feColorMatrix|feComponentTransfer|feTurbulence)\b/.test(s)) return null; // a colour the sum can't reach + blob = new Blob([s], { type: "image/svg+xml" }); + } else { + // a picture made grey pixel by pixel + const im = new Image(); + im.src = URL.createObjectURL(await r.blob()); + await im.decode(); + const c = document.createElement("canvas"); + c.width = im.naturalWidth; + c.height = im.naturalHeight; + const x = c.getContext("2d"); + x.drawImage(im, 0, 0); + URL.revokeObjectURL(im.src); + const d = x.getImageData(0, 0, c.width, c.height), p = d.data; + for (let k = 0; k < p.length; k += 4) p[k] = p[k + 1] = p[k + 2] = p[k] * .2126 + p[k + 1] * .7152 + p[k + 2] * .0722; + x.putImageData(d, 0, 0); + blob = await new Promise((done) => c.toBlob(done)); + if (!blob) return null; + } + const url = URL.createObjectURL(blob); + const t = new Image(); + t.src = url; + await t.decode(); // ready before it stands in, so nothing blinks + return url; + } + // Agent chips for a server or a skill: each agent that could have it, lit // when it does. A chip whose agent couldn't be given it says why. function agentChips(all, on, onChange, opts = {}) { @@ -1305,33 +1389,61 @@ return card; } - // ---------- a long list, drawn near the view only ---------- + // ---------- a long list, drawn near the view first ---------- - // A window on a list of items of known heights ({ key, h, make }): the - // rows near the page's view are drawn, with room kept above and below - // for the rest. A scroll draws more once what's drawn ahead is less than - // a view, and then a few rows a frame until half a view more is, so no - // frame builds many rows and most touch nothing; a row still wanted - // after a change (a filter, a fold) is kept as it is, not drawn again. + // A list of items of known heights ({ key, h, make }) drawn in two goes: + // the rows near the page's view at once, with room kept above and below + // for the rest, and then the rest while the page is idle, a few rows at + // a time, each kept once drawn. A scroll builds nothing and takes nothing + // away, so it only paints what's there — unless it outruns the filling, + // when what's left is drawn at once rather than a view a frame. A row + // still wanted after a change (a filter, a fold) is kept as it is. const lists = new Set(); let listFrame = 0; function syncLists() { cancelAnimationFrame(listFrame); listFrame = 0; if (page.hidden) return; - const vp = page.getBoundingClientRect(); - let more = false; + let vp = null; for (const w of lists) { - if (w.el.isConnected) more = w.sync(vp) || more; - else if (w.shown) lists.delete(w); // its page is gone; one drawn again comes back + if (!w.el.isConnected) { if (w.shown) lists.delete(w); continue; } // its page is gone; one drawn again comes back + if (!w.full()) w.sync(vp ||= page.getBoundingClientRect()); } - if (more) queueLists(); } - const STEP = 4; // rows a frame, beyond those in view const queueLists = () => { if (!listFrame) listFrame = requestAnimationFrame(syncLists); }; - page.addEventListener("scroll", queueLists, { passive: true }); + // and while the page scrolls, its lists say so (library.css, .scrolling) + let still = 0; + const settled = () => { still = 0; for (const w of lists) w.el.classList.remove("scrolling"); }; + page.addEventListener("scroll", () => { + queueLists(); + if (!still) for (const w of lists) w.el.classList.add("scrolling"); + clearTimeout(still); + still = setTimeout(settled, 150); + }, { passive: true }); window.addEventListener("resize", queueLists); + // The filling: one list's next few rows per idle moment, the lists in turn. + const FILL = 20; + const filling = new Set(); + let fillTask = 0; + const idle = window.requestIdleCallback + ? (f) => requestIdleCallback(f, { timeout: 200 }) + : (f) => setTimeout(f, 24); + function queueFill(w) { + filling.add(w); + if (!fillTask) fillTask = idle(fillSome); + } + function fillSome() { + fillTask = 0; + for (const w of filling) { + if (!w.el.isConnected || w.full()) { filling.delete(w); continue; } + w.grow(FILL); + if (w.full()) filling.delete(w); + break; + } + if (filling.size) fillTask = idle(fillSome); + } + function windowed() { const box = el("div", "lib-vl"); const above = el("div", "lib-vpad"), below = el("div", "lib-vpad"); @@ -1356,14 +1468,11 @@ above.style.height = tops[from] + "px"; below.style.height = tops[items.length] - tops[to] + "px"; }; - // draw items a…b, keeping the rows of those already drawn + // draw items a…b around those drawn (a ≤ from, b ≥ to), or afresh, + // keeping the rows of those already drawn function show(a, b, fresh) { if (!fresh && a === from && b === to) return; - if (!fresh && a < to && b > from) { - // the same list moved along: rows are taken off the ends and added - // at them, the rest untouched - for (let i = from; i < a; i++) { live.get(items[i].key)?.remove(); live.delete(items[i].key); } - for (let i = b; i < to; i++) { live.get(items[i].key)?.remove(); live.delete(items[i].key); } + if (!fresh && a <= from && b >= to && from < to) { if (a < from) { const f = document.createDocumentFragment(); const made = []; @@ -1391,13 +1500,20 @@ from = a; to = b; pads(); } + w.full = () => from === 0 && to === items.length; + // n more rows, below what's drawn first, then above it + w.grow = (n) => { + const b = Math.min(items.length, to + n); + show(Math.max(0, from - (n - (b - to))), b); + }; w.set = (next) => { items = next; tops = [0]; for (const it of items) tops.push(tops[tops.length - 1] + it.h); // placed, what's near the view; until then the first rows, enough for one - if (box.isConnected && !page.hidden) { if (w.sync(page.getBoundingClientRect(), true)) queueLists(); } + if (box.isConnected && !page.hidden) w.sync(page.getBoundingClientRect(), true); else show(0, Math.min(items.length, 12), true); + if (!w.full()) queueFill(w); }; w.sync = (vp, fresh) => { w.shown = true; @@ -1408,26 +1524,12 @@ const k = r.height ? r.height / total : 1; // the page may be zoomed const view = vp.height / k; const top = (vp.top - r.top) / k, bottom = (vp.bottom - r.top) / k; - // what must be drawn: the view and a view about it; what is drawn - // when that runs out: a view and a half about it — the rows in view - // at once, the others a few a frame, so no frame builds a whole view - const need = (m) => [top - view * m > total ? items.length : at(Math.max(0, top - view * m)), - bottom + view * m < 0 ? 0 : Math.min(items.length, at(bottom + view * m) + 1)]; - const [a, b] = need(1); - if (!fresh && from <= a && to >= b && (a < b || from === to)) return false; - let [na, nb] = need(1.5); - if (nb < na) nb = na; - if (fresh || na >= to || nb <= from) { - // drawn anew, or a jump past what's drawn: the view and a little - // about it now, the rest as above - const [ja, jb] = need(0.25); - show(ja, Math.max(ja, jb), !!fresh); - return ja > na || jb < nb; - } - const [va, vb] = need(0); - const sa = Math.max(na, Math.min(va, from - STEP)), sb = Math.min(nb, Math.max(vb, to + STEP)); - show(sa, sb); - return sa > na || sb < nb; // more to come next frame + // the rows in view and half a view about it + const a = top - view / 2 > total ? items.length : at(Math.max(0, top - view / 2)); + const b = bottom + view / 2 < 0 ? 0 : Math.min(items.length, at(bottom + view / 2) + 1); + if (fresh) { show(a, Math.max(a, b), true); return; } + // a scroll past what's filled in: the rest, now + if (from === to || (a < b && (a < from || b > to))) show(0, items.length, from === to); }; lists.add(w); return w;