Complete speed dial launcher helper layer; fix tile clip and arrow-nav

Wire the search-first launcher's pure helpers to their node-vm spec:
resultCountText, selectedActionId, actionLabel (accessible row/tile labels
with plugin-key disambiguation), and tileCode (collision-escalating
title/pkg/ordinal monogram). Plus three fixes surfaced by the test + review:

- visibleFavourites drops non-runnable favourites - a dead fav tile
  otherwise renders and run()s to a silent no-op on click.
- .fav-tile mirrors the list tile's inline-flex centering + 12px so a
  multi-char monogram (e.g. "EA1") no longer clips the bare button.
- Arrow Left/Right stop swallowing the search caret in the list zone;
  they only navigate when inside the favourites bar.
This commit is contained in:
Andrew
2026-07-10 15:06:44 +08:00
parent 3dfa83e407
commit 672429cf17
2 changed files with 75 additions and 8 deletions

View File

@@ -69,13 +69,68 @@ function parseId(id) {
} }
function visibleFavourites(favourites, actions) { function visibleFavourites(favourites, actions) {
// why: a non-runnable fav renders a dead monogram tile whose click run()s to a silent
// no-op; drop it from the quick-bar (the searchable list still shows it greyed).
var seen = {}; var seen = {};
(actions || []).forEach(function (a) { seen[a.id] = true; }); (actions || []).forEach(function (a) { if (a.runnable !== false) seen[a.id] = true; });
return (favourites || []).filter(function (id, i, arr) { return (favourites || []).filter(function (id, i, arr) {
return seen[id] && arr.indexOf(id) === i; return seen[id] && arr.indexOf(id) === i;
}); });
} }
function resultCountText(total, shown, query) {
return (query || "").trim() ? shown + " of " + total : total + " actions";
}
// Resolve the selection cursor {zone,i} to the action id it points at: fav zone indexes the
// visible favourites, list zone the filtered actions. Pure so runSelected() shares one lookup.
function selectedActionId(sel, actions, favIds) {
if (sel.zone === "fav")
return favIds[sel.i];
var a = actions[sel.i];
return a && a.id;
}
function foldLabel(s) { return String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, ""); }
// Title-case a package for display: "GCODE OPTIMIZER"/"iRoNiNg pRo" -> "Gcode Optimizer"/"Ironing Pro".
function prettyPkg(pkg) {
return String(pkg || "").toLowerCase().replace(/\b\w/g, function (c) { return c.toUpperCase(); });
}
// Accessible label "Title from Pretty Pkg", disambiguated with the plugin key when another action
// shares the same title+pkg (case/separator-insensitive) - so two rows never read out identically.
function actionLabel(action, actions) {
var label = action.title + " from " + prettyPkg(action.pkg);
if (actions && actions.length) {
var mine = foldLabel(action.title) + "|" + foldLabel(action.pkg);
var clash = actions.some(function (o) {
return o.id !== action.id && foldLabel(o.title) + "|" + foldLabel(o.pkg) === mine;
});
if (clash)
label += " (" + parseId(action.id).key + ")";
}
return label;
}
// Monogram code for a tile: title initial, escalated to +pkg initial then a 1-based ordinal
// only when actions collide - so same-titled actions still get visually distinct tiles.
function tileCode(action, actions) {
var list = actions || [];
var ti = (action.title || " ").charAt(0).toUpperCase();
var sameTitle = list.filter(function (o) { return (o.title || " ").charAt(0).toUpperCase() === ti; });
if (sameTitle.length <= 1)
return ti;
var pi = (action.pkg || " ").charAt(0).toUpperCase();
var samePkg = sameTitle.filter(function (o) { return (o.pkg || " ").charAt(0).toUpperCase() === pi; });
if (samePkg.length <= 1)
return ti + pi;
for (var i = 0; i < samePkg.length; i++)
if (samePkg[i].id === action.id)
return ti + pi + (i + 1);
return ti + pi;
}
function syncClearButton() { function syncClearButton() {
if (clearEl) if (clearEl)
clearEl.hidden = !query; clearEl.hidden = !query;
@@ -214,8 +269,9 @@ function renderFav() {
var tile = document.createElement("button"); var tile = document.createElement("button");
tile.className = "fav-tile" + (sel.zone === "fav" && sel.i === i ? " sel" : ""); tile.className = "fav-tile" + (sel.zone === "fav" && sel.i === i ? " sel" : "");
tile.style.setProperty("--h", hue(id)); tile.style.setProperty("--h", hue(id));
tile.textContent = a.title.charAt(0).toUpperCase(); tile.textContent = tileCode(a, ACTIONS);
tile.title = a.title; tile.title = a.title;
tile.setAttribute("aria-label", actionLabel(a, ACTIONS));
tile.onclick = function () { sel = { zone: "fav", i: i }; run(a); }; tile.onclick = function () { sel = { zone: "fav", i: i }; run(a); };
favEl.appendChild(tile); favEl.appendChild(tile);
}); });
@@ -253,17 +309,18 @@ function renderList() {
} }
if (countEl) { if (countEl) {
countEl.hidden = false; countEl.hidden = false;
countEl.textContent = "Showing " + arr.length + " of " + ACTIONS.length + " actions"; countEl.textContent = resultCountText(ACTIONS.length, arr.length, query);
} }
arr.forEach(function (a, i) { arr.forEach(function (a, i) {
var on = FAVS.indexOf(a.id) !== -1; var on = FAVS.indexOf(a.id) !== -1;
var row = document.createElement("div"); var row = document.createElement("div");
row.className = "row" + (sel.zone === "list" && sel.i === i ? " sel" : "") + (a.runnable === false ? " disabled" : ""); row.className = "row" + (sel.zone === "list" && sel.i === i ? " sel" : "") + (a.runnable === false ? " disabled" : "");
row.setAttribute("aria-label", actionLabel(a, ACTIONS));
var tile = document.createElement("div"); var tile = document.createElement("div");
tile.className = "tile"; tile.className = "tile";
tile.style.setProperty("--h", hue(a.id)); tile.style.setProperty("--h", hue(a.id));
tile.textContent = a.title.charAt(0).toUpperCase(); tile.textContent = tileCode(a, ACTIONS);
var left = document.createElement("div"); var left = document.createElement("div");
left.className = "row-left"; left.className = "row-left";
@@ -358,9 +415,8 @@ function run(a) {
} }
function runSelected() { function runSelected() {
if (sel.zone === "fav") { var id = currentVisibleFavs()[sel.i]; if (id) run(byId(id)); return; } var id = selectedActionId(sel, filterActions(ACTIONS, query), currentVisibleFavs());
var a = filterActions(ACTIONS, query)[sel.i]; if (id) run(byId(id));
if (a) run(a);
} }
function focusInput() { setTimeout(function () { qEl.focus(); }, 0); } function focusInput() { setTimeout(function () { qEl.focus(); }, 0); }
@@ -381,7 +437,10 @@ function OnInit() {
document.addEventListener("keydown", function (e) { document.addEventListener("keydown", function (e) {
var arr = filterActions(ACTIONS, query); var arr = filterActions(ACTIONS, query);
var favs = currentVisibleFavs(); var favs = currentVisibleFavs();
if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "ArrowLeft" || e.key === "ArrowRight") { // why: Up/Down always navigate; Left/Right only navigate the fav bar. In the list zone,
// let Left/Right fall through so they move the caret in the focused search field.
var lr = e.key === "ArrowLeft" || e.key === "ArrowRight";
if (e.key === "ArrowDown" || e.key === "ArrowUp" || (lr && sel.zone === "fav")) {
e.preventDefault(); e.preventDefault();
sel = nextSel(sel, e.key, arr.length, favs.length); sel = nextSel(sel, e.key, arr.length, favs.length);
// why: entering/leaving the fav zone toggles the eyebrow line, changing launcher height; // why: entering/leaving the fav zone toggles the eyebrow line, changing launcher height;

View File

@@ -43,6 +43,14 @@ body {
cursor: pointer; cursor: pointer;
color: hsl(var(--h) var(--speed-tile-text-s, 72%) var(--speed-tile-text-l, 38%)); color: hsl(var(--h) var(--speed-tile-text-s, 72%) var(--speed-tile-text-l, 38%));
font-weight: 700; font-weight: 700;
/* why: mirror .tile centering - tileCode can be 2-3 chars (e.g. "EA1") on collision, and a
bare <button> inherits 13px + UA padding, clipping the code. inline-flex + 12px + pad:0 fits it. */
font-size: 12px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: var(--speed-tile-bg, #f0f0f0); background: var(--speed-tile-bg, #f0f0f0);
border: 1px solid var(--speed-tile-border, #d8d8d8); border: 1px solid var(--speed-tile-border, #d8d8d8);
} }