diff --git a/dash-apps/web-compose/README.md b/dash-apps/web-compose/README.md index 36bd100..a3f99ed 100644 --- a/dash-apps/web-compose/README.md +++ b/dash-apps/web-compose/README.md @@ -54,6 +54,10 @@ every signal of an accepted kind, grouped by category, and each prop becomes the control its type implies. A new widget folder is fully editable with no editor changes. +Every widget can have a border, set in its settings (color and 1-6 px). It is +stored on the item as `"border": { "color", "width" }`, outside `props`, so it +works for every widget and older layouts and app versions ignore it. + Each orientation has its own layout: editing landscape leaves portrait alone. ## Adding a widget diff --git a/dash-apps/web-compose/core/editor.js b/dash-apps/web-compose/core/editor.js index 9d0b7c1..69c23ae 100644 --- a/dash-apps/web-compose/core/editor.js +++ b/dash-apps/web-compose/core/editor.js @@ -446,6 +446,60 @@ return control; } + // The border belongs to the cell, not the widget, so every widget gets it. + // A switch turns it on and off; color and width show only while it is on and + // are remembered while it is off. + function borderControls(item, onChange) { + const pick = { + color: (item.border && item.border.color) || "#3b82f6", + width: (item.border && item.border.width) || 2, + }; + const apply = () => { + item.border = { color: pick.color, width: pick.width }; + onChange(); + }; + + const toggle = el("input", "field__control field__control--check"); + toggle.type = "checkbox"; + toggle.checked = !!item.border; + + const picker = el("input", "field__control"); + picker.type = "color"; + picker.value = pick.color; + picker.addEventListener("input", () => { + pick.color = picker.value; + apply(); + }); + + const width = el("select", "field__control"); + for (let w = 1; w <= 6; w++) { + const option = el("option", null, w + " px"); + option.value = w; + width.appendChild(option); + } + width.value = pick.width; + width.addEventListener("change", () => { + pick.width = Number(width.value); + apply(); + }); + + const colorRow = field(t("editor.borderColor"), picker); + const widthRow = field(t("editor.borderWidth"), width); + const show = () => { + colorRow.hidden = widthRow.hidden = !toggle.checked; + }; + show(); + toggle.addEventListener("change", () => { + show(); + if (toggle.checked) apply(); + else { + delete item.border; + onChange(); + } + }); + return [field(t("editor.border"), toggle), colorRow, widthRow]; + } + function openSettings(item) { const entry = DC.registry.get(item.type); if (!entry) return; @@ -473,8 +527,8 @@ body.appendChild(field(label, bindControl(item, bind, refresh))); } } + body.appendChild(el("h3", "sheet__section", t("editor.appearance"))); if ((descriptor.props || []).length) { - body.appendChild(el("h3", "sheet__section", t("editor.appearance"))); for (const prop of descriptor.props) { const control = propControl(item, prop, refresh); if (prop.key === "label") { @@ -484,6 +538,7 @@ body.appendChild(field(DC.i18n.localized(prop.name), control)); } } + for (const row of borderControls(item, refresh)) body.appendChild(row); } // ---------- document edits ---------- diff --git a/dash-apps/web-compose/core/i18n.js b/dash-apps/web-compose/core/i18n.js index 8fe2c2e..1b0536c 100644 --- a/dash-apps/web-compose/core/i18n.js +++ b/dash-apps/web-compose/core/i18n.js @@ -52,6 +52,9 @@ "editor.shows": "Shows", "editor.type": "Type", "editor.appearance": "Appearance", + "editor.border": "Border", + "editor.borderColor": "Color", + "editor.borderWidth": "Width", "editor.nospace": "No free space — remove a widget first", "editor.full": "No space", "editor.hint": "Drag to move, corner to resize, tap for settings", @@ -116,6 +119,9 @@ "editor.shows": "Mit mutat", "editor.type": "Típus", "editor.appearance": "Megjelenés", + "editor.border": "Keret", + "editor.borderColor": "Szín", + "editor.borderWidth": "Vastagság", "editor.nospace": "Nincs szabad hely — előbb távolíts el egy elemet", "editor.full": "Nincs hely", "editor.hint": "Húzd a mozgatáshoz, a sarkát a méretezéshez, koppints a beállításokhoz", @@ -176,6 +182,9 @@ "editor.shows": "Zeigt", "editor.type": "Typ", "editor.appearance": "Darstellung", + "editor.border": "Rahmen", + "editor.borderColor": "Farbe", + "editor.borderWidth": "Breite", "editor.nospace": "Kein freier Platz — zuerst ein Element entfernen", "editor.full": "Kein Platz", "editor.hint": "Ziehen zum Verschieben, Ecke zum Skalieren, tippen für Einstellungen", diff --git a/dash-apps/web-compose/core/layout.js b/dash-apps/web-compose/core/layout.js index d352b31..9bef5c7 100644 --- a/dash-apps/web-compose/core/layout.js +++ b/dash-apps/web-compose/core/layout.js @@ -8,7 +8,8 @@ // "portrait": { "cols": 6, "rows": 12, "items": [ item, ... ] } // } // } -// item: { "uid", "type", "x", "y", "w", "h", "bind": { key: signalId }, "props": {} } +// item: { "uid", "type", "x", "y", "w", "h", "bind": { key: signalId }, "props": {}, +// "border": { "color": "#rrggbb", "width": 1-6 } (optional) } // // Compatibility rules (the document outlives app versions in both directions): // - unknown widget type -> kept, rendered as a placeholder diff --git a/dash-apps/web-compose/core/runtime.js b/dash-apps/web-compose/core/runtime.js index b08b41a..e281e2d 100644 --- a/dash-apps/web-compose/core/runtime.js +++ b/dash-apps/web-compose/core/runtime.js @@ -49,6 +49,12 @@ cell.dataset.uid = item.uid; cell.dataset.type = item.type; cell.style.gridArea = (item.y + 1) + " / " + (item.x + 1) + " / span " + item.h + " / span " + item.w; + const border = item.border; + if (border && typeof border.color === "string" && border.color) { + cell.dataset.border = "1"; + cell.style.setProperty("--border-color", border.color); + cell.style.setProperty("--border-width", Math.min(6, Math.max(1, Number(border.width) || 2)) + "px"); + } // The body is the size container; the widget renders into an inner root so // its own @container rules can match it (a container can't query itself). const body = document.createElement("div"); diff --git a/dash-apps/web-compose/styles/compose.css b/dash-apps/web-compose/styles/compose.css index 4061e06..4035a56 100644 --- a/dash-apps/web-compose/styles/compose.css +++ b/dash-apps/web-compose/styles/compose.css @@ -59,6 +59,15 @@ html, body { border-radius: var(--radius); overflow: hidden; } +/* Drawn above the widget so a widget's own background can't hide it. */ +.cell[data-border]::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + box-shadow: inset 0 0 0 var(--border-width) var(--border-color); + pointer-events: none; +} .cell__body { position: absolute; inset: 0; container-type: size; } .widget { position: absolute; inset: 0; } diff --git a/dash-apps/web-compose/styles/editor.css b/dash-apps/web-compose/styles/editor.css index 1807e65..d5fe6a9 100644 --- a/dash-apps/web-compose/styles/editor.css +++ b/dash-apps/web-compose/styles/editor.css @@ -147,6 +147,8 @@ border-bottom: 1px solid color-mix(in srgb, var(--track) 60%, transparent); font-size: 14px; } +/* display:flex above would otherwise win over the hidden attribute. */ +.field[hidden] { display: none; } .field__label { color: var(--text-dim); } .field__control { appearance: none; diff --git a/dash-apps/web-compose/widgets/_generated.css b/dash-apps/web-compose/widgets/_generated.css index a76b97c..fba5963 100644 --- a/dash-apps/web-compose/widgets/_generated.css +++ b/dash-apps/web-compose/widgets/_generated.css @@ -62,6 +62,84 @@ .bar__track { flex: 1; height: clamp(6px, 16cqh, 14px); } } +/* --- battery */ +.w-battery { + --level-color: var(--text-dim); + display: flex; + flex-direction: column; + justify-content: center; + gap: 4px; + padding: clamp(8px, 7cqmin, 24px); +} +.w-battery[data-level="ok"] { --level-color: var(--accent, #22c55e); } +.w-battery[data-level="low"] { --level-color: #f59e0b; } +.w-battery[data-level="critical"] { --level-color: var(--danger); } +.battery__label { + color: var(--text-dim); + font-size: clamp(11px, 11cqmin, 22px); + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.battery__row { + display: flex; + align-items: center; + gap: 0.6em; + min-width: 0; + /* ~5.6em of content (icon + gap + "100%"): size by width as well as height. */ + font-size: clamp(10px, min(28cqh, 14cqw), 30px); +} +.battery__icon { + display: flex; + align-items: center; + flex: none; + height: 0.75em; + width: 1.9em; +} +.battery__shell { + position: relative; + flex: 1; + height: 100%; + padding: max(1.5px, 0.07em); + border: max(1.5px, 0.08em) solid var(--level-color); + border-radius: 0.22em; + transition: border-color 120ms linear; +} +.battery__fill { + width: 100%; + height: 100%; + background: var(--level-color); + border-radius: 0.1em; + transform-origin: left center; + transform: scaleX(0); + will-change: transform; + transition: background-color 120ms linear; +} +.battery__cap { + flex: none; + width: 0.1em; + height: 0.34em; + margin-inline-start: 1px; + border-radius: 0 0.1em 0.1em 0; + background: var(--level-color); +} +.battery__value { + color: var(--text); + font-size: 0.9em; + font-weight: 600; + font-variant-numeric: tabular-nums; + line-height: 1; + white-space: nowrap; +} +.battery__unit { + color: var(--text-dim); + font-size: 0.45em; + margin-inline-start: 0.3em; +} +/* No room for a label next to the icon: drop it. */ +@container (max-height: 60px) { .battery__label { display: none; } } + /* --- lamp */ .w-lamp { display: flex; diff --git a/dash-apps/web-compose/widgets/_generated.js b/dash-apps/web-compose/widgets/_generated.js index 6e13cd9..08c99a2 100644 --- a/dash-apps/web-compose/widgets/_generated.js +++ b/dash-apps/web-compose/widgets/_generated.js @@ -85,6 +85,90 @@ } return create; })()); + // --- battery + register({"type":"battery","version":1,"renderer":"dom","name":{"en":"Battery","hu":"Akkumulátor","de":"Batterie"},"defaultSize":{"w":2,"h":1},"minSize":{"w":1,"h":1},"binds":[{"key":"value","kinds":["number"],"name":{"en":"Value","hu":"Érték","de":"Wert"},"default":"batteryPercent"}],"props":[{"key":"label","type":"string","default":"","name":{"en":"Label","hu":"Felirat","de":"Beschriftung"}},{"key":"showLabel","type":"bool","default":true,"name":{"en":"Show label","hu":"Felirat mutatása","de":"Beschriftung anzeigen"}},{"key":"showValue","type":"bool","default":true,"name":{"en":"Show value","hu":"Érték mutatása","de":"Wert anzeigen"}},{"key":"accent","type":"color","default":"","name":{"en":"Full color","hu":"Teli szín","de":"Farbe bei voll"}}]}, (function () { + // Battery icon: a shell whose fill follows the signal and changes color as the + // charge drops (low <= 20 %, critical <= 10 %, like the car's own indicator). + // The fill is one transform and the color one data attribute, each written + // only when it changes. + const LOW = 20, CRITICAL = 10; + + function create(ctx) { + const { el, props } = ctx; + if (props.accent) el.style.setProperty("--accent", props.accent); + + let label = null; + if (props.showLabel) { + label = document.createElement("div"); + label.className = "battery__label"; + label.textContent = props.label || ctx.label("value"); + } else { + el.dataset.bare = "1"; + } + + const icon = document.createElement("div"); + icon.className = "battery__icon"; + const shell = document.createElement("div"); + shell.className = "battery__shell"; + const fill = document.createElement("div"); + fill.className = "battery__fill"; + shell.appendChild(fill); + const cap = document.createElement("div"); + cap.className = "battery__cap"; + icon.append(shell, cap); + + const row = document.createElement("div"); + row.className = "battery__row"; + row.appendChild(icon); + + let text = null; + if (props.showValue) { + const value = document.createElement("span"); + value.className = "battery__value"; + text = document.createTextNode(""); + const unit = document.createElement("span"); + unit.className = "battery__unit"; + unit.textContent = ctx.unit("value"); + value.append(text, unit); + row.appendChild(value); + } + + if (label) el.append(label); + el.append(row); + el.dataset.level = "none"; + + const ratio = (v) => { + const [min, max] = ctx.range("value") || [0, 100]; + return max > min ? Math.min(1, Math.max(0, (v - min) / (max - min))) : 0; + }; + + let lastText = null, lastTransform = null, lastLevel = "none"; + return { + update(values) { + const v = values.value; + const r = v === undefined ? 0 : Math.round(ratio(v) * 100) / 100; + const transform = "scaleX(" + r + ")"; + if (transform !== lastTransform) { + lastTransform = transform; + fill.style.transform = transform; + } + const level = v === undefined ? "none" : r * 100 <= CRITICAL ? "critical" : r * 100 <= LOW ? "low" : "ok"; + if (level !== lastLevel) { + lastLevel = level; + el.dataset.level = level; + } + if (text) { + const next = ctx.format("value", v); + if (next !== lastText) { + lastText = next; + text.nodeValue = next; + } + } + }, + }; + } + return create; + })()); // --- lamp register({"type":"lamp","version":1,"renderer":"dom","name":{"en":"Indicator","hu":"Jelzőfény","de":"Kontrollleuchte"},"defaultSize":{"w":2,"h":2},"minSize":{"w":1,"h":1},"binds":[{"key":"value","kinds":["bool"],"name":{"en":"Condition","hu":"Feltétel","de":"Bedingung"},"default":"adasOn"},{"key":"steering","kinds":["number"],"default":"egoSteeringAngle","hidden":true}],"props":[{"key":"label","type":"string","default":"","name":{"en":"Label","hu":"Felirat","de":"Beschriftung"}},{"key":"icon","type":"enum","default":"dot","options":["dot","arrow-left","arrow-right","warning","wheel"],"name":{"en":"Icon","hu":"Ikon","de":"Symbol"}},{"key":"color","type":"color","default":"#22c55e","name":{"en":"Active color","hu":"Aktív szín","de":"Aktive Farbe"}},{"key":"blink","type":"bool","default":false,"name":{"en":"Blink when active","hu":"Villogjon aktívan","de":"Blinken wenn aktiv"}},{"key":"hideWhenOff","type":"bool","default":false,"name":{"en":"Hide when off","hu":"Rejtve, ha nem aktív","de":"Ausblenden wenn aus"}},{"key":"showLabel","type":"bool","default":false,"name":{"en":"Show label","hu":"Felirat mutatása","de":"Beschriftung anzeigen"}}]}, (function () { // On/off indicator for any bool signal. The whole visual state is one diff --git a/dash-apps/web-compose/widgets/battery/descriptor.json b/dash-apps/web-compose/widgets/battery/descriptor.json new file mode 100644 index 0000000..c9201a6 --- /dev/null +++ b/dash-apps/web-compose/widgets/battery/descriptor.json @@ -0,0 +1,74 @@ +{ + "type": "battery", + "version": 1, + "renderer": "dom", + "name": { + "en": "Battery", + "hu": "Akkumulátor", + "de": "Batterie" + }, + "defaultSize": { + "w": 2, + "h": 1 + }, + "minSize": { + "w": 1, + "h": 1 + }, + "binds": [ + { + "key": "value", + "kinds": [ + "number" + ], + "name": { + "en": "Value", + "hu": "Érték", + "de": "Wert" + }, + "default": "batteryPercent" + } + ], + "props": [ + { + "key": "label", + "type": "string", + "default": "", + "name": { + "en": "Label", + "hu": "Felirat", + "de": "Beschriftung" + } + }, + { + "key": "showLabel", + "type": "bool", + "default": true, + "name": { + "en": "Show label", + "hu": "Felirat mutatása", + "de": "Beschriftung anzeigen" + } + }, + { + "key": "showValue", + "type": "bool", + "default": true, + "name": { + "en": "Show value", + "hu": "Érték mutatása", + "de": "Wert anzeigen" + } + }, + { + "key": "accent", + "type": "color", + "default": "", + "name": { + "en": "Full color", + "hu": "Teli szín", + "de": "Farbe bei voll" + } + } + ] +} diff --git a/dash-apps/web-compose/widgets/battery/preview.svg b/dash-apps/web-compose/widgets/battery/preview.svg new file mode 100644 index 0000000..2e89d06 --- /dev/null +++ b/dash-apps/web-compose/widgets/battery/preview.svg @@ -0,0 +1 @@ +Label63% diff --git a/dash-apps/web-compose/widgets/battery/widget.css b/dash-apps/web-compose/widgets/battery/widget.css new file mode 100644 index 0000000..6770840 --- /dev/null +++ b/dash-apps/web-compose/widgets/battery/widget.css @@ -0,0 +1,76 @@ +.w-battery { + --level-color: var(--text-dim); + display: flex; + flex-direction: column; + justify-content: center; + gap: 4px; + padding: clamp(8px, 7cqmin, 24px); +} +.w-battery[data-level="ok"] { --level-color: var(--accent, #22c55e); } +.w-battery[data-level="low"] { --level-color: #f59e0b; } +.w-battery[data-level="critical"] { --level-color: var(--danger); } +.battery__label { + color: var(--text-dim); + font-size: clamp(11px, 11cqmin, 22px); + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.battery__row { + display: flex; + align-items: center; + gap: 0.6em; + min-width: 0; + /* ~5.6em of content (icon + gap + "100%"): size by width as well as height. */ + font-size: clamp(10px, min(28cqh, 14cqw), 30px); +} +.battery__icon { + display: flex; + align-items: center; + flex: none; + height: 0.75em; + width: 1.9em; +} +.battery__shell { + position: relative; + flex: 1; + height: 100%; + padding: max(1.5px, 0.07em); + border: max(1.5px, 0.08em) solid var(--level-color); + border-radius: 0.22em; + transition: border-color 120ms linear; +} +.battery__fill { + width: 100%; + height: 100%; + background: var(--level-color); + border-radius: 0.1em; + transform-origin: left center; + transform: scaleX(0); + will-change: transform; + transition: background-color 120ms linear; +} +.battery__cap { + flex: none; + width: 0.1em; + height: 0.34em; + margin-inline-start: 1px; + border-radius: 0 0.1em 0.1em 0; + background: var(--level-color); +} +.battery__value { + color: var(--text); + font-size: 0.9em; + font-weight: 600; + font-variant-numeric: tabular-nums; + line-height: 1; + white-space: nowrap; +} +.battery__unit { + color: var(--text-dim); + font-size: 0.45em; + margin-inline-start: 0.3em; +} +/* No room for a label next to the icon: drop it. */ +@container (max-height: 60px) { .battery__label { display: none; } } diff --git a/dash-apps/web-compose/widgets/battery/widget.js b/dash-apps/web-compose/widgets/battery/widget.js new file mode 100644 index 0000000..9cd75b8 --- /dev/null +++ b/dash-apps/web-compose/widgets/battery/widget.js @@ -0,0 +1,80 @@ +// Battery icon: a shell whose fill follows the signal and changes color as the +// charge drops (low <= 20 %, critical <= 10 %, like the car's own indicator). +// The fill is one transform and the color one data attribute, each written +// only when it changes. +const LOW = 20, CRITICAL = 10; + +function create(ctx) { + const { el, props } = ctx; + if (props.accent) el.style.setProperty("--accent", props.accent); + + let label = null; + if (props.showLabel) { + label = document.createElement("div"); + label.className = "battery__label"; + label.textContent = props.label || ctx.label("value"); + } else { + el.dataset.bare = "1"; + } + + const icon = document.createElement("div"); + icon.className = "battery__icon"; + const shell = document.createElement("div"); + shell.className = "battery__shell"; + const fill = document.createElement("div"); + fill.className = "battery__fill"; + shell.appendChild(fill); + const cap = document.createElement("div"); + cap.className = "battery__cap"; + icon.append(shell, cap); + + const row = document.createElement("div"); + row.className = "battery__row"; + row.appendChild(icon); + + let text = null; + if (props.showValue) { + const value = document.createElement("span"); + value.className = "battery__value"; + text = document.createTextNode(""); + const unit = document.createElement("span"); + unit.className = "battery__unit"; + unit.textContent = ctx.unit("value"); + value.append(text, unit); + row.appendChild(value); + } + + if (label) el.append(label); + el.append(row); + el.dataset.level = "none"; + + const ratio = (v) => { + const [min, max] = ctx.range("value") || [0, 100]; + return max > min ? Math.min(1, Math.max(0, (v - min) / (max - min))) : 0; + }; + + let lastText = null, lastTransform = null, lastLevel = "none"; + return { + update(values) { + const v = values.value; + const r = v === undefined ? 0 : Math.round(ratio(v) * 100) / 100; + const transform = "scaleX(" + r + ")"; + if (transform !== lastTransform) { + lastTransform = transform; + fill.style.transform = transform; + } + const level = v === undefined ? "none" : r * 100 <= CRITICAL ? "critical" : r * 100 <= LOW ? "low" : "ok"; + if (level !== lastLevel) { + lastLevel = level; + el.dataset.level = level; + } + if (text) { + const next = ctx.format("value", v); + if (next !== lastText) { + lastText = next; + text.nodeValue = next; + } + } + }, + }; +}