All files / src/web config-form.js

100% Statements 96/96
100% Branches 46/46
100% Functions 34/34
100% Lines 83/83

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208      5x         45x                             37x 2119x 520x 37x 37x 37x 557x 557x 74x 74x   483x 222x 222x 222x   483x 483x   37x         35x   351x 526x         2560x         256x     176x   5x 5436x 8663x 5436x 5436x     841x   5x 351x   5x 281x               105x     5x 105x                 105x 105x     5x 245x 245x 245x 245x 245x     5x   176x 176x 176x 176x 1017x         1017x 1017x 21x 21x   1017x     176x           176x         176x 176x 176x       70x           70x 70x 70x       210x 35x       5x       35x           35x                     351x 35x 280x         998x 998x   376x   83x 376x   483x 483x   139x 139x      
import { MaxPersistenceMs } from "./canvas.js";
import { DisplaySection, Parameters } from "./parameters.js";
 
export const PersistenceField = {
    type: "range",
    id: "persistence",
    section: DisplaySection,
    label: "Phosphor afterglow",
    readout: (afterglowMs) => (afterglowMs > 0 ? `${afterglowMs} ms` : "none"),
    min: 0,
    max: MaxPersistenceMs,
    step: 5,
    title: "How long the last picture takes to fade under the next, in milliseconds: 0 is no afterglow",
};
 
/**
 * The Settings dialog's fields, top to bottom, as the parameters declare them, then the afterglow slider, which edits
 * whichever setting the display in use has; an array is a section, set off from what is around it, and a `group` a
 * label beside a column of fields.
 *
 * @returns {Array<object|object[]>}
 */
export function configForm(parameters = Parameters) {
    const fields = Object.entries(parameters)
        .filter(([, { field }]) => field)
        .map(([setting, { field }]) => ({ setting, ...field }));
    const form = [];
    const places = new Map();
    for (const field of [...fields, PersistenceField]) {
        const place = field.group ?? field.section;
        if (place === undefined) {
            form.push(field);
            continue;
        }
        if (!places.has(place)) {
            const container = field.group ? { type: "group", ...field.group, fields: [] } : [];
            places.set(place, container);
            form.push(container);
        }
        const container = places.get(place);
        (Array.isArray(container) ? container : container.fields).push(field);
    }
    return form;
}
 
/** @returns {object[]} every field in the form that shows a setting, sections and groups flattened. */
export function settingFields(form) {
    return form
        .flat()
        .flatMap((field) => (field.type === "group" ? field.fields : [field]))
        .filter((field) => field.setting);
}
 
/** @returns {string} the id of the element a field's value is shown in. */
export function controlId(field) {
    return field.id ?? (field.type === "menu" ? `${field.setting}Dropdown` : field.setting);
}
 
/** @returns {string} the id of a range's readout. */
export function readoutId(field) {
    return `${controlId(field)}Value`;
}
 
const kebab = (name) => name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
 
const element = (tag, attributes = {}, ...children) => {
    const node = document.createElement(tag);
    for (const [name, value] of Object.entries(attributes)) if (value !== undefined) node.setAttribute(name, value);
    node.append(...children);
    return node;
};
 
const withCode = (text) => text.split("`").map((part, index) => (index % 2 ? element("code", {}, part) : part));
 
const row = (left, right, className = "row align-items-center") =>
    element("div", { class: className }, element("div", { class: "col-sm-6" }, ...left), right);
 
const rowLabel = (field, ...extra) =>
    element(
        "label",
        { for: field.type === "group" ? undefined : controlId(field), class: "col-form-label" },
        ...withCode(`${field.label}:`),
        ...extra,
        ...(field.hint ? [element("br"), element("small", {}, ...withCode(field.hint))] : []),
    );
 
const readout = (field) => (field.readout ? [" ", element("span", { id: readoutId(field) })] : []);
 
// A field without a setting is wired by whoever owns it, so its input never becomes a pick.
const rangeInput = (field, pick) => {
    const input = element("input", {
        type: "range",
        class: "form-range",
        id: controlId(field),
        min: field.min,
        max: field.max,
        step: field.step,
        title: field.title,
    });
    if (field.setting) input.addEventListener("input", () => pick(field, parseFloat(input.value)));
    return input;
};
 
const checkbox = (field, pick) => {
    const id = controlId(field);
    const input = element("input", { class: "form-check-input", type: "checkbox", id });
    input.addEventListener("click", () => pick(field, input.checked));
    const label = element("label", { class: "form-check-label", for: id }, ...withCode(field.label));
    return element("div", { class: "form-check" }, input, label);
};
 
const renderers = {
    menu(field, pick) {
        const id = controlId(field);
        const stem = kebab(field.setting);
        const attribute = `data-${field.attribute ?? "value"}`;
        const items = field.options.map((option) => {
            const link = element("a", {
                href: "#",
                class: `dropdown-item ${stem}-option`,
                [attribute]: option.value ?? "",
            });
            link.textContent = option.text;
            link.addEventListener("click", (event) => {
                event.preventDefault();
                pick(field, option.value);
            });
            return element("li", {}, link);
        });
        // The space is the one Bootstrap's own markup leaves before the caret.
        const button = element(
            "button",
            { type: "button", class: "btn btn-secondary dropdown-toggle", "data-bs-toggle": "dropdown", id },
            element("span"),
            " ",
        );
        const menu = element(
            "ul",
            { class: `dropdown-menu dropdown-menu-dark ${stem}-menu`, role: "menu", "aria-labelledby": id },
            ...items,
        );
        const rendered = [row([rowLabel(field)], element("div", { class: "col-sm-6 dropdown" }, button, menu))];
        if (field.status) rendered.push(element("div", { id: field.status, class: "mt-2 small text-muted" }));
        return rendered;
    },
 
    range(field, pick) {
        return [
            row([rowLabel(field, ...readout(field))], element("div", { class: "col-sm-6" }, rangeInput(field, pick))),
        ];
    },
 
    check(field, pick) {
        const heading = element("label", { class: "col-form-label" }, `${field.heading}:`);
        const hint = element("div", { class: "small text-muted mt-2" }, ...withCode(field.hint));
        return [row([heading, checkbox(field, pick)], element("div", { class: "col-sm-6" }, hint))];
    },
 
    group(field, pick) {
        const items = field.fields.map((item) => groupItemRenderers[item.type](item, pick));
        return [row([rowLabel(field)], element("div", { class: "col-sm-6" }, ...items), "row")];
    },
};
 
const groupItemRenderers = {
    check: checkbox,
 
    range(field, pick) {
        const label = element(
            "label",
            { class: "form-check-label", for: controlId(field) },
            ...withCode(`${field.label}:`),
            ...readout(field),
        );
        return element("div", { class: "form-check" }, label, rangeInput(field, pick));
    },
};
 
/**
 * Builds the form, one element per row or section. `pick(field, value)` is called with the value the user gives a
 * field's setting; showing it is left to the caller, through showValue.
 *
 * @returns {HTMLElement[]}
 */
export function renderConfigForm(form, pick) {
    const render = (field) => renderers[field.type](field, pick);
    return form.flatMap((entry) =>
        Array.isArray(entry) ? [element("div", { class: "config-section" }, ...entry.flatMap(render))] : render(entry),
    );
}
 
export function showValue(field, value) {
    const control = document.getElementById(controlId(field));
    switch (field.type) {
        case "menu":
            control.firstChild.textContent = field.describe
                ? field.describe(value)
                : field.options.find((option) => option.value === value).text;
            break;
        case "check":
            control.checked = !!value;
            break;
        case "range":
            control.value = value;
            break;
    }
}