123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288 | // Schema-driven settings form, shared by the settings window and the
// per-widget right-click popup on the bar. One schema entry renders as a
// row (label + control) reporting coerced values through
// onChange(key, value); values resolve as options[key] ?? schema.default,
// and the caller owns persistence.
//
// renderSettingsRows(container, { schemas, options, invoke, onChange })
// hasFocusedControl(container) -> bool // popups' hold-open check
//
// Types: boolean, select, font, color, number, location, text (default).
//
// The module owns all form-control CSS: controls are drawn by hand
// (WebKitGTK's native ones render unreliably on dark pages), sized in em
// so each host's font size scales them, and themed via --form-*
// variables — the defaults match the bar's glass palette; the settings
// window overrides them in settings.css.
const SELECT_ARROW = `url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="10" height="6" viewBox="0 0 10 6"><path d="M1 1l4 4 4-4" fill="none" stroke="%239aa3b2" stroke-width="1.5" stroke-linecap="round"/></svg>')`;
const CSS = `
.dform-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1em;
}
.dform-row > label {
color: var(--form-label, var(--bar-fg-dim, #9aa3b2));
white-space: nowrap;
}
.dform-row input[type="text"],
.dform-row input[type="number"],
.dform-row select {
font: inherit;
color: var(--form-fg, var(--bar-fg, #e6e9ef));
background-color: var(--form-control-bg, rgba(255, 255, 255, 0.06));
border: 1px solid var(--form-border, rgba(255, 255, 255, 0.12));
border-radius: 0.4em;
padding: 0.2em 0.5em;
}
.dform-row input[type="number"] { width: 4.7em; }
.dform-row select {
-webkit-appearance: none;
appearance: none;
padding-right: 1.5em;
background-image: ${SELECT_ARROW};
background-repeat: no-repeat;
background-position: right 0.5em center;
}
.dform-row select option {
background: var(--form-option-bg, var(--bar-bg, #1c1f28));
color: var(--form-fg, var(--bar-fg, #e6e9ef));
}
.dform-row input[type="checkbox"] {
-webkit-appearance: none;
appearance: none;
width: 1.1em;
height: 1.1em;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 0.3em;
background: var(--form-control-bg, rgba(255, 255, 255, 0.06));
cursor: pointer;
display: grid;
place-content: center;
}
.dform-row input[type="checkbox"]:checked {
background: var(--form-accent, var(--accent, #7aa2f7));
border-color: var(--form-accent, var(--accent, #7aa2f7));
}
.dform-row input[type="checkbox"]:checked::after {
content: "";
width: 0.55em;
height: 0.3em;
border-left: 0.12em solid #10121a;
border-bottom: 0.12em solid #10121a;
transform: translateY(-1px) rotate(-45deg);
}
.dform-row input[type="color"] {
width: 2.9em;
height: 1.7em;
padding: 1px;
border: 1px solid var(--form-border, rgba(255, 255, 255, 0.12));
border-radius: 0.35em;
background: var(--form-control-bg, rgba(255, 255, 255, 0.06));
cursor: pointer;
}
.dform-row .font-select { max-width: 12.5em; }
.dform-row .loc-field {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.25em;
}
.dform-row .loc-field input { width: 13em; }
.dform-row .loc-status {
color: var(--form-fg-dim, var(--bar-fg-dim, #9aa3b2));
font-size: 0.85em;
max-width: 17em;
text-align: right;
}
.dform-row .loc-status.error {
color: var(--form-critical, var(--critical, #e06c75));
}
`;
let stylesInjected = false;
/// Renders one .dform-row (label + control) per schema entry into
/// `container`. Values resolve as options[key] ?? schema.default;
/// changes report as onChange(key, value), coerced per type.
export function renderSettingsRows(container, { schemas, options, invoke, onChange }) {
if (!stylesInjected) {
stylesInjected = true;
const style = document.createElement("style");
style.dataset.lib = "settings-form";
style.textContent = CSS;
document.head.appendChild(style);
}
for (const schema of schemas) {
const row = el("div", "dform-row");
row.append(el("label", "", schema.label ?? schema.key));
const current = options[schema.key] ?? schema.default;
row.append(
createSettingInput(schema, current, {
invoke,
onChange: (value) => onChange(schema.key, value),
}),
);
container.append(row);
}
}
/// Whether a control needing sustained focus (typing in a field, an open
/// select or color dialog) inside `container` is active — popups stay
/// open while this holds, even if the pointer wanders off.
export function hasFocusedControl(container) {
const active = document.activeElement;
return Boolean(
container?.contains(active) &&
active.matches('input[type="text"], input[type="number"], input[type="color"], select'),
);
}
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text != null) node.textContent = text;
return node;
}
// Installed font families, fetched once and shared by all font pickers.
let fontsPromise = null;
function loadFonts(invoke) {
fontsPromise ??= invoke("list_fonts").catch((err) => {
console.error("divebar: list_fonts failed", err);
return null;
});
return fontsPromise;
}
function createSettingInput(schema, current, { invoke, onChange }) {
if (schema.type === "boolean") {
const input = el("input");
input.type = "checkbox";
input.checked = Boolean(current);
input.addEventListener("change", () => onChange(input.checked));
return input;
}
if (schema.type === "select") {
const input = el("select");
for (const choice of schema.choices ?? []) {
const opt = el("option", "", choice);
opt.value = choice;
input.append(opt);
}
input.value = current;
input.addEventListener("change", () => onChange(input.value));
return input;
}
if (schema.type === "font") {
const input = el("select", "font-select");
const defaultOpt = el("option", "", "(default)");
defaultOpt.value = "";
input.append(defaultOpt);
if (current) {
// Keep the saved value selectable before the font list arrives.
const opt = el("option", "", current);
opt.value = current;
input.append(opt);
}
input.value = current ?? "";
loadFonts(invoke).then((fonts) => {
if (!fonts || !input.isConnected) return;
input.replaceChildren(defaultOpt);
for (const family of fonts) {
const opt = el("option", "", family);
opt.value = family;
opt.style.fontFamily = `"${family}"`;
input.append(opt);
}
input.value = current ?? "";
});
input.addEventListener("change", () => {
input.style.fontFamily = input.value ? `"${input.value}"` : "";
onChange(input.value);
});
if (current) input.style.fontFamily = `"${current}"`;
return input;
}
if (schema.type === "color") {
const input = el("input");
input.type = "color";
input.value = current || schema.default;
input.addEventListener("change", () => onChange(input.value));
return input;
}
if (schema.type === "number") {
const input = el("input");
input.type = "number";
if (schema.min != null) input.min = schema.min;
if (schema.max != null) input.max = schema.max;
input.value = current;
input.addEventListener("change", () => {
let v = Number(input.value);
if (Number.isNaN(v)) v = schema.default;
if (schema.min != null) v = Math.max(schema.min, v);
if (schema.max != null) v = Math.min(schema.max, v);
input.value = v;
onChange(v);
});
return input;
}
if (schema.type === "location") {
return locationInput(schema, current, { invoke, onChange });
}
const input = el("input");
input.type = "text";
input.value = current ?? "";
input.addEventListener("change", () => onChange(input.value));
return input;
}
// Location field with live feedback: a status line shows the place the
// typed value (or IP auto-detection, when empty) actually resolves to,
// so a bad geocode ("Brooklyn" → the Bahamas) is visible immediately.
function locationInput(schema, current, { invoke, onChange }) {
const wrap = el("div", "loc-field");
const text = el("input");
text.type = "text";
text.placeholder = schema.placeholder ?? "";
text.value = current ?? "";
const status = el("div", "loc-status");
let seq = 0;
const preview = async () => {
const mySeq = ++seq;
status.className = "loc-status";
status.textContent = "Looking up…";
try {
const r = await invoke("resolve_location", { location: text.value });
if (mySeq !== seq || !text.isConnected) return;
const place = [r.area, r.region, r.country].filter(Boolean).join(", ");
status.textContent = `${text.value.trim() ? "→" : "Auto-detected:"} ${place}`;
} catch (err) {
if (mySeq !== seq) return;
status.textContent = String(err);
status.classList.add("error");
}
};
let previewTimer = null;
text.addEventListener("input", () => {
clearTimeout(previewTimer);
previewTimer = setTimeout(preview, 800);
});
text.addEventListener("change", () => onChange(text.value));
wrap.append(text, status);
preview();
return wrap;
}
|