mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 12:18:51 +00:00
v1.59.0-beta.2: live text labels, text block frame, warm-memo owners
This commit is contained in:
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.59.0-beta.1"
|
||||
VERSION = "1.59.0-beta.2"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.59.0-beta.1"
|
||||
"version": "1.59.0-beta.2"
|
||||
}
|
||||
|
||||
@@ -220,6 +220,22 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
# Live text on a decor label (docs/LIVE-TEXT.md). An entity id is
|
||||
# `<domain>.<object_id>`; HA itself allows only lowercase letters, digits and
|
||||
# underscores in both halves. The bound is a sanity limit, not a policy.
|
||||
MAX_ENTITY_ID = 255
|
||||
_ENTITY_ID = vol.All(str, vol.Length(min=3, max=MAX_ENTITY_ID),
|
||||
vol.Match(r"^[a-z0-9_]+\.[a-z0-9_]+$"))
|
||||
# A caption is a caption: the template is bounded, the attribute name is a flat
|
||||
# name (no dots needed), and a unit is a couple of characters, not a sentence.
|
||||
MAX_DECOR_TEXT = 200
|
||||
MAX_DECOR_ATTR = 64
|
||||
MAX_DECOR_UNIT = 16
|
||||
# The text block is scaled by dragging its corners; the range is what a human
|
||||
# could mean on a 1000-unit canvas, the rest is garbage insurance.
|
||||
DECOR_TEXT_SCALE_MIN = 0.15
|
||||
DECOR_TEXT_SCALE_MAX = 20.0
|
||||
|
||||
_DECOR_COMMON = {
|
||||
vol.Required("id"): str,
|
||||
vol.Optional("color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
|
||||
@@ -240,8 +256,25 @@ DECOR_SCHEMA = vol.Any(
|
||||
extra=vol.ALLOW_EXTRA),
|
||||
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "text",
|
||||
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
|
||||
vol.Required("text"): vol.All(str, vol.Length(min=1, max=200)),
|
||||
vol.Optional("size"): vol.In(["s", "m", "l"])},
|
||||
# the template: newlines are the user's own line breaks and are
|
||||
# kept verbatim (docs/LIVE-TEXT.md); the label never wraps itself
|
||||
vol.Required("text"): vol.All(str, vol.Length(min=1, max=MAX_DECOR_TEXT)),
|
||||
# legacy font size ('s'|'m'|'l'). The dialog no longer offers it
|
||||
# — the block is scaled by its corner handles — but a plan
|
||||
# written before that keeps it, and it is read as the scale it
|
||||
# used to render at. Kept in the schema so it stays BOUNDED.
|
||||
vol.Optional("size"): vol.In(["s", "m", "l"]),
|
||||
# size and rotation of the block (docs/LIVE-TEXT.md §block).
|
||||
# Both optional: a shape without them is the old one exactly.
|
||||
vol.Optional("scale"): vol.All(
|
||||
_finite, vol.Range(min=DECOR_TEXT_SCALE_MIN, max=DECOR_TEXT_SCALE_MAX)),
|
||||
vol.Optional("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
|
||||
# live text: the label may show one entity's value. All three
|
||||
# optional, so every existing plan validates unchanged and no
|
||||
# migration runs.
|
||||
vol.Optional("entity"): vol.Any(None, _ENTITY_ID),
|
||||
vol.Optional("attr"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DECOR_ATTR))),
|
||||
vol.Optional("unit"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DECOR_UNIT)))},
|
||||
extra=vol.ALLOW_EXTRA),
|
||||
)
|
||||
|
||||
|
||||
@@ -86,8 +86,10 @@ const res = await page.evaluate(async () => {
|
||||
c._decorTool = 'select';
|
||||
await c.updateComplete;
|
||||
out.stillThreeShapes = c._decorList.length === 3;
|
||||
// 4) надпись через диалог
|
||||
c._decorTextDialog = { x: 0.5, y: 0.5, text: 'Сауна', size: 'l', color: '#0000ff' };
|
||||
// 4) надпись через диалог. Поля `size` в диалоге больше нет — размер задаётся
|
||||
// углами блока (см. smoke_decor_text); сохранённый старый `size` по-прежнему
|
||||
// читается как начальный масштаб.
|
||||
c._decorTextDialog = { x: 0.5, y: 0.5, text: 'Сауна', color: '#0000ff' };
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
out.textSaved = c._decorList.some((x) => x.kind === 'text' && x.text === 'Сауна');
|
||||
out.textRendered = [...sr().querySelectorAll('.decorlayer text')].some((t) => t.textContent.includes('Сауна'));
|
||||
@@ -151,7 +153,10 @@ const res = await page.evaluate(async () => {
|
||||
out[key] = !!c._decorDraft && c._decorDraft.kind === tool && c._decorSel === null;
|
||||
c._decorDraft = null;
|
||||
}
|
||||
// — «текст» по фигуре открывает диалог новой надписи, а не выделяет
|
||||
// — «текст» по ЛИНИИ открывает диалог новой надписи, а не выделяет её.
|
||||
// Инертность нетекстовых фигур под рисующими инструментами остаётся в силе;
|
||||
// единственное исключение — надпись под инструментом «текст», она открывает
|
||||
// СВОЮ форму (владелец 2026-08-04, пинится в smoke_decor_text).
|
||||
c._decorTool = 'text'; c._decorSel = null; c._decorTextDialog = null; await c.updateComplete;
|
||||
press(probe(), g * 12, g * 30, 43);
|
||||
await c.updateComplete;
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
// Текстовый блок декора (правки владельца 2026-08-04):
|
||||
// 1) выбора размера шрифта БОЛЬШЕ НЕТ — размер задаётся углами блока, а
|
||||
// сохранённый старый `size` читается как начальный масштаб;
|
||||
// 2) многострочный текст: перевод строки сохраняется и рисуется, блок
|
||||
// выравнивается по центру;
|
||||
// 3) у выделенного блока есть угловые ручки (масштаб) и ручка поворота
|
||||
// (шаг 5°, Shift — мимо шага);
|
||||
// 4) при инструменте «текст» клик по УЖЕ РАЗМЕЩЁННОЙ надписи открывает её
|
||||
// форму, а не создаёт новую; по пустому месту и по НЕтекстовой фигуре —
|
||||
// создаёт новую (нетекстовые фигуры остаются инертными, см. smoke_decor).
|
||||
// ПАДАЕТ на сборке до этих правок: в диалоге ещё радиокнопки размера, перевод
|
||||
// строки схлопывается в одну строку, ручек нет, а «текст» по надписи создаёт
|
||||
// вторую надпись поверх первой.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
// null-safe: на сборке ДО этих правок у надписи нет ни data-id, ни tspan —
|
||||
// смок должен показать список провалов, а не упасть с исключением
|
||||
const el = (id) => sr().querySelector(`.decorlayer text.dtext[data-id="${id}"]`);
|
||||
const attr = (id, name) => { const e = el(id); return e ? e.getAttribute(name) : null; };
|
||||
const tspans = (id) => { const e = el(id); return e ? [...e.querySelectorAll('tspan')] : []; };
|
||||
const stageEl = () => sr().querySelector('.stage');
|
||||
/** открыть форму существующей надписи (на старой сборке — двойным щелчком) */
|
||||
const open = (sh) => (c._decorOpenText ? c._decorOpenText(sh) : c._decorShapeDbl(sh));
|
||||
const toScreen = (x, y) => {
|
||||
const r = stageEl().getBoundingClientRect();
|
||||
const v = c._viewOr(c._baseVb());
|
||||
return { clientX: r.left + ((x - v.x) / v.w) * r.width,
|
||||
clientY: r.top + ((y - v.y) / v.h) * r.height };
|
||||
};
|
||||
const ev = (type, target, x, y, extra = {}) => {
|
||||
const { clientX, clientY } = toScreen(x, y);
|
||||
target.dispatchEvent(new PointerEvent(type, { bubbles: true, composed: true,
|
||||
cancelable: true, pointerId: 7, clientX, clientY, button: 0, isPrimary: true, ...extra }));
|
||||
};
|
||||
|
||||
sr().querySelectorAll('.modetab')[2].click(); await c.updateComplete;
|
||||
c._curSpaceCfg.decor = [];
|
||||
c._decorTool = 'select'; c._decorSel = null; await c.updateComplete;
|
||||
|
||||
// ================= 1. размера шрифта в диалоге больше нет ================
|
||||
c._decorTool = 'text';
|
||||
c._decorTextDialog = { x: 0.3, y: 0.3, text: '', color: '#223344' };
|
||||
await c.updateComplete;
|
||||
out.noSizePicker = !sr().querySelector('input[name="dtsize"]');
|
||||
out.textareaNotInput = !!sr().querySelector('.dialog textarea.dtarea');
|
||||
c._decorTextDialog = null; await c.updateComplete;
|
||||
|
||||
// старый `size` продолжает рисоваться ровно как раньше: 'l' = 30px
|
||||
c._curSpaceCfg.decor = [
|
||||
{ id: 'dtl', kind: 'text', x: 0.25, y: 0.25, text: 'Старый', size: 'l', color: '#223344' },
|
||||
{ id: 'dts', kind: 'text', x: 0.25, y: 0.4, text: 'Мелкий', size: 's', color: '#223344' },
|
||||
{ id: 'dtm', kind: 'text', x: 0.25, y: 0.55, text: 'Обычный', color: '#223344' },
|
||||
];
|
||||
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
|
||||
out.legacyLarge = +attr('dtl', 'font-size') === 30;
|
||||
out.legacySmall = +attr('dts', 'font-size') === 14;
|
||||
out.noSizeIsMedium = +attr('dtm', 'font-size') === 20;
|
||||
|
||||
// ================= 2. многострочный текст, по центру =====================
|
||||
c._decorTool = 'text';
|
||||
c._decorTextDialog = { x: 0.5, y: 0.5, text: ' Гараж\nпод ключ ', color: '#223344' };
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
const multi = c._decorList.find((x) => x.text && x.text.includes('\n'));
|
||||
out.newlineStored = !!multi && multi.text === 'Гараж\nпод ключ'; // края обрезаны, середина цела
|
||||
const tsp = tspans(multi.id);
|
||||
out.twoLinesRendered = tsp.length === 2
|
||||
&& tsp[0].textContent === 'Гараж' && tsp[1].textContent === 'под ключ';
|
||||
out.linesCentredHorizontally = tsp.length === 2 && !!el(multi.id)
|
||||
&& getComputedStyle(el(multi.id)).textAnchor === 'middle'
|
||||
&& tsp[0].getAttribute('x') === tsp[1].getAttribute('x');
|
||||
// блок центрирован и по вертикали: якорь ровно между строками
|
||||
const ay = multi.y * 1000;
|
||||
const y0 = tsp[0] ? +tsp[0].getAttribute('y') : NaN;
|
||||
const y1 = tsp[1] ? +tsp[1].getAttribute('y') : NaN;
|
||||
out.blockCentredVertically = Math.abs((y0 + y1) / 2 - ay) < 0.01 && y1 > y0;
|
||||
// длинная строка НЕ переносится сама (docs/LIVE-TEXT.md: никаких автопереносов)
|
||||
open(multi); await c.updateComplete;
|
||||
c._decorTextDialog = { ...c._decorTextDialog, text: 'x'.repeat(120) };
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
out.noAutoWrap = tspans(multi.id).length === 1;
|
||||
open(c._decorList.find((x) => x.id === multi.id)); await c.updateComplete;
|
||||
c._decorTextDialog = { ...c._decorTextDialog, text: 'Гараж\nпод ключ' };
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
|
||||
// ================= 3. ручки масштаба и поворота ==========================
|
||||
c._decorTool = 'select'; c._decorSel = null; await c.updateComplete;
|
||||
out.noFrameWithoutSelection = !sr().querySelector('.dtframe');
|
||||
c._decorSel = 'dtm'; c.requestUpdate();
|
||||
await c.updateComplete; await sleep(60); await c.updateComplete;
|
||||
const frame = () => sr().querySelector('.dtframe');
|
||||
out.frameOnSelection = !!frame();
|
||||
out.fourCornersAndRotate = !!frame()
|
||||
&& frame().querySelectorAll('.dthandle').length === 5
|
||||
&& !!frame().querySelector('.dtrot');
|
||||
// линия выделена — рамки текста нет (это рамка ТЕКСТОВОГО блока)
|
||||
c._curSpaceCfg.decor = [...c._decorList, { id: 'dline', kind: 'line',
|
||||
x1: 0.1, y1: 0.8, x2: 0.4, y2: 0.8, color: '#000000', width: 3 }];
|
||||
c._cfgEpoch++; c._decorSel = 'dline'; c.requestUpdate();
|
||||
await c.updateComplete; await sleep(40); await c.updateComplete;
|
||||
out.noFrameOnALine = !sr().querySelector('.dtframe');
|
||||
c._decorSel = 'dtm'; c.requestUpdate();
|
||||
await c.updateComplete; await sleep(60); await c.updateComplete;
|
||||
|
||||
// тянем угол: масштаб растёт пропорционально расстоянию от якоря
|
||||
const anchor = () => {
|
||||
const sh = c._decorList.find((x) => x.id === 'dtm');
|
||||
return [sh.x * 1000, sh.y * 1000];
|
||||
};
|
||||
const [ax, ay2] = anchor();
|
||||
const corner = frame()?.querySelector('.dthandle.dt-nwse') || stageEl();
|
||||
ev('pointerdown', corner, ax + 40, ay2);
|
||||
out.dragStarted = !!c._dtDrag && c._dtDrag.kind === 'scale';
|
||||
ev('pointermove', stageEl(), ax + 80, ay2);
|
||||
await c.updateComplete;
|
||||
const scaled = c._decorList.find((x) => x.id === 'dtm');
|
||||
out.cornerScales = Math.abs((scaled.scale || 0) - 2) < 0.01;
|
||||
out.fontFollowsScale = +attr('dtm', 'font-size') === 40;
|
||||
ev('pointerup', stageEl(), ax + 80, ay2);
|
||||
out.dragEnded = !c._dtDrag;
|
||||
|
||||
// старый `size` заменяется масштабом, который он означал (одна правда)
|
||||
c._decorSel = 'dtl'; c.requestUpdate();
|
||||
await c.updateComplete; await sleep(60); await c.updateComplete;
|
||||
const [lx, ly] = [c._decorList.find((x) => x.id === 'dtl').x * 1000,
|
||||
c._decorList.find((x) => x.id === 'dtl').y * 1000];
|
||||
ev('pointerdown', frame()?.querySelector('.dthandle.dt-nwse') || stageEl(), lx + 50, ly);
|
||||
ev('pointermove', stageEl(), lx + 100, ly);
|
||||
await c.updateComplete;
|
||||
const grown = c._decorList.find((x) => x.id === 'dtl');
|
||||
out.legacySizeBecomesScale = grown.size === undefined && Math.abs((grown.scale || 0) - 3) < 0.01;
|
||||
ev('pointerup', stageEl(), lx + 100, ly);
|
||||
|
||||
// ручка поворота: шаг 5°, Shift — мимо шага
|
||||
c._decorSel = 'dtm'; c.requestUpdate();
|
||||
await c.updateComplete; await sleep(60); await c.updateComplete;
|
||||
const [rx, ry] = anchor();
|
||||
const rot = frame()?.querySelector('.dthandle.dtrot') || stageEl();
|
||||
ev('pointerdown', rot, rx + 100, ry);
|
||||
out.rotateStarted = c._dtDrag?.kind === 'rotate';
|
||||
ev('pointermove', stageEl(), rx + 100, ry + 100); // ровно 45°
|
||||
await c.updateComplete;
|
||||
out.rotates = Math.abs((c._decorList.find((x) => x.id === 'dtm').angle || 0) - 45) < 1e-6;
|
||||
ev('pointermove', stageEl(), rx + 100, ry + 2); // ~1.1° → прилипает к 0
|
||||
await c.updateComplete;
|
||||
out.snapsTo5 = (c._decorList.find((x) => x.id === 'dtm').angle || 0) === 0;
|
||||
ev('pointermove', stageEl(), rx + 100, ry + 2, { shiftKey: true });
|
||||
await c.updateComplete;
|
||||
const free = c._decorList.find((x) => x.id === 'dtm').angle;
|
||||
out.shiftGoesFree = free > 0.5 && free < 5; // тот же жест, но точный угол
|
||||
ev('pointerup', stageEl(), rx + 100, ry + 2);
|
||||
await c.updateComplete;
|
||||
out.rotationRendered = /rotate\(/.test(attr('dtm', 'transform') || '');
|
||||
// …а без Shift тот же 1.1° снова прилипает к нулю
|
||||
// повернули обратно в ноль — поле исчезает, фигура снова «прямая»
|
||||
ev('pointerdown', frame()?.querySelector('.dthandle.dtrot') || stageEl(), rx + 100, ry);
|
||||
ev('pointermove', stageEl(), rx + 100, ry);
|
||||
ev('pointerup', stageEl(), rx + 100, ry);
|
||||
await c.updateComplete;
|
||||
out.zeroAngleDropped = c._decorList.find((x) => x.id === 'dtm').angle === undefined;
|
||||
|
||||
// ================= 4. инструмент «текст» по надписи = редактировать =======
|
||||
c._decorTool = 'text'; c._decorSel = null; c._decorTextDialog = null;
|
||||
await c.updateComplete;
|
||||
const before = c._decorList.length;
|
||||
const target = c._decorList.find((x) => x.id === 'dtm');
|
||||
out.labelIsLiveUnderTextTool = !!el('dtm') && getComputedStyle(el('dtm')).pointerEvents !== 'none';
|
||||
ev('pointerdown', el('dtm') || stageEl(), target.x * 1000, target.y * 1000);
|
||||
await c.updateComplete;
|
||||
out.textToolEditsTheLabel = c._decorTextDialog?.id === 'dtm'
|
||||
&& c._decorTextDialog.text === target.text;
|
||||
out.textToolMakesNoSecondLabel = c._decorList.length === before;
|
||||
out.textToolDoesNotSelect = c._decorSel === null;
|
||||
c._decorTextDialog = null; await c.updateComplete;
|
||||
|
||||
// по пустому месту — новая надпись
|
||||
ev('pointerdown', stageEl(), 60, 940);
|
||||
await c.updateComplete;
|
||||
out.textToolOnEmptyCreates = !!c._decorTextDialog && !c._decorTextDialog.id;
|
||||
c._decorTextDialog = null; await c.updateComplete;
|
||||
|
||||
// по НЕтекстовой фигуре — тоже новая надпись: она осталась инертной
|
||||
const lineEl = sr().querySelector('.decorlayer line.dshape');
|
||||
out.lineStillInert = getComputedStyle(lineEl).pointerEvents === 'none';
|
||||
ev('pointerdown', lineEl, 250, 800);
|
||||
await c.updateComplete;
|
||||
out.textToolOnAShapeCreates = !!c._decorTextDialog && !c._decorTextDialog.id
|
||||
&& c._decorSel === null;
|
||||
c._decorTextDialog = null;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,140 @@
|
||||
// Живой текст в декоре (docs/LIVE-TEXT.md): текстовая фигура может показывать
|
||||
// значение ОДНОЙ сущности. Шаблон — поле text, место значения — {}; единица по
|
||||
// умолчанию из сущности; мёртвая/отсутствующая сущность — прочерк «—»; ничего
|
||||
// не округляем и не переформатируем. Смок проверяет, что значение читается
|
||||
// живьём (новый hass — новый текст в DOM, без пересоздания карточки), что
|
||||
// редактор и просмотр рисуют одно и то же и что все три поля переживают
|
||||
// сохранение конфига.
|
||||
// ПАДАЕТ на сборке до этой фичи: подстановки нет, в DOM остаётся сырой шаблон.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
/** текст фигуры в DOM, склеенный из строк (tspan) */
|
||||
const dom = (id) => {
|
||||
const el = sr().querySelector(`.decorlayer text.dtext[data-id="${id}"]`);
|
||||
if (!el) return null;
|
||||
return [...el.querySelectorAll('tspan')].map((t) => t.textContent).join('\n');
|
||||
};
|
||||
/** открыть форму существующей надписи (на старой сборке — двойным щелчком) */
|
||||
const open = (sh) => (c._decorOpenText ? c._decorOpenText(sh) : c._decorShapeDbl(sh));
|
||||
/** подменить одно состояние — ровно так, как HA присылает новый hass */
|
||||
const push = async (id, st) => {
|
||||
const states = { ...c.hass.states };
|
||||
if (st === null) delete states[id]; else states[id] = st;
|
||||
c.hass = { ...c.hass, states };
|
||||
await c.updateComplete;
|
||||
};
|
||||
|
||||
sr().querySelectorAll('.modetab')[2].click(); await c.updateComplete;
|
||||
out.decorMode = c._mode === 'decor';
|
||||
c._curSpaceCfg.decor = [];
|
||||
|
||||
// ---------- 1. связанная и обычная надписи -------------------------------
|
||||
c._decorTextDialog = { x: 0.3, y: 0.3, text: 'Бак {}', color: '#112233',
|
||||
entity: 'sensor.living_temp', attr: '', unit: '' };
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
const live = c._decorList.find((x) => x.kind === 'text');
|
||||
out.linkStored = live.entity === 'sensor.living_temp';
|
||||
out.noEmptyFieldsStored = !('attr' in live) && !('unit' in live);
|
||||
out.templateStoredRaw = live.text === 'Бак {}'; // в конфиге — ШАБЛОН
|
||||
out.liveValueRendered = dom(live.id) === 'Бак 22.4 °C';
|
||||
|
||||
c._decorTextDialog = { x: 0.6, y: 0.3, text: 'Сауна', color: '#112233' };
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
const plain = c._decorList.find((x) => x.text === 'Сауна');
|
||||
out.plainUntouched = dom(plain.id) === 'Сауна' && !('entity' in plain);
|
||||
|
||||
// ---------- 2. значение живое: новый hass — новый текст -------------------
|
||||
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: '23.9',
|
||||
attributes: { unit_of_measurement: '°C', linkquality: 154 } });
|
||||
out.updatesOnState = dom(live.id) === 'Бак 23.9 °C';
|
||||
out.plainStillUntouched = dom(plain.id) === 'Сауна';
|
||||
// как отдаёт HA: ни округления, ни переформатирования
|
||||
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: '23.94781',
|
||||
attributes: { unit_of_measurement: '°C', linkquality: 154 } });
|
||||
out.noRounding = dom(live.id) === 'Бак 23.94781 °C';
|
||||
|
||||
// ---------- 3. мёртвая сущность — прочерк, шаблон остаётся ---------------
|
||||
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: 'unavailable',
|
||||
attributes: { unit_of_measurement: '°C' } });
|
||||
out.deadIsDash = dom(live.id) === 'Бак —';
|
||||
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: 'unknown', attributes: {} });
|
||||
out.unknownIsDash = dom(live.id) === 'Бак —';
|
||||
await push('sensor.living_temp', null);
|
||||
out.missingIsDash = dom(live.id) === 'Бак —';
|
||||
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: '22.4',
|
||||
attributes: { unit_of_measurement: '°C', linkquality: 154 } });
|
||||
out.backAlive = dom(live.id) === 'Бак 22.4 °C';
|
||||
|
||||
// ---------- 4. атрибут и единица ----------------------------------------
|
||||
open(live); await c.updateComplete;
|
||||
out.dialogHasTheLink = c._decorTextDialog?.id === live.id
|
||||
&& c._decorTextDialog?.entity === 'sensor.living_temp';
|
||||
c._decorTextDialog = { ...c._decorTextDialog, attr: 'linkquality' };
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
out.attrRead = dom(live.id) === 'Бак 154';
|
||||
open(c._decorList.find((x) => x.id === live.id)); await c.updateComplete;
|
||||
c._decorTextDialog = { ...c._decorTextDialog, unit: 'LQI' };
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
out.unitOverride = dom(live.id) === 'Бак 154 LQI';
|
||||
// атрибута нет — прочерк, но остальная надпись жива
|
||||
open(c._decorList.find((x) => x.id === live.id)); await c.updateComplete;
|
||||
c._decorTextDialog = { ...c._decorTextDialog, attr: 'nope', unit: '' };
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
out.missingAttrIsDash = dom(live.id) === 'Бак —';
|
||||
|
||||
// ---------- 5. без плейсхолдера значение уезжает в конец ------------------
|
||||
open(c._decorList.find((x) => x.id === live.id)); await c.updateComplete;
|
||||
c._decorTextDialog = { ...c._decorTextDialog, text: 'Бак', attr: '', unit: '' };
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
out.appendedWithoutSlot = dom(live.id) === 'Бак 22.4 °C';
|
||||
|
||||
// ---------- 6. отвязали — снова обычная надпись --------------------------
|
||||
open(c._decorList.find((x) => x.id === live.id)); await c.updateComplete;
|
||||
c._decorTextDialog = { ...c._decorTextDialog, text: 'Бак {}', entity: '' };
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
const unlinked = c._decorList.find((x) => x.id === live.id);
|
||||
out.linkCleared = !('entity' in unlinked) && !('attr' in unlinked) && !('unit' in unlinked);
|
||||
out.rawTemplateShown = dom(live.id) === 'Бак {}';
|
||||
|
||||
// ---------- 7. просмотр и киоск рисуют то же самое ------------------------
|
||||
open(unlinked); await c.updateComplete;
|
||||
c._decorTextDialog = { ...c._decorTextDialog, entity: 'sensor.living_temp' };
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
const editorText = dom(live.id);
|
||||
c._setMode('view'); await c.updateComplete; await sleep(60);
|
||||
out.sameInViewMode = dom(live.id) === editorText && editorText === 'Бак 22.4 °C';
|
||||
|
||||
const kiosk = document.createElement('houseplan-card');
|
||||
kiosk.setConfig({ type: 'custom:houseplan-card', kiosk: true });
|
||||
kiosk.hass = c.hass;
|
||||
document.body.appendChild(kiosk);
|
||||
await kiosk.updateComplete; await sleep(400); await kiosk.updateComplete;
|
||||
const kr = kiosk.shadowRoot || kiosk.renderRoot;
|
||||
const kEl = kr.querySelector(`.decorlayer text.dtext[data-id="${live.id}"]`);
|
||||
out.sameInKiosk = !!kEl
|
||||
&& [...kEl.querySelectorAll('tspan')].map((t) => t.textContent).join('\n') === editorText;
|
||||
kiosk.remove();
|
||||
|
||||
// ---------- 8. все три поля переживают запись конфига ---------------------
|
||||
const sent = [];
|
||||
const ws = c.hass.callWS;
|
||||
c.hass = { ...c.hass, callWS: async (m) => { sent.push(m); return ws(m); } };
|
||||
c._setMode('decor'); await c.updateComplete;
|
||||
open(c._decorList.find((x) => x.id === live.id)); await c.updateComplete;
|
||||
c._decorTextDialog = { ...c._decorTextDialog, attr: 'linkquality', unit: 'LQI' };
|
||||
c._decorSaveText();
|
||||
await sleep(900);
|
||||
const set = sent.filter((m) => m.type === 'houseplan/config/set').pop();
|
||||
const shape = set && (set.config.spaces.find((s) => s.id === c._space).decor || [])
|
||||
.find((x) => x.id === live.id);
|
||||
out.roundTrip = !!shape && shape.entity === 'sensor.living_temp'
|
||||
&& shape.attr === 'linkquality' && shape.unit === 'LQI' && shape.text === 'Бак {}';
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,117 @@
|
||||
// Регрессия аудита v1.59.0-beta.1 — владение тёплой памяткой.
|
||||
// Три находки, все три ПАДАЮТ на сборке v1.59.0-beta.1:
|
||||
// AUD-159B1-01 (P2): две карточки с ИДЕНТИЧНЫМ конфигом на одной странице
|
||||
// делили один слот памятки (ключ = размер окна × JSON конфига), поэтому
|
||||
// новый экземпляр на месте A усыновлял вид последнего писателя — соседа B
|
||||
// (его этаж, режим, зум), а черновик настоящего предшественника съедался
|
||||
// guard-ом `d.mode !== this._mode`.
|
||||
// AUD-159B1-02 (P2): быстрая серия пересозданий (A→B→C в одном такте):
|
||||
// промежуточный B ещё не успел воскресить диалог A, но в своём
|
||||
// disconnectedCallback сбрасывал `_warmRevivePending` ДО снимка и писал
|
||||
// `dlg: null` поверх чужого черновика — C не получал ничего.
|
||||
// AUD-159B1-03 (P3): 10-секундный TTL запрещал воскрешение, но не освобождал
|
||||
// payload: просроченная запись держала диалог (у пространства это план
|
||||
// целиком в base64) до перезагрузки страницы.
|
||||
import { launch, check, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 820, height: 760 });
|
||||
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const HP = customElements.get('houseplan-card');
|
||||
HP._warmBootReset(400); // TTL диалога 400мс вместо 10с — иначе смок стоит 10 секунд
|
||||
localStorage.removeItem('houseplan_card_zoom_v1');
|
||||
localStorage.removeItem('houseplan_card_nav_v1');
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
|
||||
document.body.appendChild(wrap);
|
||||
/** Lovelace: setConfig ДО вставки; `before` = вставить в чужой DOM-слот */
|
||||
const mk = (before) => {
|
||||
const c = document.createElement('houseplan-card');
|
||||
c.setConfig({ type: 'custom:houseplan-card' });
|
||||
c.hass = window.__mkHass();
|
||||
if (before) wrap.insertBefore(c, before); else wrap.appendChild(c);
|
||||
return c;
|
||||
};
|
||||
const settle = async (c) => {
|
||||
const t0 = performance.now();
|
||||
while (c._booting && performance.now() - t0 < 2500) await sleep(30);
|
||||
await sleep(250);
|
||||
};
|
||||
const near = (a, b) => Math.abs(a - b) < 1e-6;
|
||||
|
||||
// ============ A. две одинаковые карточки не делят вид и черновик =========
|
||||
const A = mk();
|
||||
await settle(A);
|
||||
A._applyView(2.15, 300, 420);
|
||||
A.requestUpdate(); await A.updateComplete; await sleep(60);
|
||||
const zoomA = A._zoom;
|
||||
A._openSpaceDialog('edit', A._space); await A.updateComplete;
|
||||
A._spaceDialog = { ...A._spaceDialog, title: 'DRAFT-FROM-A' };
|
||||
await A.updateComplete; await sleep(30);
|
||||
|
||||
const B = mk(); // вторая карточка, тот же конфиг
|
||||
await settle(B);
|
||||
B._setMode('devices'); await B.updateComplete; await sleep(120);
|
||||
B._applyView(3.35, 200, 200);
|
||||
B.requestUpdate(); await B.updateComplete; await sleep(60);
|
||||
const zoomB = B._zoom;
|
||||
out.aTwoLiveCards = A.isConnected && B.isConnected && zoomB !== zoomA;
|
||||
// B — самостоятельная карточка, а не наследник A
|
||||
out.aNeighbourKeptOwnView = B._mode === 'devices' && !B._spaceDialog;
|
||||
|
||||
// Lovelace создаёт замену A ДО того, как отсоединит A (порядок аудитора)
|
||||
const A2 = mk(A);
|
||||
A.remove();
|
||||
await sleep(120); await A2.updateComplete; await sleep(60);
|
||||
|
||||
out.aReplacementKeepsOwnMode = A2._mode === 'view'; // не 'devices' от B
|
||||
out.aReplacementKeepsOwnZoom = near(A2._zoom, zoomA); // не 3.35 от B
|
||||
out.aOwnersDraftRestored = A2._spaceDialog?.title === 'DRAFT-FROM-A';
|
||||
out.aNeighbourUntouched = B._mode === 'devices' && near(B._zoom, zoomB) && !B._spaceDialog;
|
||||
A2.remove(); B.remove(); await sleep(20);
|
||||
|
||||
// ============ B. быстрый двойной ре-маунт не убивает черновик ============
|
||||
HP._warmBootReset(400);
|
||||
let c = mk();
|
||||
await settle(c);
|
||||
c._openSpaceDialog('edit', c._space); await c.updateComplete;
|
||||
c._spaceDialog = { ...c._spaceDialog, title: 'DRAFT-RAPID' };
|
||||
await c.updateComplete; await sleep(30);
|
||||
out.bDraftOpenBefore = c._spaceDialog?.title === 'DRAFT-RAPID';
|
||||
// A(черновик) -> создать B -> убрать A -> создать C -> убрать B — всё в одном
|
||||
// такте, ни один таймер воскрешения не успевает отработать
|
||||
const rb = mk(c);
|
||||
c.remove();
|
||||
const rc = mk(rb);
|
||||
rb.remove();
|
||||
await sleep(150); await rc.updateComplete; await sleep(60);
|
||||
out.bDraftSurvivedDoubleRemount = rc._spaceDialog?.title === 'DRAFT-RAPID';
|
||||
rc.remove(); await sleep(20);
|
||||
|
||||
// ============ C. просроченный диалог освобождает payload =================
|
||||
HP._warmBootReset(400);
|
||||
c = mk();
|
||||
await settle(c);
|
||||
c._openSpaceDialog('edit', c._space); await c.updateComplete;
|
||||
c._spaceDialog = { ...c._spaceDialog, title: 'DRAFT-TTL', planFile: 'x'.repeat(4096) };
|
||||
await c.updateComplete; await sleep(30);
|
||||
c.remove(); await sleep(20);
|
||||
out.cHeldWhileRevivable = HP._warmBootStats().dlgs === 1; // пока TTL идёт — держим
|
||||
await sleep(900); // TTL 400мс + запас
|
||||
const st = HP._warmBootStats();
|
||||
out.cPayloadFreedAfterTtl = st.dlgs === 0;
|
||||
out.cGeometryKept = st.slots >= 0 && st.keys >= 0; // память не сломана
|
||||
// и после TTL ничего не воскресает
|
||||
c = mk(); await sleep(150); await c.updateComplete; await sleep(40);
|
||||
out.cNoReviveAfterTtl = !c._spaceDialog;
|
||||
c.remove();
|
||||
|
||||
HP._warmBootReset();
|
||||
wrap.remove();
|
||||
return out;
|
||||
});
|
||||
for (const [k, v] of Object.entries(res)) check(k, v);
|
||||
await finish(browser, res);
|
||||
+407
-325
File diff suppressed because one or more lines are too long
Vendored
+407
-325
File diff suppressed because one or more lines are too long
@@ -1,5 +1,79 @@
|
||||
# Changelog
|
||||
|
||||
## v1.59.0-beta.2 — 2026-08-04
|
||||
|
||||
The second pre-release of the 1.59 line, and it is about the words you put
|
||||
on the plan. A decor label can now read a live value off an entity, so a
|
||||
caption becomes a readout without becoming a template language. The text
|
||||
block itself lost the choice between three font sizes: it is scaled by its
|
||||
corners and turned by its handle, like every other object on the plan, and
|
||||
it may have more than one line. Under that, the three findings of the
|
||||
beta.1 audit are closed — two identical cards no longer share one warm
|
||||
memo, a rapid double re-mount no longer eats the draft, and an expired
|
||||
dialog no longer holds a plan file in memory.
|
||||
|
||||
- **A decor label can show a live value (docs/LIVE-TEXT.md).** A text shape
|
||||
gains three optional fields — `entity`, `attr`, `unit` — and its `text`
|
||||
becomes a template whose `{}` is where the value lands: `Бак {}` reads
|
||||
*Бак 68 %*. Without a placeholder the value is appended; without an entity
|
||||
the label is byte-for-byte the static one it always was. The unit comes
|
||||
from the entity unless you type your own; a dead, unknown or missing
|
||||
entity shows an em dash instead of quietly vanishing. Nothing is rounded
|
||||
or reformatted — the value is what Home Assistant reports, because
|
||||
rounding belongs to the sensor's `display_precision`. Not a template
|
||||
language: one value, one place, no syntax to get wrong.
|
||||
|
||||
- **The text dialog gained an entity picker, an attribute picker and a live
|
||||
preview.** The attribute list is the chosen entity's own attributes, the
|
||||
unit field shows the entity's unit as its placeholder, and the preview is
|
||||
rendered through the very same substitution the plan uses.
|
||||
|
||||
- **A text block is sized by its corners and turned by its handle.** The
|
||||
choice between three font sizes is gone: select a label and pull a corner
|
||||
to scale it, or use the handle above it to rotate in 5° steps (Shift for
|
||||
any angle) — the same mechanics as the backdrop frame. Labels drawn with
|
||||
the old Small/Medium/Large come back at exactly their old size, and the
|
||||
first drag replaces that setting with the scale it meant.
|
||||
|
||||
- **A label can have more than one line.** The text field is a textarea now:
|
||||
your line breaks are stored and drawn as line breaks, centred, with the
|
||||
block growing around its anchor. Nothing wraps by itself — a caption that
|
||||
reflows on a state change is a caption that jumps around the plan.
|
||||
|
||||
- **The text tool edits the label you press on.** Drawing tools own the
|
||||
canvas (a new line must be able to start on the end of an old one), and
|
||||
that stays true — with one exception: pressing an existing label with the
|
||||
text tool opens ITS form instead of starting a second label on top of it.
|
||||
Empty canvas and non-text shapes still create a new label.
|
||||
|
||||
- **Two identical cards on one page no longer share a warm memo
|
||||
(AUD-159B1-01).** The memo key was the window size and the card config,
|
||||
which cannot tell two placements of the same config apart: the newer of
|
||||
them was the last writer, and a card re-created in the OTHER placement
|
||||
woke up with its neighbour's floor, editor mode and zoom — while the
|
||||
draft of its real predecessor was eaten by the mode guard. The memo now
|
||||
keeps one entry per card PLACEMENT: `location.pathname` (the dashboard
|
||||
view) joins the key, and inside a key the entry is claimed by DOM slot —
|
||||
the parent element and the index in it — with a live neighbour's entry
|
||||
never adoptable. When two placements are genuinely indistinguishable
|
||||
only the settled header height is adopted (it is the same for both),
|
||||
never the viewport and never the dialog.
|
||||
|
||||
- **A rapid double re-mount no longer destroys the draft
|
||||
(AUD-159B1-02).** `disconnectedCallback()` cleared the «I still owe my
|
||||
predecessor a dialog» flag BEFORE taking its snapshot, so a middle
|
||||
instance in an A→B→C rebuild wrote `dlg: null` over a draft it had not
|
||||
yet restored, and the third instance got nothing. The snapshot now runs
|
||||
while the flag is still set: an unsaved dialog simply travels down the
|
||||
chain until one instance lives long enough to reopen it.
|
||||
|
||||
- **An expired dialog no longer holds its payload (AUD-159B1-03).** The
|
||||
10-second revive TTL was only a rule checked at revive time; the entry
|
||||
itself kept the dialog — for a space dialog that is a whole plan file
|
||||
as base64 — until the page reloaded. Detaching now arms a guarded
|
||||
eviction that frees the payload the moment it stops being revivable
|
||||
(and drops the stale slot, so the next claim is unambiguous again).
|
||||
|
||||
## v1.59.0-beta.1 — 2026-08-04
|
||||
|
||||
Minor pre-release: the card survives a Lovelace re-mount bit-for-bit —
|
||||
|
||||
@@ -6,6 +6,85 @@
|
||||
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||
> изменением — как и остальная документация (см. docs/STATUS.md).
|
||||
|
||||
## v1.59.0-beta.2 — 2026-08-04
|
||||
|
||||
Второй пре-релиз ветки 1.59, и он про надписи на плане. Надпись в декоре
|
||||
умеет показывать живое значение сущности — подпись становится показанием,
|
||||
не превращаясь в язык шаблонов. Сам текстовый блок лишился выбора из трёх
|
||||
размеров шрифта: он масштабируется углами и поворачивается ручкой, как
|
||||
любой другой объект плана, и в нём может быть несколько строк. Под этим
|
||||
закрыты три находки аудита beta.1 — две одинаковые карточки больше не
|
||||
делят одну тёплую памятку, быстрый двойной ре-маунт не съедает черновик,
|
||||
а просроченный диалог не держит в памяти файл плана.
|
||||
|
||||
- **Надпись на плане может показывать живое значение (docs/LIVE-TEXT.md).**
|
||||
У текстовой фигуры появились три необязательных поля — `entity`, `attr`,
|
||||
`unit`, — а её `text` стал шаблоном, в котором `{}` — место значения:
|
||||
`Бак {}` читается как *Бак 68 %*. Без плейсхолдера значение добавляется в
|
||||
конец; без сущности надпись остаётся ровно той статичной надписью, какой
|
||||
была. Единица берётся из сущности, если не задать свою; мёртвая,
|
||||
неизвестная или отсутствующая сущность показывает прочерк, а не исчезает
|
||||
молча. Ничего не округляется и не переформатируется — значение такое,
|
||||
каким его отдаёт Home Assistant: округление живёт в `display_precision`
|
||||
сенсора, и дублировать его здесь означало бы две правды. Это не язык
|
||||
шаблонов: одно значение, одно место, нечего написать неправильно.
|
||||
|
||||
- **В форме надписи появились пикер сущности, пикер атрибута и живой
|
||||
предпросмотр.** Список атрибутов — собственные атрибуты выбранной
|
||||
сущности, в поле единицы подсказкой стоит единица сущности, а
|
||||
предпросмотр рисуется той же подстановкой, что и сам план.
|
||||
|
||||
- **Размер текстового блока задаётся углами, поворот — ручкой.** Выбор из
|
||||
трёх размеров шрифта убран: выделите надпись и тяните угол, чтобы менять
|
||||
размер, или крутите ручку над блоком — шаг 5°, Shift для любого угла
|
||||
(механика та же, что у рамки картинки-подложки). Надписи, сделанные со
|
||||
старыми «Мелкий/Средний/Крупный», возвращаются ровно в своём прежнем
|
||||
размере, а первая же протяжка заменяет эту настройку тем масштабом,
|
||||
который она означала.
|
||||
|
||||
- **В надписи может быть несколько строк.** Поле текста стало
|
||||
многострочным: ваши переводы строк сохраняются и рисуются как переводы
|
||||
строк, по центру, а блок растёт вокруг своей точки. Автопереносов нет —
|
||||
надпись, которая перекомпоновывается при каждом изменении состояния, это
|
||||
надпись, которая прыгает по плану.
|
||||
|
||||
- **Инструмент «текст» открывает ту надпись, по которой вы нажали.**
|
||||
Рисующие инструменты владеют холстом (начало новой линии должно вставать
|
||||
ровно на конец старой), и это остаётся в силе — с одним исключением:
|
||||
нажатие на существующую надпись инструментом «текст» открывает ЕЁ форму,
|
||||
а не создаёт вторую поверх. По пустому месту и по нетекстовой фигуре
|
||||
по-прежнему создаётся новая надпись.
|
||||
|
||||
- **Две одинаковые карточки на одной странице больше не делят тёплую
|
||||
памятку (AUD-159B1-01).** Ключом памятки были размер окна и конфиг
|
||||
карточки — он не различает два РАЗМЕЩЕНИЯ одного и того же конфига:
|
||||
последним писателем оказывался сосед, и карточка, пересозданная на
|
||||
своём месте, просыпалась с чужим этажом, чужим режимом и чужим зумом,
|
||||
а черновик её настоящего предшественника съедался проверкой режима.
|
||||
Теперь памятка хранит запись на каждое РАЗМЕЩЕНИЕ: в ключ вошёл
|
||||
`location.pathname` (вид дашборда), а внутри ключа запись занимается
|
||||
по DOM-слоту — родительский элемент и позиция в нём, — и запись живого
|
||||
соседа не усыновляется никогда. Если два размещения действительно
|
||||
неразличимы, усыновляется только осевшая высота шапки (она у них
|
||||
одинаковая), но не вьюпорт и не диалог.
|
||||
|
||||
- **Быстрый двойной ре-маунт больше не уничтожает черновик
|
||||
(AUD-159B1-02).** `disconnectedCallback()` сбрасывал флаг «я ещё должен
|
||||
предшественнику диалог» ДО снимка, поэтому промежуточный экземпляр в
|
||||
цепочке A→B→C писал `dlg: null` поверх черновика, который сам ещё не
|
||||
успел восстановить, и третьему не доставалось ничего. Снимок теперь
|
||||
делается, пока флаг ещё поднят: несохранённый диалог просто едет по
|
||||
цепочке, пока кто-нибудь не проживёт достаточно долго, чтобы его
|
||||
открыть.
|
||||
|
||||
- **Просроченный диалог больше не держит свой payload (AUD-159B1-03).**
|
||||
10-секундный TTL был правилом, которое проверяли только в момент
|
||||
воскрешения; сама запись продолжала держать диалог — а у диалога
|
||||
пространства это целый файл плана в base64 — до перезагрузки страницы.
|
||||
Отсоединение теперь заводит защищённую очистку, которая освобождает
|
||||
payload ровно тогда, когда он перестаёт быть воскрешаемым (и убирает
|
||||
устаревший слот, чтобы следующее усыновление снова было однозначным).
|
||||
|
||||
## v1.59.0-beta.1 — 2026-08-04
|
||||
|
||||
Минорный пре-релиз: карточка переживает пересоздание в Lovelace
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
# The text block — a decor label that can show an entity's state
|
||||
|
||||
Status: **implemented (dev, unreleased).** Code: `src/logic.ts`
|
||||
(`liveText`, `liveTextValue`, `decorTextScale`, `decorTextLines` — pure,
|
||||
unit-tested), `src/houseplan-card.ts` (`_renderDecorLayer`,
|
||||
`_renderTextFrame`, `_renderDecorTextDialog`, the `_dt*` gestures),
|
||||
`custom_components/houseplan/validation.py` (`DECOR_SCHEMA`, text branch).
|
||||
Smokes: `demo/smoke_live_text.mjs`, `demo/smoke_decor_text.mjs`;
|
||||
`demo/smoke_decor.mjs` keeps the surrounding decor contract.
|
||||
|
||||
The shape: `{kind:'text', x, y, text, color, scale?, angle?, entity?, attr?,
|
||||
unit?}` — plus the legacy `size?` that older plans still carry. Everything
|
||||
after `color` is optional, so **every existing plan validates and renders
|
||||
unchanged and no migration runs**.
|
||||
|
||||
## 1. Why a live label
|
||||
|
||||
The plan already answers "which lamps are on" and "how warm is the bedroom"
|
||||
through icons and room fills. It cannot answer the things a house says in
|
||||
words: *water tank 68 %*, *garage 12 °C*, *watering tonight at 20:00*,
|
||||
*firewood left: 3 days*. Today a user who wants that puts a device marker in
|
||||
"value instead of icon" mode — which gives a badge with a bare number, always
|
||||
tied to a device with a position, an area and a tap action. What is missing is
|
||||
the caption on the wall: free-standing text, in the user's own words, that
|
||||
happens to have a live number in it.
|
||||
|
||||
The competing card (ha-floorplan) covers this with `text_set` and it is one of
|
||||
its most used features; our decor text was one field away from it.
|
||||
|
||||
## 2. The live value
|
||||
|
||||
A decor text shape has three optional fields:
|
||||
|
||||
- `entity` — an entity id whose state is substituted; absent = today's plain
|
||||
static label, byte-for-byte unchanged;
|
||||
- `attr` — an attribute name to read instead of `state` (battery level,
|
||||
current temperature of a climate, position of a cover); absent = the state;
|
||||
- `unit` — a suffix; absent = the entity's own `unit_of_measurement`, so
|
||||
`sensor.tank` needs no configuration to read *68 %*.
|
||||
|
||||
The `text` field keeps its present meaning and becomes the **template**: the
|
||||
placeholder `{}` is where the value lands. `Бак {}` → *Бак 68 %*. A template
|
||||
without a placeholder gets the value appended after a space, so a user who
|
||||
only picks an entity and types nothing sensible still sees something useful.
|
||||
**Only the first `{}` is replaced** — one label, one value.
|
||||
|
||||
`{}` was chosen over `{{ }}` deliberately: this is a substitution, not a
|
||||
template language. There is no expression, no condition, no arithmetic — the
|
||||
moment we accept `{{ states('x') | round(1) }}` we have signed up for the
|
||||
class of support load that fills the competitor's thread (138 posts of "my
|
||||
CSS/template does not work"). One value, one place, no syntax to get wrong.
|
||||
|
||||
### 2.1. Rendering rules
|
||||
|
||||
- The value is read live from `hass` on every render — the same source as the
|
||||
rest of the card, no polling and no subscriptions of its own. A new `hass`
|
||||
repaints the label; nothing is re-created.
|
||||
- **Unavailable / unknown / missing entity** → the value renders as `—` (an
|
||||
em dash) **and the dash carries no unit** («— °C» is not a reading); the
|
||||
rest of the template stays. A label that silently disappears when a sensor
|
||||
dies is worse than one that says "no data": the user must see that the
|
||||
caption is alive and the sensor is not.
|
||||
- An attribute that is not on the entity, or that is a dict, renders as the
|
||||
same dash. A list attribute is joined with `, `; `0` and `false` are values,
|
||||
not absences.
|
||||
- Numbers are shown as HA reports them; we do not round, do not reformat and
|
||||
do not localise decimal separators. Rounding belongs to the sensor's
|
||||
`display_precision` in HA, and duplicating it here would make two sources of
|
||||
truth. Imperial/metric is not our business either — the value and the unit
|
||||
come from HA.
|
||||
- **The unit is inherited only for the STATE.** With an `attr` the entity's
|
||||
`unit_of_measurement` is not applied: it describes the state, and a
|
||||
`battery_level` read off a °C sensor must not come out as «73 °C». An
|
||||
explicit `unit` always wins; an empty one inherits.
|
||||
- The value is clipped to `LIVE_TEXT_VALUE_MAX` (60) characters: a caption is
|
||||
a caption, and an attribute that turns out to be a 4 KB string must not
|
||||
become the plan's wallpaper.
|
||||
- Editors and kiosk render it identically; in the decor editor the *live*
|
||||
value is shown (not the raw template), so the user sees what visitors will
|
||||
see while positioning it. The read-only `houseplan-space-card` does not
|
||||
render the decor layer at all — that is unchanged, and out of scope here.
|
||||
|
||||
## 3. The block: size, rotation, lines
|
||||
|
||||
The old `size: 's'|'m'|'l'` selector is **gone from the dialog**. A caption's
|
||||
size is not one of three opinions; it is whatever fits the place it is put in.
|
||||
|
||||
- **`scale`** — a font multiplier against the base 20 px, written by dragging
|
||||
a corner of the selected block (select tool). Bounded `0.15…20` in the card
|
||||
and in the backend.
|
||||
- **`angle`** — degrees, written by the handle above the block. The step is
|
||||
**5°**, the same step a device icon rotates in; **Shift** drags past it, as
|
||||
past every other snap in this card (docs/CANVAS.md §9.4). Rotating back to
|
||||
zero removes the field, so a straight label stores nothing.
|
||||
- **No migration for `size`.** A stored `size` is read as the multiplier it
|
||||
used to render at — `s` = 0.7 (14 px), `m` = 1 (20 px), `l` = 1.5 (30 px) —
|
||||
so an old label comes back at exactly its old size. An explicit `scale`
|
||||
wins, and the first corner drag **replaces** `size` with the scale it meant:
|
||||
a shape never states its size twice. `size` stays in `DECOR_SCHEMA` (still
|
||||
bounded to the three known values) precisely because old plans keep sending
|
||||
it.
|
||||
- **Line breaks are the user's own.** The dialog's field is a textarea; a
|
||||
newline is stored and rendered as a newline (one `<tspan>` per line, line
|
||||
height 1.2 em). The label **never wraps by itself** — a caption that reflows
|
||||
on every state change is a caption that jumps around the plan. A
|
||||
300-character line stays one line.
|
||||
- **Multi-line blocks are centred**, horizontally (the decor layer's
|
||||
`text-anchor: middle`, which single-line labels already used) and
|
||||
vertically: the anchor `x/y` sits in the middle of the block, so adding a
|
||||
second line grows the label in both directions instead of pushing the first
|
||||
one up.
|
||||
- Both gestures pivot on the **anchor** (`x`/`y`), not on a box corner, so a
|
||||
label never walks away from the point it was placed at, and a rotated block
|
||||
still scales along the same axis (a distance from the anchor is invariant
|
||||
under its own rotation). The frame chrome — dashed outline, four corner
|
||||
handles, one rotate handle on a stem — reuses the backdrop frame's mechanics
|
||||
and sizes: chrome that never takes a pointer, handles that always do, sized
|
||||
at 1.8 % of the visible view so they stay finger-sized at any zoom
|
||||
(docs/BACKDROP.md §2).
|
||||
- The frame is measured from the rendered glyphs (`getBBox`), so it appears
|
||||
one frame after the text and follows every edit of it.
|
||||
|
||||
## 4. Tools: what a click does
|
||||
|
||||
Decor shapes are inert under a drawing tool — a new line must be able to start
|
||||
exactly on the end of an old one (owner, 2026-08-04). The **text tool has one
|
||||
exception**, asked for by the owner on the same day:
|
||||
|
||||
| Text tool, press on… | What happens |
|
||||
|---|---|
|
||||
| an existing **label** | its editor opens (the same form, prefilled) |
|
||||
| empty canvas | a new label is created there |
|
||||
| a **non-text** shape (line, rect, ellipse) | a new label is created there; the shape stays inert |
|
||||
|
||||
Under the **select** tool a label is selected and dragged as before, a double
|
||||
click opens its editor, and the corner/rotate handles appear.
|
||||
|
||||
## 5. The dialog
|
||||
|
||||
Under the text field:
|
||||
|
||||
- a **textarea** (line breaks are content now), saved with the button or
|
||||
Ctrl/⌘+Enter — plain Enter is a new line;
|
||||
- a hint that mentions `{}` **only when an entity is chosen** — an unlinked
|
||||
label must not be burdened with syntax it does not need;
|
||||
- an **entity picker** with a datalist of all entities — the same control
|
||||
style the vacuum source and the weather field use;
|
||||
- an **attribute field**, shown only once an entity is chosen, with a datalist
|
||||
of that entity's actual attribute names — the user should not have to know
|
||||
that a climate keeps `current_temperature`;
|
||||
- a **unit field** whose placeholder is the entity's own unit, so leaving it
|
||||
empty is the obvious right answer (the placeholder disappears once an
|
||||
attribute is chosen, because an attribute does not inherit it);
|
||||
- a **live preview** of the resulting label, rendered through the same
|
||||
`liveText` the plan uses: one substitution, one truth.
|
||||
|
||||
Clearing the entity clears the attribute and the unit with it — a save never
|
||||
leaves orphan fields in the config.
|
||||
|
||||
## 6. Backend
|
||||
|
||||
`DECOR_SCHEMA`, text branch: `entity` optional, `None` or an entity id
|
||||
(`^[a-z0-9_]+\.[a-z0-9_]+$`, ≤ 255); `attr` optional, `None` or a flat name
|
||||
≤ 64; `unit` optional, `None` or ≤ 16 characters; `scale` optional, finite,
|
||||
`0.15…20`; `angle` optional, finite, `-360…360`; `text` ≤ 200 characters,
|
||||
newlines included. Everything optional, so every existing plan validates
|
||||
unchanged. Tests: `tests_backend/test_validation.py`
|
||||
(`test_decor_text_live_fields`, `test_decor_text_block_scale_and_angle`).
|
||||
|
||||
## 7. What this is not
|
||||
|
||||
- **Not a second device marker.** No tap action, no icon, no state class, no
|
||||
participation in room aggregation (LQI, climate averages) — it is a caption,
|
||||
not a device. A user who wants an interactive thing puts a marker.
|
||||
- **Not a template engine** (see §2).
|
||||
- **Not a multi-entity widget.** One label, one value. Two values are two
|
||||
labels; that stays honest and costs the user one drag.
|
||||
- **Not an auto-layout.** No wrapping, no shrink-to-fit: the size is set with
|
||||
the corners and the lines with the Enter key.
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.59.0-beta.1** everywhere (manifest, const.py, package.json, CARD_VERSION) — **pre-release**, tag `v1.59.0-beta.1` on **`dev`**, GitHub Release with `prerelease=true`; `main` is NOT touched and **nothing is copied to the home instance by hand** — HACS delivers it on the beta channel. Contents: **warm re-mount** (docs/WARM-REMOUNT.md — a Lovelace re-creation of the card element restores the whole viewport bit-for-bit: space, mode, zoom, the `_view` rect, the view-mode snapshot, the far-objects frame, the tool, the selection and the local «show hidden»; an open dialog is revived WITH its live draft, while destructive confirmations, tap confirmations, the floor-import wizard and any dialog with a save in flight are deliberately not revived; same space + same mode, at most once, within 10 s); **the sun-ray rim** (docs/SUN.md «The rim» — a 1 px black hairline on the two SIDE edges of every lit wedge, fading on the same axis/curve/85 % threshold as the fill, `non-scaling-stroke`, peak alpha 0.42 — light finally reads on white paper; the «shade instead of light» model of docs/SUN-CONTRAST.md was REJECTED and that file now records the decision); the «+» that adds a space moved out of the Plan editor into the tab row in **every** mode (admin-only, absent in kiosk); and the «Already uploaded» plan list no longer collapses to 14 px (a scrolling flex child needs `min-height:0` overridden — docs/TESTING.md now requires new scrollable dialog containers to be smoke-tested BY GEOMETRY, not by node count). Previous stable: v1.58.0 "the backdrop you can move" |
|
||||
| Version | **v1.59.0-beta.2** everywhere (manifest, const.py, package.json, CARD_VERSION) — **pre-release**, tag `v1.59.0-beta.2` on **`dev`**, GitHub Release with `prerelease=true`; `main` is NOT touched and **nothing is copied to the home instance by hand** — HACS delivers it on the beta channel. Contents on top of beta.1: **live text on a decor label** (docs/LIVE-TEXT.md — optional `entity`/`attr`/`unit` on a text shape, `{}` as the one placeholder, the unit inherited from the entity for the state only, an em dash for a dead entity, no rounding, 60 chars max; the dialog gained an entity picker, an attribute picker and a live preview); **the text block** (docs/LIVE-TEXT.md §3–5 — the three font sizes are gone: corner handles scale it and a handle rotates it in 5° steps with Shift for any angle and the pivot on the anchor, the old `size` is read as the initial scale and replaced by the first drag; explicit line breaks are drawn as line breaks, centred on both axes; the text tool opens the form of the label it is pressed on, non-text shapes stay inert); and the three findings of the beta.1 audit (docs/WARM-REMOUNT.md §1.1–1.3): the warm memo keeps one entry per card PLACEMENT (`location.pathname` in the key, a list of slots inside it with candidates ranked by DOM position) so two identical cards on one page no longer mix owners (AUD-159B1-01), the snapshot is taken BEFORE the flag is cleared so a rapid double re-mount no longer loses the draft (AUD-159B1-02), and an expired entry frees its dialog payload on a timer instead of holding a base64 plan file until reload (AUD-159B1-03). Previous stable: v1.58.0 "the backdrop you can move" |
|
||||
|
||||
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command. **Since 2026-08-04 there is also a pre-release track:** bump to `X.Y.Z-beta.N`, tag the `dev` commit, publish a GitHub Release with `prerelease=true` — `main` is not touched and nothing is copied to the home instance by hand; HACS delivers it on the beta channel |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — **`main` carries every published release, the latest tag is the current version above**; `dev` is where work lands and is merged into `main` at release time (so `dev` is normally equal to or ahead of `main`, never behind). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
|
||||
|
||||
@@ -1121,6 +1121,51 @@ require hands on real hardware — they remain for the human pass.
|
||||
updates on every move, is absent before the drag has any length, and is
|
||||
gone the moment the shape is committed. Rectangles and ovals show their
|
||||
bounding box «W × H» instead [auto: smoke_decor]
|
||||
|
||||
## The text block on the plan (docs/LIVE-TEXT.md, dev, unreleased)
|
||||
|
||||
- [ ] **A label can show a live value**: in the Background editor place a text,
|
||||
pick an entity in the dialog and write `Бак {}` — the plan shows
|
||||
«Бак 68 %». Change the sensor: the label follows without any reload. A
|
||||
label with no entity is untouched, byte for byte
|
||||
[auto: smoke_live_text + unit logic.test]
|
||||
- [ ] **The unit comes from the entity**: leave the unit field empty and the
|
||||
entity's own `unit_of_measurement` is used (its placeholder shows which);
|
||||
type your own and it wins. Choose an ATTRIBUTE (say `battery_level` on a
|
||||
°C sensor) and the entity's unit is NOT inherited — an attribute is not
|
||||
the state [auto: smoke_live_text]
|
||||
- [ ] **A dead sensor says so**: make the entity unavailable (or delete it) —
|
||||
the value becomes «—» and the rest of the caption stays. The dash carries
|
||||
no unit [auto: smoke_live_text]
|
||||
- [ ] **Nothing is rounded**: a sensor reporting `23.94781` shows `23.94781`.
|
||||
Rounding is the sensor's `display_precision`, not ours [auto:
|
||||
smoke_live_text]
|
||||
- [ ] **The preview is the truth**: the line under the dialog's fields is
|
||||
rendered by the same substitution the plan uses, so what it shows is what
|
||||
the plan will show [manual]
|
||||
- [ ] **The same everywhere**: the label reads identically in View, in the
|
||||
editors and on a kiosk screen [auto: smoke_live_text]
|
||||
- [ ] **No font-size choice any more**: the dialog has no Small/Medium/Large.
|
||||
Select a label and pull a corner — the text scales uniformly about its
|
||||
anchor; the handle above it turns the block in 5° steps, Shift for any
|
||||
angle. Rotating back to zero leaves a straight label
|
||||
[auto: smoke_decor_text]
|
||||
- [ ] **Old labels keep their size**: a plan made before the handles renders
|
||||
its Small/Medium/Large labels at exactly the old 14/20/30 px, and the
|
||||
first corner drag converts that setting into the scale it meant
|
||||
[auto: smoke_decor_text + unit logic.test]
|
||||
- [ ] **Enter is a new line**: type two lines in the dialog (Ctrl/⌘+Enter or
|
||||
the button saves) — the plan shows two lines, centred, growing around the
|
||||
anchor. A very long single line is NOT wrapped for you
|
||||
[auto: smoke_decor_text]
|
||||
- [ ] **The text tool edits the label under the cursor**: with the text tool
|
||||
selected, press an existing label — its form opens, prefilled, and no
|
||||
second label is created. Press empty canvas, or a line/rectangle, and a
|
||||
NEW label is created there instead (non-text shapes stay inert under
|
||||
drawing tools) [auto: smoke_decor_text, smoke_decor]
|
||||
- [ ] **A label is still a caption, not a device**: no tap action, no icon, no
|
||||
part in room averages; it is not offered in any of the device pickers
|
||||
[manual]
|
||||
- [ ] **Grazing sunlight** (dev, audit DEV-EB173-01): with `sun_rays` on, set
|
||||
the sun almost ALONG a wall carrying a window (e.g. a west window,
|
||||
azimuth 190°, elevation 90° at north_deg 0). The shaft is a true
|
||||
@@ -1305,3 +1350,21 @@ require hands on real hardware — they remain for the human pass.
|
||||
- [ ] **A save in flight is not offered twice**: press Save in the space dialog
|
||||
and reload/reconnect during the write — the dialog does not come back
|
||||
with a live Save button; the reloaded config shows the outcome [manual]
|
||||
- [ ] **Two identical cards keep to themselves**: put the SAME card config
|
||||
twice on one view, park one in the Devices editor at 350 % and leave the
|
||||
other in View with an unsaved space dialog, then force a rebuild — each
|
||||
card comes back with its OWN floor, mode and zoom, and the draft returns
|
||||
to the card that owned it, not to its neighbour (AUD-159B1-01)
|
||||
[auto: smoke_warm_owners, section A]
|
||||
- [ ] **Two dashboard views keep to themselves**: the same card config on two
|
||||
views of one dashboard — switching between them never carries a viewport
|
||||
or a dialog across (`location.pathname` is part of the key) [manual]
|
||||
- [ ] **A rebuild storm keeps the draft**: a dashboard that rebuilds twice in a
|
||||
row (config churn, a flapping websocket) still returns the unsaved dialog
|
||||
— the draft travels down the chain of instances (AUD-159B1-02)
|
||||
[auto: smoke_warm_owners, section B]
|
||||
- [ ] **A forgotten draft frees its plan file**: open the space dialog with a
|
||||
plan chosen, leave the view and do not come back — after the 10-second
|
||||
TTL the memo no longer holds the dialog (a plan is base64 in memory), and
|
||||
nothing revives afterwards (AUD-159B1-03)
|
||||
[auto: smoke_warm_owners, section C]
|
||||
|
||||
+74
-5
@@ -2,7 +2,7 @@
|
||||
|
||||
> Статус: реализовано (dev, DEV-B703-01…03). Код: `src/houseplan-card.ts`,
|
||||
> модульная памятка `warmBoot`. Смоки: `demo/smoke_warm_remount.mjs`,
|
||||
> `demo/smoke_warm_dialogs.mjs`.
|
||||
> `demo/smoke_warm_dialogs.mjs`, `demo/smoke_warm_owners.mjs` (владение слотами).
|
||||
|
||||
## 1. Что вообще происходит
|
||||
|
||||
@@ -23,13 +23,82 @@ Lovelace **пересоздаёт элемент карточки** — выбр
|
||||
который живёт в загруженном JS-модуле, а не в экземпляре. Ключ:
|
||||
|
||||
```
|
||||
`${window.innerWidth}x${window.innerHeight}|${JSON.stringify(config)}`
|
||||
`${window.innerWidth}x${window.innerHeight}|${location.pathname}|${JSON.stringify(config)}`
|
||||
```
|
||||
|
||||
Смена размера окна → промах → полный защитный бут (единственный случай, когда
|
||||
хром HA может реально пересобраться заново). Одинаковый конфиг дважды на одной
|
||||
странице неразличим — это задокументированное ограничение (высоты у них всё
|
||||
равно совпадают).
|
||||
хром HA может реально пересобраться заново). `location.pathname` — это дашборд
|
||||
И путь вида: две вкладки Lovelace никогда не делят ключ, поэтому карточка,
|
||||
открытая на другом виде, стартует холодной, а не с чужим вьюпортом. Хеш в ключ
|
||||
НЕ входит: `#space=` — наш собственный диплинк, а не другое размещение.
|
||||
|
||||
Одинаковый конфиг дважды на одном виде ключ по-прежнему не различает — этим и
|
||||
занимается §1.1.
|
||||
|
||||
### 1.1. Одна запись — одно РАЗМЕЩЕНИЕ карточки (AUD-159B1-01)
|
||||
|
||||
Пока в памятке лежали два числа (высота шапки и высота сцены), «две одинаковые
|
||||
карточки неразличимы» было косметикой: высоты у них всё равно совпадают. Как
|
||||
только в записи появились вьюпорт и диалог, ограничение стало функциональным —
|
||||
аудит v1.59.0-beta.1 воспроизвёл это так:
|
||||
|
||||
1. A: вид, зум 2.15, открытый диалог пространства с черновиком;
|
||||
2. B (тот же конфиг): «Устройства», зум 3.35 — последний писатель общей записи;
|
||||
3. Lovelace создаёт замену A′ **до** того, как отсоединит A;
|
||||
4. A′ усыновляет вьюпорт B, а черновик A потом отбраковывается проверкой
|
||||
«тот же режим» — пользователь видит чужой экран и теряет несохранённое.
|
||||
|
||||
Поэтому значение ключа теперь **список слотов**, по одному на каждое
|
||||
размещение карточки. В слоте, кроме геометрии/вьюпорта/диалога, лежат:
|
||||
|
||||
* `owner` — поколение живого экземпляра, сидящего в слоте;
|
||||
* `place` — **WeakRef** на родительский элемент, в который карточка была
|
||||
вставлена (слабая ссылка: памятка не имеет права держать DOM живым);
|
||||
* `idx` — позиция карточки среди детей этого родителя;
|
||||
* `live` — сидит ли в слоте кто-то живой.
|
||||
|
||||
Слот занимается не в `setConfig` (Lovelace зовёт его **до** вставки элемента,
|
||||
размещения ещё не существует), а в `connectedCallback` — всё ещё до первого
|
||||
рендера, поэтому вуаль не мигает. Кандидаты ранжируются:
|
||||
|
||||
| Оценка | Что это | Усыновляем |
|
||||
|---|---|---|
|
||||
| 4 | тот же родитель и тот же индекс — буквально наш DOM-слот; именно так Lovelace подменяет карточку (предшественник может быть ещё жив) | да |
|
||||
| 0 | **другое** размещение, владелец которого жив — соседняя карточка | никогда |
|
||||
| 3 | тот же родитель, индекс сдвинулся, владелец ушёл | да |
|
||||
| 2 | надгробие, чьё размещение исчезло вместе с поддеревом — обычная перестройка Lovelace | да |
|
||||
|
||||
Ничья (два одинаково правдоподобных кандидата) означает: усыновляется **только
|
||||
осевшая высота шапки** — она у одинаковых карточек и так одинаковая, — но не
|
||||
вьюпорт и не диалог. Это худший случай: карточка открывается без вуали, но со
|
||||
своим собственным видом.
|
||||
|
||||
Диалог воскрешается **из своего слота**, а не «из записи по ключу», поэтому
|
||||
чужой черновик физически недостижим.
|
||||
|
||||
### 1.2. Черновик едет по цепочке (AUD-159B1-02)
|
||||
|
||||
`_warmSnapshot()` не трогает `dlg`, пока у экземпляра поднят
|
||||
`_warmRevivePending` — «я ещё не забрал диалог предшественника». Раньше
|
||||
`disconnectedCallback()` сбрасывал этот флаг **до** снимка, и промежуточный
|
||||
экземпляр в цепочке A→B→C записывал `dlg: null` поверх чужого черновика:
|
||||
двойное пересоздание в одном такте теряло несохранённый ввод. Теперь снимок
|
||||
делается **первым**, и черновик просто едет дальше по цепочке, пока кто-нибудь
|
||||
не проживёт достаточно долго, чтобы его открыть. Регрессия:
|
||||
`demo/smoke_warm_owners.mjs`, секция B.
|
||||
|
||||
### 1.3. TTL освобождает payload, а не только запрещает воскрешение (AUD-159B1-03)
|
||||
|
||||
`WARM_REVIVE_MS` (10 c) был правилом, которое проверялось только в момент
|
||||
воскрешения. Запись при этом продолжала держать объект диалога — а у диалога
|
||||
пространства это **целый файл плана в base64** (бэкенд разрешает 8 MiB). На
|
||||
настенном планшете, который не перезагружают неделями, это десятки мегабайт
|
||||
бесполезного удержания. Теперь отсоединение заводит защищённый таймер: через
|
||||
`WARM_REVIVE_MS` он обнуляет `dlg`, если у слота та же метка `freed` и тот же
|
||||
владелец (успел прийти преемник — таймер молчит). Высота и вьюпорт остаются:
|
||||
это байты, и именно они делают следующий маунт тёплым. Заодно опустевший слот
|
||||
удаляется из списка, если он там не последний — чтобы следующее усыновление
|
||||
снова было однозначным.
|
||||
|
||||
## 2. Рывок масштаба: фактическая причина
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.59.0-beta.1",
|
||||
"version": "1.59.0-beta.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "houseplan-card",
|
||||
"version": "1.59.0-beta.1",
|
||||
"version": "1.59.0-beta.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lit": "^3.1.3",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.59.0-beta.1",
|
||||
"version": "1.59.0-beta.2",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+555
-64
@@ -25,6 +25,8 @@ import {
|
||||
referencedContentUrls,
|
||||
DISPLAY_MODES, TAP_ACTIONS, SPACE_FILL_MODES, ROOM_FILL_MODES,
|
||||
coverService, coverMoving, coverEntityOf, COVER_GUARDED_CLASSES,
|
||||
liveText, decorTextScale, decorTextLines,
|
||||
DECOR_TEXT_BASE, DECOR_TEXT_SCALE_MIN, DECOR_TEXT_SCALE_MAX,
|
||||
} from './logic';
|
||||
import {
|
||||
planEdgeDrag, applyEdgeDrag, clampEdgeDrag, applyRoomScale, clampRoomScale,
|
||||
@@ -62,7 +64,7 @@ import {
|
||||
import { alignAllToGrid, type AlignReport } from './align-grid';
|
||||
import { langOf, t, type I18nKey } from './i18n';
|
||||
|
||||
const CARD_VERSION = '1.59.0-beta.1';
|
||||
const CARD_VERSION = '1.59.0-beta.2';
|
||||
/** HP-1552 boot-veil timing (AUD-1552-02). The veil holds for at least
|
||||
* BOOT_MIN_MS; every stage-height change restarts a BOOT_QUIET_MS
|
||||
* trailing-quiescence requirement (chrome still settling near the cap
|
||||
@@ -119,27 +121,98 @@ type WarmViewport = {
|
||||
* half-filled device dialog with its uploaded pdfs survives for free. */
|
||||
type WarmDialogKind = 'space' | 'marker' | 'settings' | 'opening' | 'decorText' | 'rules' | 'room' | 'info' | 'openingInfo';
|
||||
type WarmDialog = { kind: WarmDialogKind; space: string; mode: string; data: any };
|
||||
/** AUD-159B1-01: one entry per CARD PLACEMENT, not per key. Two cards with an
|
||||
* identical config on one view share the key, so the key alone cannot say
|
||||
* whose viewport this is; `place`/`idx` (the parent element the card was
|
||||
* mounted in, and its position among that parent's children) identify the
|
||||
* DOM slot, and `owner` the live instance sitting in it. A re-mount into the
|
||||
* same slot inherits the entry; a different card never does. */
|
||||
type WarmEntry = {
|
||||
/** generation id of the instance that currently owns the slot */
|
||||
owner: number;
|
||||
/** the parent element the owner was mounted in (weak — never keep DOM alive) */
|
||||
place: WeakRef<Node> | null;
|
||||
/** the owner's index among that parent's children */
|
||||
idx: number;
|
||||
/** the owner is attached; a dead slot is a tombstone waiting for a successor */
|
||||
live: boolean;
|
||||
hdrH: number;
|
||||
stageH: number;
|
||||
vp: WarmViewport | null;
|
||||
dlg: WarmDialog | null;
|
||||
/** when the instance that wrote `dlg` detached; 0 = it is still alive */
|
||||
freed: number;
|
||||
/** the TTL timer that frees `dlg` once it can no longer be revived */
|
||||
evict: number;
|
||||
};
|
||||
const warmBoot = new Map<string, WarmEntry>();
|
||||
const warmBoot = new Map<string, WarmEntry[]>();
|
||||
let warmGen = 0;
|
||||
/** `location.pathname` is the dashboard AND the view path: two Lovelace views
|
||||
* never share a key, so a card that comes back on another view boots cold
|
||||
* instead of inheriting a stranger's viewport (AUD-159B1-01). The hash is
|
||||
* deliberately out — `#space=` is OUR deep link, not another placement. */
|
||||
const warmBootKey = (config: unknown): string =>
|
||||
`${window.innerWidth}x${window.innerHeight}|${JSON.stringify(config ?? {})}`;
|
||||
`${window.innerWidth}x${window.innerHeight}|${location.pathname}|${JSON.stringify(config ?? {})}`;
|
||||
/** A dialog is revived only if the instance that owned it died THIS long ago.
|
||||
* A Lovelace rebuild detaches and re-attaches within one task; a user who
|
||||
* walked off to another dashboard view and came back later must not be met
|
||||
* by a dialog they have long forgotten opening. */
|
||||
const WARM_REVIVE_MS = 10000;
|
||||
let WARM_REVIVE_MS = 10000;
|
||||
/** The memo gains a key on every window RESIZE and never loses one; the
|
||||
* values used to be two numbers, and now they can hold a dialog draft (a
|
||||
* plan pdf among it). Keep the last few viewports — a stale entry only
|
||||
* costs the next card at that size a cold boot. */
|
||||
const WARM_MAX_KEYS = 8;
|
||||
/** How many placements of ONE key are remembered. More identical cards than
|
||||
* this on one view and the oldest dead slot is dropped — it only costs that
|
||||
* placement a cold boot. */
|
||||
const WARM_MAX_SLOTS = 4;
|
||||
|
||||
/** Which slot of `list` belongs to the instance now claiming it, and may we
|
||||
* trust it with the viewport/dialog (`sure`) or only with the settled height?
|
||||
* Ranked, best first (AUD-159B1-01):
|
||||
* 4 — same parent, same index: literally the DOM slot we are standing in,
|
||||
* which is how Lovelace replaces a card (the predecessor may still be
|
||||
* attached for another task — that is the case the audit reproduced);
|
||||
* 0 — some OTHER placement whose owner is still attached: a neighbouring
|
||||
* card with an identical config, never ours to inherit;
|
||||
* 3 — same parent, shifted index, owner gone: still our placement;
|
||||
* 2 — a tombstone whose placement is gone with its subtree — the ordinary
|
||||
* Lovelace rebuild, where the container is rebuilt too.
|
||||
* A tie means two candidates are equally plausible: then only the settled
|
||||
* height is adopted, and it is the same for all of them anyway. */
|
||||
const warmMatch = (
|
||||
list: WarmEntry[],
|
||||
gen: number,
|
||||
place: Node | null,
|
||||
idx: number,
|
||||
): { slot: WarmEntry | null; sure: boolean } => {
|
||||
const score = (s: WarmEntry): number => {
|
||||
const same = !!place && s.place?.deref() === place;
|
||||
if (same && s.idx === idx) return 4;
|
||||
if (s.live) return 0;
|
||||
return same ? 3 : 2;
|
||||
};
|
||||
let best: WarmEntry | null = null;
|
||||
let bestScore = 0;
|
||||
let ties = 0;
|
||||
let newest: WarmEntry | null = null;
|
||||
for (const s of list) {
|
||||
if (s.owner === gen) continue;
|
||||
newest = s;
|
||||
const sc = score(s);
|
||||
if (sc <= 0) continue;
|
||||
if (sc > bestScore) { best = s; bestScore = sc; ties = 1; }
|
||||
else if (sc === bestScore) ties++;
|
||||
}
|
||||
if (!best || ties > 1) return { slot: best || newest, sure: false };
|
||||
return { slot: best, sure: true };
|
||||
};
|
||||
/** Rotation step of a decor text block — the same 5° a device icon turns in
|
||||
* (marker dialog). Shift drags past it, as past every other snap. */
|
||||
const DT_ANGLE_STEP = 5;
|
||||
/** Line spacing of a multi-line label, in font sizes. */
|
||||
const DT_LINE = 1.2;
|
||||
const LS_KEY = 'houseplan_card_layout_v1';
|
||||
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
|
||||
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
||||
@@ -268,7 +341,23 @@ class HouseplanCard extends LitElement {
|
||||
private _decorDraft: { kind: 'line' | 'rect' | 'ellipse'; a: number[]; b: number[]; pid: number } | null = null;
|
||||
private _decorMove: { id: string; start: number[]; orig: any; pid: number; moved: boolean } | null = null;
|
||||
private _decorSel: string | null = null;
|
||||
private _decorTextDialog: { id?: string; x: number; y: number; text: string; size: 's' | 'm' | 'l'; color: string } | null = null;
|
||||
/** The text dialog. `size` is gone from the UI (the block is scaled by its
|
||||
* corners now); `entity`/`attr`/`unit` are the live link (docs/LIVE-TEXT.md). */
|
||||
private _decorTextDialog: {
|
||||
id?: string; x: number; y: number; text: string; color: string;
|
||||
entity?: string; attr?: string; unit?: string;
|
||||
} | null = null;
|
||||
/** The measured box of the selected text block, its own (unrotated) frame. */
|
||||
private _dtBox: { id: string; x: number; y: number; w: number; h: number } | null = null;
|
||||
/** A live corner (scale) or rotate gesture on the selected text block. */
|
||||
private _dtDrag: {
|
||||
id: string; kind: 'scale' | 'rotate'; pid: number;
|
||||
/** the anchor the block is scaled and rotated about (render units) */
|
||||
ax: number; ay: number;
|
||||
/** distance / bearing of the pointer at the start, and the stored values */
|
||||
r0: number; a0: number; scale0: number; angle0: number;
|
||||
moved: boolean;
|
||||
} | null = null;
|
||||
/**
|
||||
* The live backdrop gesture (docs/BACKDROP.md §2): moving the picture by its
|
||||
* body, or scaling it UNIFORMLY by a corner handle about the opposite corner.
|
||||
@@ -572,6 +661,11 @@ class HouseplanCard extends LitElement {
|
||||
private _warmVpArmed = false;
|
||||
private _warmRevivePending = false;
|
||||
private _warmReviveTimer?: number;
|
||||
/** AUD-159B1-01: this instance's identity in the memo — its generation, the
|
||||
* key it settled under and the slot (card placement) it owns. */
|
||||
private _warmGen = ++warmGen;
|
||||
private _warmKey: string | null = null;
|
||||
private _warmSlot: WarmEntry | null = null;
|
||||
private _hashApplied = false;
|
||||
private _navApplied = false; // the saved space was restored (or the user navigated)
|
||||
// ---- kiosk (wall device) mode ----
|
||||
@@ -670,6 +764,8 @@ class HouseplanCard extends LitElement {
|
||||
_decorSel: { state: true },
|
||||
_decorTextDialog: { state: true },
|
||||
_bdDrag: { state: true },
|
||||
_dtBox: { state: true },
|
||||
_dtDrag: { state: true },
|
||||
_kioskDialog: { state: true },
|
||||
_vacFit: { state: true },
|
||||
_kioskDots: { state: true },
|
||||
@@ -724,6 +820,8 @@ class HouseplanCard extends LitElement {
|
||||
// DEV-B703-02: a reattach mid-outage must keep revalidating — the retry
|
||||
// timer died in disconnectedCallback
|
||||
if (!this._loadOk && this._serverCfg && this.hass) this._scheduleLoadRetry();
|
||||
// AUD-159B1-01: the placement is only knowable once we are IN the DOM.
|
||||
if (!this._warmSlot && this._config) this._warmAdopt();
|
||||
// DEV-B703-03: one task later the element Lovelace replaced has detached
|
||||
// — only then is its open dialog ours to take over.
|
||||
if (this._warmVp && !this._warmRevivePending && this._warmReviveTimer === undefined) {
|
||||
@@ -774,11 +872,18 @@ class HouseplanCard extends LitElement {
|
||||
clearTimeout(this._layoutSyncTimer);
|
||||
// DEV-B703-03: the last thing this instance was showing, then the
|
||||
// tombstone that lets exactly one successor adopt the open dialog.
|
||||
// AUD-159B1-02: the snapshot runs while `_warmRevivePending` is still
|
||||
// TRUE. An instance that never got to consume its predecessor's dialog
|
||||
// has nothing of its own to record there, and the old order (clear the
|
||||
// flag, then snapshot) had it write `dlg: null` over somebody else's
|
||||
// unsaved draft — a second Lovelace rebuild inside one task destroyed
|
||||
// the draft before the first successor could restore it. Now the draft
|
||||
// simply travels down the chain until one of them lives long enough.
|
||||
this._warmSnapshot();
|
||||
this._warmRevivePending = false;
|
||||
clearTimeout(this._warmReviveTimer);
|
||||
this._warmReviveTimer = undefined;
|
||||
this._warmSnapshot();
|
||||
this._warmPatch({ freed: Date.now() });
|
||||
this._warmRelease();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
@@ -893,33 +998,32 @@ class HouseplanCard extends LitElement {
|
||||
return { type: 'custom:houseplan-card' };
|
||||
}
|
||||
|
||||
/** Test hook (smokes): forget the warm re-mount memo — a cold page again. */
|
||||
public static _warmBootReset(): void {
|
||||
/** Test hook (smokes): forget the warm re-mount memo — a cold page again.
|
||||
* `ttl` retunes WARM_REVIVE_MS so a smoke can watch the TTL expire without
|
||||
* standing still for ten seconds (AUD-159B1-03); omitted = back to 10 s. */
|
||||
public static _warmBootReset(ttl?: number): void {
|
||||
for (const list of warmBoot.values()) for (const s of list) clearTimeout(s.evict);
|
||||
warmBoot.clear();
|
||||
WARM_REVIVE_MS = ttl && ttl > 0 ? ttl : 10000;
|
||||
}
|
||||
|
||||
/** Test hook (smokes): what the memo is holding — slots per key, and how
|
||||
* many of them still keep a dialog payload alive. */
|
||||
public static _warmBootStats(): { keys: number; slots: number; dlgs: number; drafts: string[] } {
|
||||
let slots = 0, dlgs = 0;
|
||||
const drafts: string[] = [];
|
||||
for (const list of warmBoot.values()) {
|
||||
for (const s of list) {
|
||||
slots++;
|
||||
if (s.dlg) { dlgs++; drafts.push(s.dlg.kind); }
|
||||
}
|
||||
}
|
||||
return { keys: warmBoot.size, slots, dlgs, drafts };
|
||||
}
|
||||
|
||||
public setConfig(config: CardConfig): void {
|
||||
this._config = { icon_size: 2.5, show_temperature: true, live_states: true, show_signal: true, ...config };
|
||||
if (this._config.kiosk) { this._booting = false; this._bootFading = false; } // kiosk: 100dvh, nothing to settle
|
||||
else {
|
||||
// DEV-B703-01: this page already booted an identical card at this
|
||||
// viewport — adopt its settled header height and skip the veil
|
||||
// entirely: the card reveals synchronously in the final geometry (the
|
||||
// saved zoom is armed below, HP-1551). _bootSoft covers any residual
|
||||
// chrome drift with a glide instead of a snap.
|
||||
const warm = warmBoot.get(warmBootKey(this._config));
|
||||
if (warm) {
|
||||
this._booting = false;
|
||||
this._bootFading = false;
|
||||
this._hdrH = warm.hdrH;
|
||||
this._warmVp = warm.vp; // adopted below, once the model is in hand
|
||||
this._bootSoft = true; // timer armed in connectedCallback...
|
||||
if (this.isConnected) { // ...unless setConfig re-runs while attached
|
||||
clearTimeout(this._bootSoftTimer);
|
||||
this._bootSoftTimer = window.setTimeout(() => { this._bootSoft = false; }, BOOT_SOFT_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (config.default_floor) this._space = config.default_floor;
|
||||
try {
|
||||
this._zoomBySpace = JSON.parse(localStorage.getItem(LS_ZOOM) || '{}') || {};
|
||||
@@ -962,7 +1066,127 @@ class HouseplanCard extends LitElement {
|
||||
// arm it BEFORE the first view computation, so the very first paint is
|
||||
// already at the user's zoom.
|
||||
if (this._mode === 'view' && !this._view) this._zoom = this._zoomBySpace[this._space] || 1;
|
||||
this._warmAdoptViewport(config);
|
||||
// AUD-159B1-01: the memo is claimed by DOM slot, and Lovelace calls
|
||||
// setConfig BEFORE it inserts the element — so the claim waits for
|
||||
// connectedCallback (still before the first render, so nothing flashes).
|
||||
// A setConfig on a card that is already attached (the editor's live
|
||||
// preview) re-claims right here, because the config is part of the key.
|
||||
if (this.isConnected) this._warmAdopt();
|
||||
}
|
||||
|
||||
/**
|
||||
* DEV-B703-01/03 + AUD-159B1-01: find the memo of the card that used to sit
|
||||
* in THIS placement and step into it — the settled header height (no veil,
|
||||
* synchronous reveal in the final geometry) and, when the slot is provably
|
||||
* ours, the whole viewport. `_bootSoft` covers residual chrome drift with a
|
||||
* glide instead of a snap.
|
||||
*/
|
||||
private _warmAdopt(): void {
|
||||
if (this._config?.kiosk) return;
|
||||
const key = warmBootKey(this._config);
|
||||
if (this._warmKey === key && this._warmSlot) return; // already sitting in it
|
||||
if (this._warmSlot) this._warmRelease(); // the config (or the window) changed under us
|
||||
const place = this.parentNode;
|
||||
const idx = this._warmIdx(place);
|
||||
const list = warmBoot.get(key);
|
||||
if (!list || !list.length) return; // cold page — the full protective boot
|
||||
// the same element re-attached (Lovelace moves cards around): our own slot
|
||||
// is waiting for us, and our live state is newer than anything in it
|
||||
const mine = list.find((s) => s.owner === this._warmGen);
|
||||
if (mine) {
|
||||
clearTimeout(mine.evict); mine.evict = 0; mine.freed = 0; mine.live = true;
|
||||
this._warmSlot = mine;
|
||||
this._warmKey = key;
|
||||
return;
|
||||
}
|
||||
const { slot, sure } = warmMatch(list, this._warmGen, place, idx);
|
||||
if (!slot) return;
|
||||
this._booting = false;
|
||||
this._bootFading = false;
|
||||
this._hdrH = slot.hdrH;
|
||||
this._bootSoft = true; // timer armed in connectedCallback...
|
||||
if (this.isConnected) { // ...unless we are claiming while already attached
|
||||
clearTimeout(this._bootSoftTimer);
|
||||
this._bootSoftTimer = window.setTimeout(() => { this._bootSoft = false; }, BOOT_SOFT_MS);
|
||||
}
|
||||
this._warmKey = key;
|
||||
if (sure) {
|
||||
clearTimeout(slot.evict); // the dialog is ours now, not the TTL's
|
||||
slot.evict = 0;
|
||||
slot.owner = this._warmGen;
|
||||
slot.place = place ? new WeakRef(place) : null;
|
||||
slot.idx = idx;
|
||||
slot.live = true;
|
||||
this._warmSlot = slot;
|
||||
this._warmVp = slot.vp; // adopted below, once the model is in hand
|
||||
this._warmAdoptViewport(this._config!);
|
||||
} else {
|
||||
// Two identical cards on one view and no way to tell which slot is ours:
|
||||
// the height is interchangeable (they settle at the same chrome), the
|
||||
// viewport and the dialog belong to somebody and must not be guessed.
|
||||
this._warmSlot = {
|
||||
owner: this._warmGen, place: place ? new WeakRef(place) : null, idx, live: true,
|
||||
hdrH: slot.hdrH, stageH: slot.stageH, vp: null, dlg: null, freed: 0, evict: 0,
|
||||
};
|
||||
list.push(this._warmSlot);
|
||||
this._warmTrim(list);
|
||||
}
|
||||
}
|
||||
|
||||
/** This element's position among its parent's children (-1 if unmounted). */
|
||||
private _warmIdx(place: Node | null): number {
|
||||
const kids = (place as Element | null)?.children;
|
||||
if (!kids) return -1;
|
||||
for (let i = 0; i < kids.length; i++) if (kids[i] === this) return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Let go of the slot: the placement is still there, this instance is not. */
|
||||
private _warmRelease(): void {
|
||||
const s = this._warmSlot;
|
||||
const key = this._warmKey;
|
||||
this._warmSlot = null;
|
||||
this._warmKey = null;
|
||||
if (!s || !key) return;
|
||||
s.freed = Date.now();
|
||||
if (s.owner === this._warmGen) s.live = false;
|
||||
this._warmScheduleEvict(s, key);
|
||||
}
|
||||
|
||||
/** Drop the oldest slot that nobody is sitting in. */
|
||||
private _warmTrim(list: WarmEntry[]): void {
|
||||
while (list.length > WARM_MAX_SLOTS) {
|
||||
const i = list.findIndex((s) => !s.live);
|
||||
if (i < 0) break;
|
||||
clearTimeout(list[i].evict);
|
||||
list.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AUD-159B1-03: the TTL used to be a rule checked at revive time only, so a
|
||||
* draft nobody came back for stayed reachable from module scope until the
|
||||
* page reloaded — with the space dialog that means a whole plan file held as
|
||||
* base64 (8 MiB allowed by the backend). Free the payload the moment it
|
||||
* stops being revivable; the height and the viewport are bytes, they stay.
|
||||
*/
|
||||
private _warmScheduleEvict(s: WarmEntry, key: string): void {
|
||||
clearTimeout(s.evict);
|
||||
if (!s.dlg) return;
|
||||
const freed = s.freed;
|
||||
const gen = s.owner;
|
||||
s.evict = window.setTimeout(() => {
|
||||
s.evict = 0;
|
||||
if (s.freed !== freed || s.owner !== gen) return; // a successor took over
|
||||
// the dialog is unrevivable now, whoever is sitting in the slot
|
||||
s.dlg = null;
|
||||
// a slot nobody re-claimed is also what makes the NEXT claim ambiguous
|
||||
const list = warmBoot.get(key);
|
||||
if (!s.live && list && list.length > 1) {
|
||||
const i = list.indexOf(s);
|
||||
if (i >= 0) list.splice(i, 1);
|
||||
}
|
||||
}, WARM_REVIVE_MS + 250);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1003,14 +1227,31 @@ class HouseplanCard extends LitElement {
|
||||
private _warmPatch(patch: Partial<WarmEntry>, create = false): void {
|
||||
if (this._config?.kiosk) return;
|
||||
const k = warmBootKey(this._config);
|
||||
const cur = warmBoot.get(k);
|
||||
if (!cur && !create) return;
|
||||
warmBoot.set(k, { hdrH: this._hdrH, stageH: 0, vp: null, dlg: null, freed: 0, ...(cur || {}), ...patch });
|
||||
while (warmBoot.size > WARM_MAX_KEYS) {
|
||||
const oldest = warmBoot.keys().next().value; // Map keeps insertion order
|
||||
if (oldest === undefined || oldest === k) break;
|
||||
warmBoot.delete(oldest);
|
||||
// The key carries the window size and the view path: once either changes
|
||||
// under a LIVE card, its slot describes a geometry that is no longer on
|
||||
// screen, and the next mount at the new size must boot cold (documented
|
||||
// in docs/WARM-REMOUNT.md §1) — so write nothing rather than lie.
|
||||
if (this._warmSlot && this._warmKey !== k) return;
|
||||
if (!this._warmSlot) {
|
||||
if (!create) return;
|
||||
const place = this.parentNode;
|
||||
this._warmKey = k;
|
||||
this._warmSlot = {
|
||||
owner: this._warmGen, place: place ? new WeakRef(place) : null, idx: this._warmIdx(place), live: true,
|
||||
hdrH: this._hdrH, stageH: 0, vp: null, dlg: null, freed: 0, evict: 0,
|
||||
};
|
||||
const list = warmBoot.get(k) || [];
|
||||
list.push(this._warmSlot);
|
||||
warmBoot.set(k, list);
|
||||
this._warmTrim(list);
|
||||
while (warmBoot.size > WARM_MAX_KEYS) {
|
||||
const oldest = warmBoot.keys().next().value; // Map keeps insertion order
|
||||
if (oldest === undefined || oldest === k) break;
|
||||
for (const s of warmBoot.get(oldest) || []) clearTimeout(s.evict);
|
||||
warmBoot.delete(oldest);
|
||||
}
|
||||
}
|
||||
Object.assign(this._warmSlot, patch);
|
||||
}
|
||||
|
||||
private _warmViewportState(): WarmViewport {
|
||||
@@ -1078,6 +1319,13 @@ class HouseplanCard extends LitElement {
|
||||
const patch: Partial<WarmEntry> = { vp: this._warmViewportState() };
|
||||
// do not overwrite the snapshot we are about to revive FROM
|
||||
if (!this._warmRevivePending) patch.dlg = this._warmDialogState();
|
||||
// AUD-159B1-01: the placement is re-measured from the live DOM, so a card
|
||||
// that was moved (a sibling added above it) still names its own slot.
|
||||
if (this.isConnected && this._warmSlot?.owner === this._warmGen) {
|
||||
const place = this.parentNode;
|
||||
patch.place = place ? new WeakRef(place) : null;
|
||||
patch.idx = this._warmIdx(place);
|
||||
}
|
||||
this._warmPatch(patch);
|
||||
}
|
||||
|
||||
@@ -1089,12 +1337,14 @@ class HouseplanCard extends LitElement {
|
||||
*/
|
||||
private _warmReviveDialog(): void {
|
||||
this._warmRevivePending = false;
|
||||
const k = warmBootKey(this._config);
|
||||
const e = warmBoot.get(k);
|
||||
const e = this._warmSlot; // AUD-159B1-01: OUR slot, never a neighbour's
|
||||
this._warmReviveTimer = undefined;
|
||||
if (!e || !e.dlg) return;
|
||||
const d = e.dlg;
|
||||
warmBoot.set(k, { ...e, dlg: null, freed: 0 }); // consume-once: no zombie on the third mount
|
||||
if (!e.freed || Date.now() - e.freed > WARM_REVIVE_MS) return; // owner alive, or gone long ago
|
||||
const freed = e.freed;
|
||||
e.dlg = null; e.freed = 0; // consume-once: no zombie on the third mount
|
||||
clearTimeout(e.evict); e.evict = 0;
|
||||
if (!freed || Date.now() - freed > WARM_REVIVE_MS) return; // owner alive, or gone long ago
|
||||
if (d.space !== this._space || d.mode !== this._mode) return; // never in another space/editor
|
||||
switch (d.kind) {
|
||||
case 'space': this._spaceDialog = { ...d.data, busy: false, savedBusy: false }; break;
|
||||
@@ -1329,6 +1579,7 @@ class HouseplanCard extends LitElement {
|
||||
protected updated(): void {
|
||||
this._skyRelease();
|
||||
this._warmSnapshot(); // DEV-B703-03: the memo follows what is on screen
|
||||
this._dtMeasure(); // the selected label's frame follows the glyphs
|
||||
const stage = this._stageEl;
|
||||
if (stage && !this._roViewport) {
|
||||
this._roViewport = new ResizeObserver(() => this._refitView());
|
||||
@@ -2570,6 +2821,10 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private _stagePointerMove(ev: PointerEvent): void {
|
||||
if (this._dtDrag?.pid === ev.pointerId) {
|
||||
this._dtMove(ev);
|
||||
return;
|
||||
}
|
||||
if (this._bdDrag?.pid === ev.pointerId) {
|
||||
this._bdMove(ev);
|
||||
return;
|
||||
@@ -2672,6 +2927,10 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this._dtDrag?.pid === ev.pointerId) {
|
||||
this._dtUp();
|
||||
return;
|
||||
}
|
||||
if (this._bdDrag?.pid === ev.pointerId) {
|
||||
this._bdUp();
|
||||
return;
|
||||
@@ -2942,6 +3201,8 @@ class HouseplanCard extends LitElement {
|
||||
this._decorSel = null;
|
||||
this._decorTool = 'select';
|
||||
this._bdDrag = null;
|
||||
this._dtDrag = null;
|
||||
this._dtBox = null;
|
||||
this._saveNav();
|
||||
}
|
||||
|
||||
@@ -3487,10 +3748,12 @@ class HouseplanCard extends LitElement {
|
||||
return true;
|
||||
}
|
||||
if (t === 'text') {
|
||||
// …and the press did NOT land on an existing label: those are the one
|
||||
// exception to the inertness above (see _decorShapeDown).
|
||||
const p = this._snap(this._svgPoint(ev), ev);
|
||||
this._decorTextDialog = {
|
||||
x: clampCanvasN(p[0] / NORM_W), y: clampCanvasN(p[1] / this._decorH),
|
||||
text: '', size: 'm', color: this._decorStyle.color,
|
||||
text: '', color: this._decorStyle.color,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
@@ -3547,6 +3810,19 @@ class HouseplanCard extends LitElement {
|
||||
// backdrop tool grabs the picture). Swallowing it here was the bug — the
|
||||
// click on a line end did nothing but keep the old selection alive.
|
||||
const t = this._decorTool;
|
||||
// ONE exception (owner, 2026-08-04): under the TEXT tool an existing LABEL
|
||||
// is a target again, and pressing it opens its editor instead of starting
|
||||
// a new label on top of it. Everything else stays inert — a press on a
|
||||
// line or a rectangle under the text tool still reaches the stage and
|
||||
// creates a new label there (the CSS keeps them pointer-inert, this is
|
||||
// the belt to that pair of braces).
|
||||
if (t === 'text') {
|
||||
if (shape.kind !== 'text') return;
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
this._decorOpenText(shape);
|
||||
return;
|
||||
}
|
||||
if (t !== 'select' && t !== 'erase') return;
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
@@ -3606,27 +3882,171 @@ class HouseplanCard extends LitElement {
|
||||
/** Double click on a text shape (select tool) re-opens its dialog. */
|
||||
private _decorShapeDbl(shape: any): void {
|
||||
if (this._mode !== 'decor' || this._decorTool !== 'select' || shape.kind !== 'text') return;
|
||||
this._decorTextDialog = { id: shape.id, x: shape.x, y: shape.y,
|
||||
text: shape.text, size: shape.size || 'm', color: shape.color };
|
||||
this._decorOpenText(shape);
|
||||
}
|
||||
|
||||
/** Open the editor of an existing label (double click, or the text tool). */
|
||||
private _decorOpenText(shape: any): void {
|
||||
this._decorTextDialog = {
|
||||
id: shape.id, x: shape.x, y: shape.y, text: shape.text, color: shape.color,
|
||||
entity: shape.entity || '', attr: shape.attr || '', unit: shape.unit || '',
|
||||
};
|
||||
}
|
||||
|
||||
/** The link fields as they are STORED: only what the user actually filled in
|
||||
* ends up in the config, so a plain label stays byte-for-byte a plain one. */
|
||||
private _decorTextLink(d: { entity?: string; attr?: string; unit?: string }): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
const ent = (d.entity || '').trim();
|
||||
if (!ent) return out;
|
||||
out.entity = ent;
|
||||
const attr = (d.attr || '').trim();
|
||||
if (attr) out.attr = attr;
|
||||
const unit = (d.unit || '').trim();
|
||||
if (unit) out.unit = unit;
|
||||
return out;
|
||||
}
|
||||
|
||||
private _decorSaveText(): void {
|
||||
const d = this._decorTextDialog;
|
||||
if (!d || !d.text.trim()) { this._decorTextDialog = null; return; }
|
||||
// The user's own line breaks are kept; the surrounding whitespace is not.
|
||||
const text = String(d?.text ?? '').replace(/\r\n?/g, '\n').trim();
|
||||
if (!d || !text) { this._decorTextDialog = null; return; }
|
||||
const sp = this._curSpaceCfg;
|
||||
const link = this._decorTextLink(d);
|
||||
if (d.id) {
|
||||
sp.decor = this._decorList.map((x) => x.id === d.id
|
||||
? { ...x, text: d.text.trim(), size: d.size, color: d.color } : x);
|
||||
sp.decor = this._decorList.map((x) => {
|
||||
if (x.id !== d.id) return x;
|
||||
// the OLD link is dropped wholesale: clearing the entity in the dialog
|
||||
// must clear the attribute and the unit with it, not leave orphans
|
||||
const { entity, attr, unit, ...rest } = x;
|
||||
return { ...rest, text, color: d.color, ...link };
|
||||
});
|
||||
} else {
|
||||
const id = 'dc' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
|
||||
sp.decor = [...this._decorList, { id, kind: 'text', x: d.x, y: d.y,
|
||||
text: d.text.trim(), size: d.size, color: d.color }];
|
||||
text, color: d.color, ...link }];
|
||||
this._decorSel = id;
|
||||
}
|
||||
this._decorTextDialog = null;
|
||||
this._saveConfig();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
// ---- the text block: scale by a corner, rotate by its handle ----
|
||||
// The mechanics are the backdrop frame's (docs/BACKDROP.md §2), reused
|
||||
// rather than reinvented: chrome that never takes a pointer, finger-sized
|
||||
// handles that always do, the gesture written live into the config and
|
||||
// PERSISTED only if something actually moved. What differs is the pivot —
|
||||
// a label has an anchor (its x/y), not a box, so both gestures are about
|
||||
// that anchor and the text never walks away from the point it was placed at.
|
||||
|
||||
/** The selected text shape, when the select tool has one. */
|
||||
private get _dtSel(): any | null {
|
||||
if (this._mode !== 'decor' || this._decorTool !== 'select' || !this._decorSel) return null;
|
||||
const sh = this._decorList.find((x) => x.id === this._decorSel);
|
||||
return sh && sh.kind === 'text' ? sh : null;
|
||||
}
|
||||
|
||||
/** Write scale/angle into the shape — live, without saving. */
|
||||
private _dtApply(id: string, patch: { scale?: number; angle?: number }): void {
|
||||
const sp = this._curSpaceCfg;
|
||||
if (!sp) return;
|
||||
sp.decor = this._decorList.map((x) => {
|
||||
if (x.id !== id) return x;
|
||||
// the first drag replaces the legacy font size with the scale it means,
|
||||
// so a shape never states its size twice
|
||||
const { size, ...rest } = x;
|
||||
const out: any = { ...rest };
|
||||
if (patch.scale !== undefined) out.scale = Number(patch.scale.toFixed(4));
|
||||
else if (x.scale === undefined && size !== undefined) out.scale = decorTextScale(x);
|
||||
if (patch.angle !== undefined) {
|
||||
if (patch.angle) out.angle = Number(patch.angle.toFixed(2));
|
||||
else delete out.angle; // straight again = the field goes away
|
||||
}
|
||||
return out;
|
||||
});
|
||||
this._cfgEpoch++;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private _dtStart(ev: PointerEvent, kind: 'scale' | 'rotate'): void {
|
||||
const sh = this._dtSel;
|
||||
if (!sh) return;
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
const ax = sh.x * NORM_W, ay = sh.y * this._decorH;
|
||||
const p = this._svgPoint(ev);
|
||||
this._dtDrag = {
|
||||
id: sh.id, kind, pid: ev.pointerId, ax, ay,
|
||||
r0: Math.hypot(p[0] - ax, p[1] - ay),
|
||||
a0: (Math.atan2(p[1] - ay, p[0] - ax) * 180) / Math.PI,
|
||||
scale0: decorTextScale(sh), angle0: Number(sh.angle) || 0,
|
||||
moved: false,
|
||||
};
|
||||
capturePointer(ev);
|
||||
}
|
||||
|
||||
private _dtMove(ev: PointerEvent): void {
|
||||
const d = this._dtDrag;
|
||||
if (!d) return;
|
||||
const p = this._svgPoint(ev);
|
||||
if (d.kind === 'scale') {
|
||||
// uniform, about the anchor: the distance from the anchor is invariant
|
||||
// under the block's own rotation, so a rotated block scales the same way
|
||||
const r = Math.hypot(p[0] - d.ax, p[1] - d.ay);
|
||||
if (d.r0 < 1e-6) return;
|
||||
const k = Math.min(DECOR_TEXT_SCALE_MAX, Math.max(DECOR_TEXT_SCALE_MIN, d.scale0 * (r / d.r0)));
|
||||
if (Math.abs(k - d.scale0) > 1e-6) d.moved = true;
|
||||
this._dtApply(d.id, { scale: k });
|
||||
return;
|
||||
}
|
||||
const a = (Math.atan2(p[1] - d.ay, p[0] - d.ax) * 180) / Math.PI;
|
||||
let ang = d.angle0 + (a - d.a0);
|
||||
// 5° steps — the step the device icons rotate in; Shift is the way past a
|
||||
// snap everywhere in this card (docs/CANVAS.md §9.4), so it is here too
|
||||
if (!ev.shiftKey) ang = Math.round(ang / DT_ANGLE_STEP) * DT_ANGLE_STEP;
|
||||
ang = ((ang % 360) + 360) % 360;
|
||||
if (ang > 180) ang -= 360;
|
||||
if (Math.abs(ang - d.angle0) > 1e-6) d.moved = true;
|
||||
this._dtApply(d.id, { angle: ang });
|
||||
}
|
||||
|
||||
private _dtUp(): void {
|
||||
const d = this._dtDrag;
|
||||
this._dtDrag = null;
|
||||
if (d?.moved) this._saveConfig();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure the selected label's own box. SVG can only tell us how big a text
|
||||
* actually came out once it is in the DOM, so the frame is one render
|
||||
* behind — and it is re-measured whenever the text, its scale or the
|
||||
* selection changes. Guarded against the render→measure→render loop by
|
||||
* comparing the numbers before asking for another update.
|
||||
*/
|
||||
private _dtMeasure(): void {
|
||||
const sh = this._dtSel;
|
||||
if (!sh) {
|
||||
if (this._dtBox) { this._dtBox = null; this.requestUpdate(); }
|
||||
return;
|
||||
}
|
||||
const el = this.renderRoot.querySelector(`text.dtext[data-id="${sh.id}"]`) as SVGGraphicsElement | null;
|
||||
if (!el || typeof (el as any).getBBox !== 'function') return;
|
||||
let b: DOMRect;
|
||||
try { b = el.getBBox(); } catch { return; } // not rendered yet (hidden card)
|
||||
if (!b || (!b.width && !b.height)) return;
|
||||
const box = { id: sh.id, x: b.x, y: b.y, w: b.width, h: b.height };
|
||||
const cur = this._dtBox;
|
||||
const same = cur && cur.id === box.id && Math.abs(cur.x - box.x) < 0.01
|
||||
&& Math.abs(cur.y - box.y) < 0.01 && Math.abs(cur.w - box.w) < 0.01
|
||||
&& Math.abs(cur.h - box.h) < 0.01;
|
||||
if (same) return;
|
||||
this._dtBox = box;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private _decorDeleteSel(): void {
|
||||
if (!this._decorSel) return;
|
||||
const sp = this._curSpaceCfg;
|
||||
@@ -3836,9 +4256,37 @@ class HouseplanCard extends LitElement {
|
||||
</g>` as unknown as TemplateResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* The selected text block's frame: a dashed outline, four corner handles
|
||||
* that scale it uniformly and one handle above it that turns it. Same
|
||||
* mechanics and same handle size as the backdrop frame (docs/BACKDROP.md
|
||||
* §2) — finger-sized in SCREEN terms, so it stays grabbable at any zoom —
|
||||
* and it rides the block's own rotation, so the corners stay at the corners.
|
||||
*/
|
||||
private _renderTextFrame(view: { x: number; y: number; w: number; h: number }): TemplateResult | typeof nothing {
|
||||
const sh = this._dtSel;
|
||||
const b = this._dtBox;
|
||||
if (!sh || !b || b.id !== sh.id) return nothing;
|
||||
const hr = Math.max(view.w, view.h) * 0.018;
|
||||
const ax = sh.x * NORM_W, ay = sh.y * this._decorH;
|
||||
const ang = Number(sh.angle) || 0;
|
||||
const corners: [number, number, string][] = [
|
||||
[-1, -1, 'nwse'], [1, -1, 'nesw'], [1, 1, 'nwse'], [-1, 1, 'nesw'],
|
||||
];
|
||||
const arm = hr * 2.2;
|
||||
return svg`<g class="dtframe" transform=${ang ? `rotate(${ang} ${ax} ${ay})` : nothing}>
|
||||
<rect class="dtbox" x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}"></rect>
|
||||
<line class="dtstem" x1="${b.x + b.w / 2}" y1="${b.y}" x2="${b.x + b.w / 2}" y2="${b.y - arm}"></line>
|
||||
<circle class="dthandle dtrot" cx="${b.x + b.w / 2}" cy="${b.y - arm}" r="${hr.toFixed(1)}"
|
||||
@pointerdown=${(e: PointerEvent) => this._dtStart(e, 'rotate')}></circle>
|
||||
${corners.map(([sx, sy, cur]) => svg`<circle class="dthandle dt-${cur}"
|
||||
cx="${sx < 0 ? b.x : b.x + b.w}" cy="${sy < 0 ? b.y : b.y + b.h}" r="${hr.toFixed(1)}"
|
||||
@pointerdown=${(e: PointerEvent) => this._dtStart(e, 'scale')}></circle>`)}
|
||||
</g>` as unknown as TemplateResult;
|
||||
}
|
||||
|
||||
private _renderDecorLayer(): TemplateResult {
|
||||
const W = NORM_W, H = this._decorH;
|
||||
const TXT = { s: 14, m: 20, l: 30 } as Record<string, number>;
|
||||
const editing = this._mode === 'decor';
|
||||
const shapes = this._decorList.map((sh) => {
|
||||
const cls = 'dshape' + (editing && this._decorSel === sh.id ? ' dsel' : '');
|
||||
@@ -3857,9 +4305,25 @@ class HouseplanCard extends LitElement {
|
||||
return svg`<ellipse class="${cls}" cx="${(sh.x + sh.w / 2) * W}" cy="${(sh.y + sh.h / 2) * H}"
|
||||
rx="${(sh.w / 2) * W}" ry="${(sh.h / 2) * H}" stroke="${sh.color}" stroke-width="${sh.width}"
|
||||
fill="${sh.fill ? sh.color : 'none'}" fill-opacity="${sh.fill ? 0.25 : 0}" @pointerdown=${down}></ellipse>`;
|
||||
if (sh.kind === 'text')
|
||||
return svg`<text class="${cls} dtext" x="${sh.x * W}" y="${sh.y * H}" fill="${sh.color}"
|
||||
font-size="${TXT[sh.size] || TXT.m}" @pointerdown=${down} @dblclick=${dbl}>${sh.text}</text>`;
|
||||
if (sh.kind === 'text') {
|
||||
// The label is painted from the LIVE value on every render — the same
|
||||
// `hass` the rest of the card reads, no polling of its own. Without an
|
||||
// entity `liveText` gives the stored text back byte-for-byte, so a
|
||||
// plain label is the plain label it always was (docs/LIVE-TEXT.md).
|
||||
const fs = DECOR_TEXT_BASE * decorTextScale(sh);
|
||||
const lines = decorTextLines(liveText(sh.text, sh, this.hass));
|
||||
const ax = sh.x * W, ay = sh.y * H;
|
||||
const ang = Number(sh.angle) || 0;
|
||||
// the block is centred on its anchor, horizontally (the layer's
|
||||
// text-anchor) and vertically — so adding a second line grows the
|
||||
// label in both directions instead of pushing the first one up
|
||||
const y0 = ay - ((lines.length - 1) * fs * DT_LINE) / 2;
|
||||
return svg`<text class="${cls} dtext" data-id="${sh.id}" x="${ax}" y="${ay}" fill="${sh.color}"
|
||||
font-size="${fs}" transform=${ang ? `rotate(${ang} ${ax} ${ay})` : nothing}
|
||||
@pointerdown=${down} @dblclick=${dbl}>${lines.map(
|
||||
(ln, i) => svg`<tspan x="${ax}" y="${y0 + i * fs * DT_LINE}">${ln}</tspan>`,
|
||||
)}</text>`;
|
||||
}
|
||||
return nothing;
|
||||
});
|
||||
// живое превью рисуемой фигуры
|
||||
@@ -3940,27 +4404,53 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
private _renderDecorTextDialog(): TemplateResult {
|
||||
const d = this._decorTextDialog!;
|
||||
const ent = (d.entity || '').trim();
|
||||
const st = ent ? this.hass?.states?.[ent] : null;
|
||||
const ownUnit = String(st?.attributes?.unit_of_measurement ?? '');
|
||||
// the preview is the REAL renderer: one substitution, one truth
|
||||
const preview = liveText(d.text, d, this.hass);
|
||||
return html`<div class="menuwrap dialogwrap" @click=${() => (this._decorTextDialog = null)}>
|
||||
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
|
||||
<div class="hd"><ha-icon icon="mdi:format-text"></ha-icon>${this._t('decor.text_title')}</div>
|
||||
<div class="body">
|
||||
<label>${this._t('decor.text_label')}</label>
|
||||
<input class="namein" .value=${d.text} autofocus
|
||||
@input=${(e: Event) => (this._decorTextDialog = { ...d, text: (e.target as HTMLInputElement).value })}
|
||||
@keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter') this._decorSaveText(); }} />
|
||||
<label>${this._t('decor.text_size')}</label>
|
||||
<div class="radiorow">
|
||||
${(['s', 'm', 'l'] as const).map(
|
||||
(sz) => html`<label class="srcrow inline">
|
||||
<input type="radio" name="dtsize" .checked=${d.size === sz}
|
||||
@change=${() => (this._decorTextDialog = { ...d, size: sz })} />
|
||||
<span>${this._t(('decor.size_' + sz) as any)}</span>
|
||||
</label>`,
|
||||
)}
|
||||
</div>
|
||||
${''/* a textarea, not an input: the user's own line breaks are kept
|
||||
and rendered (centred). Enter is a NEW LINE here, so saving
|
||||
moved to Ctrl/Cmd+Enter and the button. */}
|
||||
<textarea class="namein dtarea" rows="3" maxlength="200" .value=${d.text} autofocus
|
||||
@input=${(e: Event) => (this._decorTextDialog = { ...d, text: (e.target as HTMLTextAreaElement).value })}
|
||||
@keydown=${(e: KeyboardEvent) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) this._decorSaveText();
|
||||
}}></textarea>
|
||||
<div class="rhint">${this._t(ent ? 'decor.text_hint_live' : 'decor.text_hint')}</div>
|
||||
<label>${this._t('decor.color')}</label>
|
||||
<input type="color" .value=${d.color}
|
||||
@input=${(e: Event) => (this._decorTextDialog = { ...d, color: (e.target as HTMLInputElement).value })} />
|
||||
<label class="dispsection">${this._t('decor.live_group')}</label>
|
||||
<div class="rhint">${this._t('decor.live_hint')}</div>
|
||||
<label>${this._t('decor.live_entity')}</label>
|
||||
<input class="namein" type="text" list="hp-dtext-ents" placeholder=${this._t('decor.live_entity_ph')}
|
||||
.value=${d.entity || ''}
|
||||
@input=${(e: Event) => (this._decorTextDialog = { ...d, entity: (e.target as HTMLInputElement).value })} />
|
||||
<datalist id="hp-dtext-ents">
|
||||
${Object.keys(this.hass?.states || {}).map((id) => html`<option value=${id}></option>`)}
|
||||
</datalist>
|
||||
${ent ? html`
|
||||
<label>${this._t('decor.live_attr')}</label>
|
||||
<input class="namein" type="text" list="hp-dtext-attrs" placeholder=${this._t('decor.live_attr_ph')}
|
||||
.value=${d.attr || ''}
|
||||
@input=${(e: Event) => (this._decorTextDialog = { ...d, attr: (e.target as HTMLInputElement).value })} />
|
||||
<datalist id="hp-dtext-attrs">
|
||||
${Object.keys(st?.attributes || {}).map((a) => html`<option value=${a}></option>`)}
|
||||
</datalist>
|
||||
<label>${this._t('decor.live_unit')}</label>
|
||||
<input class="namein" type="text" .value=${d.unit || ''}
|
||||
placeholder=${(d.attr || '').trim() ? '' : ownUnit}
|
||||
@input=${(e: Event) => (this._decorTextDialog = { ...d, unit: (e.target as HTMLInputElement).value })} />
|
||||
<label>${this._t('decor.live_preview')}</label>
|
||||
<div class="dtpreview">${decorTextLines(preview).map((ln) => html`<div>${ln}</div>`)}</div>
|
||||
` : nothing}
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="spacer"></span>
|
||||
@@ -6581,6 +7071,7 @@ class HouseplanCard extends LitElement {
|
||||
grabbable (docs/BACKDROP.md §2). It exists only in the
|
||||
backdrop editor, where rooms and devices are pointer-inert. */}
|
||||
${this._renderBackdropFrame(view)}
|
||||
${this._renderTextFrame(view)}
|
||||
</svg>
|
||||
${''/* docs/CANVAS.md §6: an icon is a percentage of the PLAN and
|
||||
scales with it when you zoom — the behaviour the card always
|
||||
|
||||
+10
-4
@@ -273,10 +273,16 @@
|
||||
"decor.fill": "Fill",
|
||||
"decor.text_title": "Text label",
|
||||
"decor.text_label": "Text",
|
||||
"decor.text_size": "Size",
|
||||
"decor.size_s": "Small",
|
||||
"decor.size_m": "Medium",
|
||||
"decor.size_l": "Large",
|
||||
"decor.text_hint": "A new line here is a new line on the plan; the block is centred. Drag a corner to resize it, the handle above it to turn it.",
|
||||
"decor.text_hint_live": "{} is where the value lands. Without it the value is added at the end. A new line here is a new line on the plan.",
|
||||
"decor.live_group": "Live value",
|
||||
"decor.live_hint": "The label may show one entity's value — as Home Assistant reports it, with no rounding. A dead entity shows a dash.",
|
||||
"decor.live_entity": "Entity",
|
||||
"decor.live_entity_ph": "none — a plain label",
|
||||
"decor.live_attr": "Attribute",
|
||||
"decor.live_attr_ph": "the state",
|
||||
"decor.live_unit": "Unit",
|
||||
"decor.live_preview": "Preview",
|
||||
"decor.backdrop": "Backdrop image",
|
||||
"decor.backdrop_hint": "Drag the picture to move it, pull a corner to resize it. Shift — off the grid.",
|
||||
"decor.backdrop_reset": "Reset the picture",
|
||||
|
||||
+10
-4
@@ -273,10 +273,16 @@
|
||||
"decor.fill": "Залить",
|
||||
"decor.text_title": "Надпись",
|
||||
"decor.text_label": "Текст",
|
||||
"decor.text_size": "Размер",
|
||||
"decor.size_s": "Мелкий",
|
||||
"decor.size_m": "Средний",
|
||||
"decor.size_l": "Крупный",
|
||||
"decor.text_hint": "Перевод строки здесь — перевод строки на плане; блок выравнивается по центру. Тяните угол — меняет размер, ручка сверху — поворот.",
|
||||
"decor.text_hint_live": "{} — место, куда встанет значение. Без него значение добавится в конец. Перевод строки здесь — перевод строки на плане.",
|
||||
"decor.live_group": "Живое значение",
|
||||
"decor.live_hint": "Надпись может показывать значение одной сущности — так, как его отдаёт Home Assistant, без округления. Мёртвая сущность — прочерк.",
|
||||
"decor.live_entity": "Сущность",
|
||||
"decor.live_entity_ph": "нет — обычная надпись",
|
||||
"decor.live_attr": "Атрибут",
|
||||
"decor.live_attr_ph": "состояние",
|
||||
"decor.live_unit": "Единица",
|
||||
"decor.live_preview": "Предпросмотр",
|
||||
"decor.backdrop": "Картинка-подложка",
|
||||
"decor.backdrop_hint": "Тяните картинку — двигается, тяните угол — меняет размер. Shift — мимо сетки.",
|
||||
"decor.backdrop_reset": "Вернуть картинку",
|
||||
|
||||
@@ -910,6 +910,105 @@ export function floorsOf(hass: any): FloorInfo[] {
|
||||
return list;
|
||||
}
|
||||
|
||||
// ---------------- live text on a decor label (docs/LIVE-TEXT.md) -------------
|
||||
|
||||
/** What a dead sensor says. A label that vanishes with its entity is worse
|
||||
* than one that admits it has no data. */
|
||||
export const LIVE_TEXT_DASH = '—';
|
||||
/** A caption is a caption: an attribute that turns out to be a 4 KB string
|
||||
* must not become the plan's wallpaper. */
|
||||
export const LIVE_TEXT_VALUE_MAX = 60;
|
||||
/** The one placeholder. Not `{{ }}`: this is a substitution, not a template
|
||||
* language — see docs/LIVE-TEXT.md for why that line is where it is. */
|
||||
export const LIVE_TEXT_SLOT = '{}';
|
||||
|
||||
export interface LiveTextLink {
|
||||
/** entity id whose value lands in the label; absent = a plain static label */
|
||||
entity?: string | null;
|
||||
/** attribute to read instead of the state */
|
||||
attr?: string | null;
|
||||
/** suffix; absent = the entity's own unit_of_measurement (state only) */
|
||||
unit?: string | null;
|
||||
}
|
||||
|
||||
/** One attribute/state value as text, or null when there is nothing to show. */
|
||||
function liveRaw(raw: unknown): string | null {
|
||||
if (raw === undefined || raw === null) return null;
|
||||
if (Array.isArray(raw)) {
|
||||
const s = raw.map((v) => (v === null || v === undefined ? '' : String(v))).join(', ');
|
||||
return s ? s.slice(0, LIVE_TEXT_VALUE_MAX) : null;
|
||||
}
|
||||
if (typeof raw === 'object') return null; // a dict is not a caption
|
||||
const s = String(raw);
|
||||
return s === '' ? null : s.slice(0, LIVE_TEXT_VALUE_MAX);
|
||||
}
|
||||
|
||||
/**
|
||||
* The live value of a linked label, unit included — exactly as HA reports it.
|
||||
* No rounding, no reformatting, no localised separators: rounding belongs to
|
||||
* the sensor's `display_precision`, and duplicating it here would make two
|
||||
* sources of truth (docs/LIVE-TEXT.md).
|
||||
*
|
||||
* Unit resolution: an explicit `unit` always wins. An empty one inherits the
|
||||
* entity's `unit_of_measurement` ONLY when the STATE is being read — a
|
||||
* `battery_level` attribute on a °C sensor must not come out as «73 °C».
|
||||
*/
|
||||
export function liveTextValue(hass: any, link: LiveTextLink | null | undefined): string {
|
||||
const id = (link?.entity || '').trim();
|
||||
if (!id) return '';
|
||||
const st = hass?.states?.[id];
|
||||
const state = st?.state;
|
||||
if (!st || state === undefined || state === null || state === ''
|
||||
|| state === 'unavailable' || state === 'unknown') return LIVE_TEXT_DASH;
|
||||
const attr = (link?.attr || '').trim();
|
||||
const val = liveRaw(attr ? st.attributes?.[attr] : state);
|
||||
if (val === null) return LIVE_TEXT_DASH; // the attribute is not on this entity
|
||||
const own = attr ? '' : String(st.attributes?.unit_of_measurement ?? '').trim();
|
||||
const unit = String(link?.unit ?? '').trim() || own;
|
||||
return unit ? `${val} ${unit}` : val;
|
||||
}
|
||||
|
||||
/**
|
||||
* The label as it must be painted: the template with its first `{}` replaced
|
||||
* by the live value. A template without the placeholder gets the value
|
||||
* appended after a space, so picking an entity and typing nothing sensible
|
||||
* still shows something useful. Only the FIRST `{}` is substituted — one
|
||||
* label, one value (docs/LIVE-TEXT.md «not a multi-entity widget»).
|
||||
* Without an entity the text is returned byte-for-byte.
|
||||
*/
|
||||
export function liveText(text: string | null | undefined, link: LiveTextLink | null | undefined, hass: any): string {
|
||||
const tpl = text ?? '';
|
||||
if (!(link?.entity || '').trim()) return tpl;
|
||||
const v = liveTextValue(hass, link);
|
||||
const i = tpl.indexOf(LIVE_TEXT_SLOT);
|
||||
if (i >= 0) return tpl.slice(0, i) + v + tpl.slice(i + LIVE_TEXT_SLOT.length);
|
||||
return tpl ? `${tpl} ${v}` : v;
|
||||
}
|
||||
|
||||
export const DECOR_TEXT_BASE = 20; // px at scale 1 — what 'm' has always been
|
||||
export const DECOR_TEXT_SCALE_MIN = 0.15;
|
||||
export const DECOR_TEXT_SCALE_MAX = 20;
|
||||
|
||||
/**
|
||||
* The font multiplier of a decor text shape. `scale` is the field the corner
|
||||
* handles write; the legacy `size` ('s'|'m'|'l') is read as the multiplier it
|
||||
* used to render at (14/20/30 px against the base 20), so a label drawn
|
||||
* before the handles existed comes back at exactly its old size without any
|
||||
* migration. An explicit `scale` wins — it is the newer, finer statement of
|
||||
* the same thing, and the first drag replaces `size` with it.
|
||||
*/
|
||||
export function decorTextScale(shape: { scale?: unknown; size?: unknown } | null | undefined): number {
|
||||
const s = Number(shape?.scale);
|
||||
if (Number.isFinite(s) && s > 0) return Math.min(DECOR_TEXT_SCALE_MAX, Math.max(DECOR_TEXT_SCALE_MIN, s));
|
||||
const legacy: Record<string, number> = { s: 0.7, m: 1, l: 1.5 };
|
||||
return legacy[String(shape?.size ?? '')] ?? 1;
|
||||
}
|
||||
/** The lines of a decor label. Explicit newlines only: the label never wraps
|
||||
* by itself, so a caption cannot reflow (and jump) on a state change. */
|
||||
export function decorTextLines(s: string | null | undefined): string[] {
|
||||
return String(s ?? '').replace(/\r\n?/g, '\n').split('\n');
|
||||
}
|
||||
|
||||
/** Substitute every occurrence of {name} placeholders in a template string. */
|
||||
export function subst(s: string, vars?: Record<string, string | number>): string {
|
||||
if (!vars) return s;
|
||||
|
||||
@@ -896,6 +896,15 @@ export const cardStyles = css`
|
||||
.stage.mode-decor.dtool-ellipse .decorlayer .dshape,
|
||||
.stage.mode-decor.dtool-text .decorlayer .dshape,
|
||||
.stage.mode-decor.dtool-backdrop .decorlayer .dshape { pointer-events: none; }
|
||||
/* ONE exception (owner, 2026-08-04): under the TEXT tool an existing LABEL
|
||||
is a target again — pressing it opens its editor instead of starting a
|
||||
new label on top of the old one. Only labels: a line or a rectangle
|
||||
under the text tool stays inert, so the press reaches the stage and a
|
||||
new label is created there. */
|
||||
.stage.mode-decor.dtool-text .decorlayer .dshape.dtext {
|
||||
pointer-events: visiblePainted;
|
||||
cursor: text;
|
||||
}
|
||||
.decorlayer .dsel {
|
||||
filter: drop-shadow(0 0 3px var(--hp-accent));
|
||||
}
|
||||
@@ -945,6 +954,51 @@ export const cardStyles = css`
|
||||
transform: translate(-50%, -50%);
|
||||
border: 1px solid var(--hp-accent);
|
||||
}
|
||||
/* the selected text block's frame — same chrome rules as the backdrop's:
|
||||
the outline never takes a pointer, the handles always do, and they are
|
||||
finger-sized because this is dragged on a tablet too */
|
||||
.dtframe .dtbox {
|
||||
fill: none;
|
||||
stroke: var(--hp-accent);
|
||||
stroke-width: 1.5;
|
||||
stroke-dasharray: 7 5;
|
||||
vector-effect: non-scaling-stroke;
|
||||
pointer-events: none;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.dtframe .dtstem {
|
||||
stroke: var(--hp-accent);
|
||||
stroke-width: 1.5;
|
||||
vector-effect: non-scaling-stroke;
|
||||
pointer-events: none;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.dtframe .dthandle {
|
||||
fill: var(--hp-accent);
|
||||
stroke: #fff;
|
||||
stroke-width: 1.5;
|
||||
vector-effect: non-scaling-stroke;
|
||||
pointer-events: all;
|
||||
touch-action: none;
|
||||
}
|
||||
.dtframe .dt-nwse { cursor: nwse-resize; }
|
||||
.dtframe .dt-nesw { cursor: nesw-resize; }
|
||||
.dtframe .dtrot { cursor: grab; }
|
||||
.dtarea {
|
||||
resize: vertical;
|
||||
min-height: 3.4em;
|
||||
font: inherit;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.dtpreview {
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
background: rgba(127, 127, 127, 0.12);
|
||||
text-align: center;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-weight: 600;
|
||||
}
|
||||
.stage.mode-decor.dtool-line, .stage.mode-decor.dtool-rect,
|
||||
.stage.mode-decor.dtool-ellipse, .stage.mode-decor.dtool-text {
|
||||
cursor: crosshair;
|
||||
|
||||
+112
-1
@@ -15,7 +15,9 @@ import {
|
||||
contentUrl, chunk, referencedContentUrls, MAX_SIGN_PATHS,
|
||||
interiorPoint,
|
||||
segmentCm, formatLength, roomEdges, roomPoly, paperRoomShapes, pointOnBoundary, pointStrictlyInside, roomsOverlap,
|
||||
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, openingShoulders, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, poleOfInaccessibility, runServiceFor, TOGGLE_SAFE_DOMAINS, coverService, coverMoving, coverEntityOf } from '../test-build/logic.js';
|
||||
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, openingShoulders, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, poleOfInaccessibility, runServiceFor, TOGGLE_SAFE_DOMAINS, coverService, coverMoving, coverEntityOf,
|
||||
liveText, liveTextValue, decorTextScale, decorTextLines,
|
||||
LIVE_TEXT_DASH, LIVE_TEXT_VALUE_MAX, DECOR_TEXT_SCALE_MIN, DECOR_TEXT_SCALE_MAX } from '../test-build/logic.js';
|
||||
import {
|
||||
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
|
||||
} from '../test-build/rules.js';
|
||||
@@ -1190,3 +1192,112 @@ test('openingShoulders: angled wall measures along the wall direction', () => {
|
||||
assert.equal(off.centered, false);
|
||||
assert.ok(Math.abs(off.wallCenter[0] - 150) < 1e-9 && Math.abs(off.wallCenter[1] - 200) < 1e-9);
|
||||
});
|
||||
|
||||
|
||||
// ---------------- live text on a decor label (docs/LIVE-TEXT.md) ------------
|
||||
|
||||
const hassLive = {
|
||||
states: {
|
||||
'sensor.tank': { state: '68', attributes: { unit_of_measurement: '%' } },
|
||||
'sensor.plain': { state: '17.4', attributes: {} },
|
||||
'climate.hall': { state: 'heat', attributes: { current_temperature: 21.5, unit_of_measurement: '°C', preset_modes: ['home', 'away'] } },
|
||||
'sensor.dead': { state: 'unavailable', attributes: { unit_of_measurement: '%' } },
|
||||
'sensor.unknown': { state: 'unknown', attributes: {} },
|
||||
'sensor.blob': { state: 'ok', attributes: { payload: { a: 1 }, zero: 0, no: false, empty: '' } },
|
||||
},
|
||||
};
|
||||
|
||||
test('liveText: no entity = the text is untouched, byte for byte', () => {
|
||||
assert.equal(liveText('Кухня', null, hassLive), 'Кухня');
|
||||
assert.equal(liveText('Бак {}', {}, hassLive), 'Бак {}');
|
||||
assert.equal(liveText('Бак {}', { entity: ' ' }, hassLive), 'Бак {}');
|
||||
assert.equal(liveText('', null, hassLive), '');
|
||||
});
|
||||
|
||||
test('liveText: the placeholder is where the value lands', () => {
|
||||
assert.equal(liveText('Бак {}', { entity: 'sensor.tank' }, hassLive), 'Бак 68 %');
|
||||
assert.equal(liveText('{} в баке', { entity: 'sensor.tank' }, hassLive), '68 % в баке');
|
||||
});
|
||||
|
||||
test('liveText: no placeholder = the value is appended after a space', () => {
|
||||
assert.equal(liveText('Бак', { entity: 'sensor.tank' }, hassLive), 'Бак 68 %');
|
||||
assert.equal(liveText('', { entity: 'sensor.tank' }, hassLive), '68 %', 'empty template = the bare value, no leading space');
|
||||
});
|
||||
|
||||
test('liveText: only the FIRST placeholder is replaced — one label, one value', () => {
|
||||
assert.equal(liveText('{} и {}', { entity: 'sensor.tank' }, hassLive), '68 % и {}');
|
||||
});
|
||||
|
||||
test('liveText: the placeholder may sit on any line of a multi-line label', () => {
|
||||
assert.equal(liveText('Бак\n{}', { entity: 'sensor.tank' }, hassLive), 'Бак\n68 %');
|
||||
});
|
||||
|
||||
test('liveTextValue: unit comes from the entity, an explicit one wins', () => {
|
||||
assert.equal(liveTextValue(hassLive, { entity: 'sensor.tank' }), '68 %');
|
||||
assert.equal(liveTextValue(hassLive, { entity: 'sensor.tank', unit: 'проц.' }), '68 проц.');
|
||||
assert.equal(liveTextValue(hassLive, { entity: 'sensor.tank', unit: ' ' }), '68 %', 'blank = inherit');
|
||||
assert.equal(liveTextValue(hassLive, { entity: 'sensor.plain' }), '17.4', 'no unit on the entity, none added');
|
||||
});
|
||||
|
||||
test('liveTextValue: an attribute never inherits the STATE unit', () => {
|
||||
assert.equal(liveTextValue(hassLive, { entity: 'climate.hall', attr: 'current_temperature' }), '21.5',
|
||||
'the entity °C describes the state, not the attribute');
|
||||
assert.equal(liveTextValue(hassLive, { entity: 'climate.hall', attr: 'current_temperature', unit: '°C' }), '21.5 °C');
|
||||
assert.equal(liveTextValue(hassLive, { entity: 'climate.hall' }), 'heat °C', 'the state keeps the entity unit');
|
||||
});
|
||||
|
||||
test('liveTextValue: values are shown as HA reports them — no rounding', () => {
|
||||
const hass = { states: { 'sensor.x': { state: '17.40000', attributes: { unit_of_measurement: '°C' } } } };
|
||||
assert.equal(liveTextValue(hass, { entity: 'sensor.x' }), '17.40000 °C');
|
||||
});
|
||||
|
||||
test('liveTextValue: a dead or missing entity is a dash, and the dash has no unit', () => {
|
||||
assert.equal(liveTextValue(hassLive, { entity: 'sensor.dead' }), LIVE_TEXT_DASH);
|
||||
assert.equal(liveTextValue(hassLive, { entity: 'sensor.unknown' }), LIVE_TEXT_DASH);
|
||||
assert.equal(liveTextValue(hassLive, { entity: 'sensor.nope' }), LIVE_TEXT_DASH);
|
||||
assert.equal(liveTextValue({}, { entity: 'sensor.tank' }), LIVE_TEXT_DASH);
|
||||
assert.equal(liveText('Бак {}', { entity: 'sensor.dead' }, hassLive), 'Бак —', 'the rest of the template stays');
|
||||
});
|
||||
|
||||
test('liveTextValue: a missing attribute on a live entity is a dash too', () => {
|
||||
assert.equal(liveTextValue(hassLive, { entity: 'climate.hall', attr: 'nope' }), LIVE_TEXT_DASH);
|
||||
assert.equal(liveTextValue(hassLive, { entity: 'sensor.blob', attr: 'payload' }), LIVE_TEXT_DASH, 'a dict is not a caption');
|
||||
assert.equal(liveTextValue(hassLive, { entity: 'sensor.blob', attr: 'empty' }), LIVE_TEXT_DASH);
|
||||
assert.equal(liveTextValue(hassLive, { entity: 'sensor.blob', attr: 'zero' }), '0', '0 is a value, not an absence');
|
||||
assert.equal(liveTextValue(hassLive, { entity: 'sensor.blob', attr: 'no' }), 'false');
|
||||
assert.equal(liveTextValue(hassLive, { entity: 'climate.hall', attr: 'preset_modes' }), 'home, away');
|
||||
});
|
||||
|
||||
test('liveTextValue: a runaway value is clipped, so a caption stays a caption', () => {
|
||||
const hass = { states: { 'sensor.big': { state: 'x'.repeat(500), attributes: {} } } };
|
||||
const v = liveTextValue(hass, { entity: 'sensor.big' });
|
||||
assert.equal(v.length, LIVE_TEXT_VALUE_MAX);
|
||||
assert.ok(liveText('Log: {}', { entity: 'sensor.big' }, hass).length <= 'Log: '.length + LIVE_TEXT_VALUE_MAX);
|
||||
});
|
||||
|
||||
test('decorTextScale: legacy size renders exactly as it used to', () => {
|
||||
assert.equal(decorTextScale({ size: 's' }), 0.7, '14px against the base 20');
|
||||
assert.equal(decorTextScale({ size: 'm' }), 1);
|
||||
assert.equal(decorTextScale({ size: 'l' }), 1.5, '30px against the base 20');
|
||||
assert.equal(decorTextScale({}), 1, 'a shape with neither is the medium it has always been');
|
||||
assert.equal(decorTextScale(null), 1);
|
||||
});
|
||||
|
||||
test('decorTextScale: an explicit scale wins over the legacy size and is bounded', () => {
|
||||
assert.equal(decorTextScale({ size: 's', scale: 2 }), 2);
|
||||
assert.equal(decorTextScale({ scale: 0 }), 1, '0 is not a scale — fall back');
|
||||
assert.equal(decorTextScale({ scale: -3 }), 1);
|
||||
assert.equal(decorTextScale({ scale: 'x' }), 1);
|
||||
assert.equal(decorTextScale({ scale: 1e9 }), DECOR_TEXT_SCALE_MAX);
|
||||
assert.equal(decorTextScale({ scale: 1e-9 }), DECOR_TEXT_SCALE_MIN);
|
||||
});
|
||||
|
||||
test('decorTextLines: explicit newlines only, never an automatic wrap', () => {
|
||||
assert.deepEqual(decorTextLines('a\nb'), ['a', 'b']);
|
||||
assert.deepEqual(decorTextLines('a\r\nb'), ['a', 'b'], 'CRLF from a pasted text');
|
||||
assert.deepEqual(decorTextLines('one'), ['one']);
|
||||
assert.deepEqual(decorTextLines(''), ['']);
|
||||
assert.deepEqual(decorTextLines('a\n\nb'), ['a', '', 'b'], 'a blank line is a line');
|
||||
const long = 'x'.repeat(300);
|
||||
assert.deepEqual(decorTextLines(long), [long], '300 chars stay one line — no auto wrap');
|
||||
});
|
||||
|
||||
@@ -1135,3 +1135,73 @@ class TestVacuum:
|
||||
v.MARKER_SCHEMA(_marker(vacuum={"trail_mode": ok}))
|
||||
with pytest.raises(Exception):
|
||||
v.MARKER_SCHEMA(_marker(vacuum={"trail_mode": "sometimes"}))
|
||||
|
||||
|
||||
def test_decor_text_live_fields():
|
||||
"""docs/LIVE-TEXT.md: a text label may carry an entity, an attribute and a
|
||||
unit. All three optional and BOUNDED; junk is refused; a label without them
|
||||
stays valid, so no plan needs a migration."""
|
||||
base = {"id": "s1", "title": "S", "view_box": [0, 0, 1, 1], "rooms": []}
|
||||
txt = {"id": "d", "kind": "text", "x": 0.5, "y": 0.5, "text": "Бак {}"}
|
||||
|
||||
def cfg(extra):
|
||||
return v.CONFIG_SCHEMA({"spaces": [{**base, "decor": [{**txt, **extra}]}]})
|
||||
|
||||
# --- a plain label is untouched by the new fields ---------------------
|
||||
assert cfg({})["spaces"][0]["decor"][0] == txt
|
||||
# --- accepted ---------------------------------------------------------
|
||||
out = cfg({"entity": "sensor.water_tank", "attr": "battery_level", "unit": "%"})
|
||||
shape = out["spaces"][0]["decor"][0]
|
||||
assert shape["entity"] == "sensor.water_tank"
|
||||
assert shape["attr"] == "battery_level"
|
||||
assert shape["unit"] == "%"
|
||||
# None is how the dialog clears a field
|
||||
assert cfg({"entity": None, "attr": None, "unit": None})
|
||||
assert cfg({"entity": "binary_sensor.a_1", "attr": "x", "unit": "°C"})
|
||||
# --- refused ----------------------------------------------------------
|
||||
for bad in ("sensor", "sensor.", ".tank", "Sensor.Tank", "sensor.tank; drop",
|
||||
"sensor." + "x" * 300, 5, True, ["sensor.a"]):
|
||||
with pytest.raises(vol.Invalid):
|
||||
cfg({"entity": bad})
|
||||
with pytest.raises(vol.Invalid):
|
||||
cfg({"attr": "a" * (v.MAX_DECOR_ATTR + 1)})
|
||||
with pytest.raises(vol.Invalid):
|
||||
cfg({"unit": "u" * (v.MAX_DECOR_UNIT + 1)})
|
||||
with pytest.raises(vol.Invalid):
|
||||
cfg({"attr": 7})
|
||||
with pytest.raises(vol.Invalid):
|
||||
cfg({"unit": {"a": 1}})
|
||||
# the template itself keeps its own bound, placeholder or not
|
||||
assert cfg({"text": "x" * v.MAX_DECOR_TEXT})
|
||||
with pytest.raises(vol.Invalid):
|
||||
cfg({"text": "x" * (v.MAX_DECOR_TEXT + 1)})
|
||||
|
||||
|
||||
def test_decor_text_block_scale_and_angle():
|
||||
"""The dialog no longer offers a font size — the block is scaled by its
|
||||
corners and rotated by its handle. Both fields optional and bounded; the
|
||||
legacy `size` is still accepted (and still bounded) so a label drawn before
|
||||
the handles renders exactly as it did."""
|
||||
base = {"id": "s1", "title": "S", "view_box": [0, 0, 1, 1], "rooms": []}
|
||||
txt = {"id": "d", "kind": "text", "x": 0.5, "y": 0.5, "text": "Porch"}
|
||||
|
||||
def cfg(extra):
|
||||
return v.CONFIG_SCHEMA({"spaces": [{**base, "decor": [{**txt, **extra}]}]})
|
||||
|
||||
assert cfg({"scale": 1})["spaces"][0]["decor"][0]["scale"] == 1.0
|
||||
assert cfg({"scale": v.DECOR_TEXT_SCALE_MIN})
|
||||
assert cfg({"scale": v.DECOR_TEXT_SCALE_MAX})
|
||||
assert cfg({"angle": 0}) and cfg({"angle": -360}) and cfg({"angle": 360})
|
||||
assert cfg({"angle": 45.5})["spaces"][0]["decor"][0]["angle"] == 45.5
|
||||
for bad in (0, -1, v.DECOR_TEXT_SCALE_MAX + 1, float("nan"), float("inf")):
|
||||
with pytest.raises(vol.Invalid):
|
||||
cfg({"scale": bad})
|
||||
for bad in (361, -361, float("nan")):
|
||||
with pytest.raises(vol.Invalid):
|
||||
cfg({"angle": bad})
|
||||
# legacy: still valid, still only the three known values
|
||||
assert cfg({"size": "s"}) and cfg({"size": "m"}) and cfg({"size": "l"})
|
||||
with pytest.raises(vol.Invalid):
|
||||
cfg({"size": "xxl"})
|
||||
# a multi-line label round-trips with its newlines intact
|
||||
assert cfg({"text": "Гараж\nпод ключ"})["spaces"][0]["decor"][0]["text"] == "Гараж\nпод ключ"
|
||||
|
||||
Reference in New Issue
Block a user