diff --git a/web/scripts/hangar.test.mjs b/web/scripts/hangar.test.mjs index ab56482..02684e5 100644 --- a/web/scripts/hangar.test.mjs +++ b/web/scripts/hangar.test.mjs @@ -26,6 +26,7 @@ import { matchesFilter, parseQuery, search, + stringifyQuery, suggestions, } from "../src/units.ts"; import { HELM_RELEASE, MEGAMEK_VERSION, unitIndexUrl } from "../src/megamek.ts"; @@ -371,3 +372,61 @@ test("a locked target speaks the same language as the box", () => { "advisory targets impose nothing", ); }); + +test("a quoted value is one value, not two words", () => { + // A library full of two-word names needs this: without quoting, `shadow + // hawk` is two words that both have to appear somewhere, which also finds a + // Hawk Moth standing next to a Shadow Cat. + const parsed = parseQuery('chassis:"black hawk" bv<=3000'); + assert.deepEqual(parsed.filters, [ + { key: "chassis", op: ":", value: "black hawk" }, + { key: "bv", op: "<=", value: "3000" }, + ]); + assert.deepEqual(parsed.words, []); +}); + +test("a quoted phrase with no key is one word", () => { + assert.deepEqual(parseQuery('"shadow hawk"').words, ["shadow hawk"]); +}); + +test("a query survives the round trip through text", () => { + // The pills are a view of the box, and taking one off rewrites the string — + // so parse and stringify have to agree, quotes included. + const text = 'atlas bv<=2000 role:"missile boat"'; + const once = stringifyQuery(parseQuery(text)); + assert.equal(once, 'atlas bv<=2000 role:"missile boat"'); + assert.equal(stringifyQuery(parseQuery(once)), once, "and again"); +}); + +test("a chassis with a space is searchable by name", () => { + const nova = { + chassis: "Black Hawk", + model: "Prime", + name: "Black Hawk (Nova) Prime", + bv: 2663, + }; + const shadow = { + chassis: "Shadow Hawk", + model: "SHD-2H", + name: "Shadow Hawk SHD-2H", + bv: 1064, + }; + assert.deepEqual( + search([nova, shadow], 'chassis:"black hawk"').map((u) => u.chassis), + ["Black Hawk"], + ); +}); + +test("every filter the builder can make is a term the box can hold", async () => { + // The builder writes into the query rather than holding a filter of its + // own, which is what keeps "expressible as text" true by construction + // rather than by discipline. + const hangar = await readFile(`${web}src/screens/hangar.ts`, "utf8"); + assert.match(hangar, /query\.filters\.push\(\{ key: key\.value/); + assert.match(hangar, /setQuery\(stringifyQuery\(query\)\)/); + assert.match( + hangar, + /const typed = parseQuery\(state\.query\)/, + "the pills are not read from the query", + ); +}); diff --git a/web/src/screens/hangar.ts b/web/src/screens/hangar.ts index 2c31530..9a4b206 100644 --- a/web/src/screens/hangar.ts +++ b/web/src/screens/hangar.ts @@ -37,7 +37,9 @@ import { camoPicker } from "./camo-picker"; import { FILTER_KEYS, loadUnits, + parseQuery, search, + stringifyQuery, suggestions, type Unit, } from "../units"; @@ -133,6 +135,9 @@ export function hangarScreen(): Node[] { "aria-label": "Search the unit library", }) as HTMLInputElement; + /** The filters in the box, as something to read and remove. */ + const pills = el("div", { className: "hangar-pills" }); + /** What the box takes, said once rather than guessed at. */ const syntax = el("p", { className: "hint hangar-syntax" }, [ document.createTextNode("Filters: "), @@ -153,6 +158,61 @@ export function hangarScreen(): Node[] { /** What the target is imposing on the search, when it is locked. */ const imposed = el("div", { className: "hangar-imposed" }); + /** Put a query back in the box and redraw from it. */ + function setQuery(text: string): void { + state.query = text; + searchBox.value = text; + state.shown = PAGE; + drawResults(); + } + + /** + * The filter builder: a key, an operator and a value, appended to the box. + * + * The box stays the source of truth — this writes a term into it rather + * than holding a filter of its own — so every filter is expressible as + * text by construction, and the two halves cannot drift into disagreeing + * about what is being searched. + */ + const key = el( + "select", + { className: "hangar-pick", "aria-label": "Filter by" }, + FILTER_KEYS.map((name) => el("option", { value: name, textContent: name })), + ) as HTMLSelectElement; + const op = el( + "select", + { className: "hangar-pick hangar-op", "aria-label": "How" }, + [":", "=", "<", "<=", ">", ">="].map((symbol) => + el("option", { value: symbol, textContent: symbol }), + ), + ) as HTMLSelectElement; + const value = el("input", { + type: "text", + className: "hangar-pick hangar-value", + placeholder: "value", + "aria-label": "Value", + }) as HTMLInputElement; + const addFilter = el("button", { + type: "button", + className: "secondary small-btn", + textContent: "Add filter", + }); + addFilter.addEventListener("click", () => { + const typed = value.value.trim(); + if (!typed) return; + const query = parseQuery(state.query); + query.filters.push({ key: key.value, op: op.value as never, value: typed }); + setQuery(stringifyQuery(query)); + value.value = ""; + }); + + const builder = el("div", { className: "hangar-builder" }, [ + key, + op, + value, + addFilter, + ]); + function add(unit: Unit): void { state.picks.push({ unit, id: state.nextId++ }); draw(); @@ -320,6 +380,31 @@ export function hangarScreen(): Node[] { const locked = terms.join(" "); const full = [locked, query].filter(Boolean).join(" "); + // The typed filters, as pills that can be taken off. Rendered from the + // query rather than from state of their own: the box is what is being + // searched, and a pill is a view of one term in it. + const typed = parseQuery(state.query); + pills.replaceChildren( + ...typed.filters.map((filter, at) => { + const drop = el("button", { + type: "button", + className: "hangar-pill-drop", + textContent: "\u00d7", + "aria-label": `Remove filter ${filter.key}${filter.op}${filter.value}`, + }); + drop.addEventListener("click", () => { + typed.filters.splice(at, 1); + setQuery(stringifyQuery(typed)); + }); + return el("span", { className: "hangar-pill" }, [ + el("code", { + textContent: `${filter.key}${filter.op}${filter.value}`, + }), + drop, + ]); + }), + ); + // Shown rather than silent: a search answering with a third of the library // and no reason given is a screen a player argues with. imposed.replaceChildren( @@ -512,6 +597,8 @@ export function hangarScreen(): Node[] { el("div", { className: "hangar-panes" }, [ el("section", { className: "card hangar-library" }, [ el("div", { className: "hangar-search-row" }, [searchBox, found]), + pills, + builder, imposed, syntax, results, diff --git a/web/src/styles.css b/web/src/styles.css index 88ebf82..e299e5f 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -6180,3 +6180,78 @@ footer .debug { .hangar-imposed .hangar-term { border-color: var(--accent); } + +/* The filters in the box, as something to read and take off, and the little + builder that writes one into it. Both are views of the query text: the box + is what is being searched, and neither of these holds a filter of its own. */ +.hangar-pills { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.hangar-pills:empty { + display: none; +} + +.hangar-pill { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.15rem 0.3rem 0.15rem 0.4rem; + background: var(--panel); + border: 1px solid var(--accent); + border-radius: 999px; +} + +.hangar-pill code { + font-family: var(--font-data); + font-size: 0.78rem; + border: 0; + background: none; + padding: 0; +} + +.hangar-pill-drop { + padding: 0 0.25rem; + font: inherit; + line-height: 1; + color: var(--muted); + background: none; + border: 0; + border-radius: 999px; + clip-path: none; + letter-spacing: normal; + text-transform: none; + cursor: pointer; +} + +.hangar-pill-drop:hover { + color: var(--ink); +} + +.hangar-builder { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem; +} + +.hangar-pick { + padding: 0.25rem 0.35rem; + font: inherit; + font-size: 0.85rem; + color: var(--ink); + background: var(--bg); + border: 1px solid var(--line); + border-radius: var(--radius); +} + +.hangar-op { + font-family: var(--font-data); + width: 4rem; +} + +.hangar-value { + width: 9rem; +} diff --git a/web/src/units.ts b/web/src/units.ts index 55b24f4..767202c 100644 --- a/web/src/units.ts +++ b/web/src/units.ts @@ -233,6 +233,9 @@ const FIELDS: Record = heat: { column: "heat_efficiency", kind: "number" }, cost: { column: "cost", kind: "number" }, class: { column: "weight_class", kind: "number" }, + chassis: { column: "chassis", kind: "text" }, + model: { column: "model", kind: "text" }, + name: { column: "name", kind: "text" }, tech: { column: "tech_base", kind: "text" }, role: { column: "role", kind: "text" }, type: { column: "unit_type", kind: "text" }, @@ -252,25 +255,80 @@ const CLASSES: Record = { /** Every key a query may name, for a screen that wants to say so. */ export const FILTER_KEYS = Object.keys(FIELDS).sort(); -const TERM = /^([a-z]+)(<=|>=|<|>|=|:)(.+)$/; +const TERM = /^([a-z]+)(<=|>=|<|>|=|:)(.*)$/; -/** Split a query into words and filters. */ +/** + * Split a query into tokens, keeping quoted runs together. + * + * `chassis:"black hawk"` is one term and `"shadow hawk"` is one word, which + * is what a library full of two-word names needs: without this, every name + * with a space in it is two words that both have to appear somewhere, and + * `shadow hawk` matches a Shadow Hawk and also a Hawk Moth beside a Shadow + * Cat. Quotes are the only escape there is; a quote that is never closed runs + * to the end of the input, because a player is still typing it. + */ +function tokenize(text: string): string[] { + const tokens: string[] = []; + let token = ""; + let quoted = false; + + for (const char of text) { + if (char === '"') { + quoted = !quoted; + // The quote itself is a marker and not part of the value, but it does + // hold an empty token open: `chassis:""` is a term with no value. + token += "\u0000"; + continue; + } + if (!quoted && /\s/.test(char)) { + if (token) tokens.push(token); + token = ""; + continue; + } + token += char; + } + if (token) tokens.push(token); + return tokens.map((one) => one.replaceAll("\u0000", "")); +} + +/** + * Split a query into words and filters. + * + * A token that is not a term this knows stays a word, including one with a + * colon in it: a player typing a name should get a search rather than a + * syntax error. + */ export function parseQuery(text: string): Query { const words: string[] = []; const filters: Filter[] = []; - for (const token of text.toLowerCase().split(/\s+/).filter(Boolean)) { + for (const token of tokenize(text.toLowerCase())) { const term = TERM.exec(token); - if (term && FIELDS[term[1]!]) { - filters.push({ key: term[1]!, op: term[2] as Op, value: term[3]! }); + if (term && FIELDS[term[1]!] && term[3]) { + filters.push({ key: term[1]!, op: term[2] as Op, value: term[3] }); } else { - // Not a term this knows: a word, including one with a colon in it. words.push(token); } } return { words, filters }; } +/** + * A query written back out as text. + * + * The box is the one source of truth for what is being searched — the pills + * above it are a view of this string, and editing one rewrites it — so + * anything that changes a filter goes through here. Values with a space in + * them come back quoted, which is what makes the round trip hold. + */ +export function stringifyQuery(query: Query): string { + const quote = (value: string) => (/\s/.test(value) ? `"${value}"` : value); + return [ + ...query.words.map(quote), + ...query.filters.map((f) => `${f.key}${f.op}${quote(f.value)}`), + ].join(" "); +} + /** Whether one design satisfies one term. */ export function matchesFilter(unit: Unit, filter: Filter): boolean { const field = FIELDS[filter.key];