// The pictures of keys, for the settings pages. src/render.rs draws the real keys: keep the two alike // (cargo test compares them). import { COLORS, escapeXml, glyph, glyphHeight } from "./icons.js"; export const SIZE = 144; const FONT = `'Inter','Noto Sans','DejaVu Sans','Liberation Sans',sans-serif`; // Like the titles OpenDeck draws (font size 11 is about 22 px on a key), but bold to read it on // the deck. The same in the Feishin plugin. const TITLE_FONT = `'Liberation Sans',Arial,sans-serif`; // The glyph takes 24 of 40 units, like viewBox="-8 -8 40 40". const GLYPH_SIZE = (SIZE * 24) / 40; const GLYPH_OFFSET = (SIZE - GLYPH_SIZE) / 2; // Space between a glyph (or picture) and its title. const TITLE_GAP = 16; export function isColor(value) { return /^#[0-9a-f]{6}$/i.test(String(value ?? "")); } function svg(content) { return `${content}`; } function flat(color) { return color ? `` : ""; } function text(value, { x = SIZE / 2, y, size, color = COLORS.white, weight = 700, anchor = "middle", font = FONT }) { return `${escapeXml(value)}`; } // Font size that keeps a line inside the key. Capital letters are wider. export function fitSize(value, max, width = 124) { const text = String(value ?? ""); const length = Math.max(1, text.length); const perChar = text === text.toUpperCase() && /\p{L}/u.test(text) ? 0.68 : 0.58; return Math.max(12, Math.min(max, Math.floor(width / (length * perChar)))); } // Audio level from dB to 0..1, like the meters in OBS (-60 dB to 0 dB). export function meterLevel(db) { if (!Number.isFinite(db)) return 0; return Math.max(0, Math.min(1, (db + 60) / 60)); } // Vertical level meter at the right edge: green, then yellow from -9 dB, red from -3 dB. function meterSvg(level) { const x = 125; const y = 18; const w = 9; const h = 108; const fill = Math.round(h * level); const stop = (offset, color) => ``; let out = `${stop(0, COLORS.green)}${stop(0.85, COLORS.green)}${stop(0.85, COLORS.amber)}${stop(0.95, COLORS.amber)}${stop(0.95, COLORS.red)}${stop(1, COLORS.red)}`; out += ``; out += ``; if (fill > 0) out += ``; return out; } // A title the key draws itself, in the font of an OpenDeck title: 22 px like OpenDeck draws them, // smaller for long text, and cut with "…" when even the smallest size is too wide. // ink: how tall digits and capitals are, for placing the line. export function titleLine(value, width = 128) { let line = String(value ?? "").trim(); const perChar = 0.56; const size = Math.max(15, Math.min(22, Math.floor(width / (Math.max(1, line.length) * perChar)))); const fits = Math.floor(width / (size * perChar)); if (line.length > fits) line = `${line.slice(0, Math.max(1, fits - 1)).trimEnd()}…`; return { line, size, ink: Math.round(size * 0.72) }; } function titleText(line, { x = SIZE / 2, y, stroke = false }) { const outline = stroke ? ` stroke="#000" stroke-width="4" paint-order="stroke"` : ""; return `${escapeXml(line.line)}`; } // Something of the given height with a title above or below it, together in the middle of the key. // top: where it starts. baseline: where the title stands. function withTitleLayout(height, line, above) { const top = (SIZE - height - TITLE_GAP - line.ink) / 2; return above ? { top: top + line.ink + TITLE_GAP, baseline: top + line.ink } : { top, baseline: SIZE - top }; } // A centered glyph. // meter: 0..1 adds a level meter on the right (the glyph moves a bit to the left). // setup: a yellow dot in the corner, for a key that still needs its settings. // title: a line of text "above" or "below" the glyph (titleAt), close to it, and glyph and title // together in the middle of the key. OpenDeck cannot move its own title, so keys draw it here. export function glyphKeySvg(name, { color = COLORS.white, meter = null, setup = false, title = "", titleAt = "below" } = {}) { return svg(glyphContent(name, { color, meter, title, titleAt }) + (setup ? `` : "")); } function glyphContent(name, { color = COLORS.white, meter = null, title = "", titleAt = "below" } = {}) { const shift = meter === null ? 0 : 9; let y = GLYPH_OFFSET; let label = ""; if (title) { const line = titleLine(title, meter === null ? 128 : 110); const height = (glyphHeight(name) * GLYPH_SIZE) / 24; const { top, baseline } = withTitleLayout(height, line, titleAt === "above"); y = GLYPH_OFFSET + top - (SIZE - height) / 2; label = titleText(line, { x: SIZE / 2 - shift, y: baseline }); } let content = glyph(name, { x: GLYPH_OFFSET - shift, y, size: GLYPH_SIZE, color }) + label; if (meter !== null) content += meterSvg(meter); return content; } // Big value with a small label above and an optional line or bar below. // alert: a flat color behind everything (used for flashing). // dot: a small colored dot before the label (used as a status light). export function statKeySvg({ label = "", value = "", sub = "", bar = null, color = COLORS.blue, alert = null, dot = null }) { const dim = alert ? COLORS.white : COLORS.muted; let content = flat(alert); // Longer labels (other languages) get a smaller font. const size = Math.min(18, fitSize(label, 18, dot ? 108 : 128)); if (dot) { const width = String(label).length * size * 0.59; content += ``; content += text(label, { x: SIZE / 2 + 8, y: 42, size, color: dim }); } else content += text(label, { y: 42, size, color: dim }); content += text(value, { y: 90, size: fitSize(value, 46), color: COLORS.white }); if (sub) content += text(sub, { y: 120, size: fitSize(sub, 19), weight: 600, color: dim }); if (bar !== null) { const pct = Math.max(0, Math.min(1, bar)); const y = sub ? 126 : 110; content += ``; if (pct > 0) content += ``; } return svg(content); } // Scene preview with a colored frame for live (program) or preview. // fit: "fit" shows the whole scene as a picture, "fill" crops the scene to fill the whole key. // title: the name, under the glyph or the picture (both together in the middle), or over a // picture that fills the key. export function sceneKeySvg({ image = null, border = null, glyphName = "scene", fit = "fit", title = "" }) { let content = ""; if (!image) { content = glyphContent(glyphName, { title }); } else { const href = image.replace(/^data:image\/jpg;/, "data:image/jpeg;"); const line = title ? titleLine(title) : null; if (fit === "fill") { content = ``; if (line) content += titleText(line, { y: SIZE - 16, stroke: true }); } else { // 120 x 76 fits 16:9 and 16:10 canvases. Top aligned in its box. const { top, baseline } = line ? withTitleLayout(76, line, false) : { top: (SIZE - 76) / 2 }; content = ``; content += ``; if (line) content += titleText(line, { y: baseline }); } } if (border) content += ``; return svg(content); }