diff --git a/Plugin/unraid/include/confui.php b/Plugin/unraid/include/confui.php index c0e7f37..8c2005c 100644 --- a/Plugin/unraid/include/confui.php +++ b/Plugin/unraid/include/confui.php @@ -131,17 +131,40 @@ function vv_conf_ui_assets(): void { `; } + // The gutter is seeded here and then kept in step by the input/scroll handlers in wire(). + // It cannot be produced once and left: the numbers follow the textarea's value, which is the + // thing the operator is about to change. function linesCtl(f) { const v = String(f.value == null ? '' : f.value); const n = v.split('\n').length; - const gutter = Array.from({ length: n }, (_, i) => i + 1).join('\n'); return `
-
${gutter}
-
`; } + function gutterFor(v) { + const n = String(v).split('\n').length; + let s = ''; + for (let i = 1; i <= n; i++) s += i + '\n'; + return s; + } + + // Kept together, because they are two halves of one illusion: the gutter is a separate element + // that has to be renumbered when the text changes and scrolled when the text scrolls, or the + // numbers drift away from the lines they belong to. + function syncGutter(ta) { + const g = ta.parentElement && ta.parentElement.querySelector('.vv-cf-lines'); + if (!g) return; + g.textContent = gutterFor(ta.value); + g.scrollTop = ta.scrollTop; + } + function scrollGutter(ta) { + const g = ta.parentElement && ta.parentElement.querySelector('.vv-cf-lines'); + if (g) g.scrollTop = ta.scrollTop; + } + function textCtl(f) { return `
${esc(f.desc)}
` : ''; - return `
+ return `
+ ${n}
${esc(f.key)}
@@ -167,8 +196,11 @@ function vv_conf_ui_assets(): void {
`; } - function groupHtml(g) { - const fields = (g.fields || []).map(fieldHtml).join(''); + // Takes the counter by reference so numbering continues across groups. Returning it would have + // worked too, but every caller would then be responsible for threading it correctly and one of + // them eventually would not. + function groupHtml(g, counter) { + const fields = (g.fields || []).map(f => fieldHtml(f, ++counter.n)).join(''); if (!fields) return ''; return `
${esc(g.subsection || g.name || '')} @@ -186,11 +218,32 @@ function vv_conf_ui_assets(): void { } window.VvConfUI = { + // The markup on its own, for a caller composing it into something larger — the Scheduler's + // advanced view wraps it in a Config block alongside the header and README sections. Whoever + // uses this owes the DOM a hydrate() afterwards. + html(groups) { + const counter = { n: 0 }; + return (groups || []).map(g => groupHtml(g, counter)).join(''); + }, + + // Everything that can only be done once the markup is in the document. Separate from html() + // because a caller that built a bigger string still needs this half, and separate from wire() + // because wire() is idempotent per container while this must run after every re-render. + hydrate(into) { + const box = typeof into === 'string' ? document.getElementById(into) : into; + if (!box) return; + // Seeded from the rendered DOM rather than at build time, so a textarea the browser sized + // differently than expected still gets a gutter matching what is on screen. + box.querySelectorAll('.vv-cf-array').forEach(syncGutter); + return box; + }, + render(into, groups) { const box = typeof into === 'string' ? document.getElementById(into) : into; if (!box) return; - const html = (groups || []).map(groupHtml).join(''); + const html = this.html(groups); box.innerHTML = html || '

Nothing configurable here.

'; + this.hydrate(box); return box; }, @@ -232,7 +285,16 @@ function vv_conf_ui_assets(): void { if (onChange) onChange(); }; - box.addEventListener('input', e => { if (e.target.dataset.ctl) refresh(e.target); }); + box.addEventListener('input', e => { + if (!e.target.dataset.ctl) return; + if (e.target.classList.contains('vv-cf-array')) syncGutter(e.target); + refresh(e.target); + }); + // Capture: scroll does not bubble, so a listener on the container never sees a textarea + // scrolling inside it any other way. + box.addEventListener('scroll', e => { + if (e.target.classList && e.target.classList.contains('vv-cf-array')) scrollGutter(e.target); + }, true); box.addEventListener('change', e => { const el = e.target; if (!el.dataset.ctl) return; diff --git a/Plugin/unraid/pages/scheduler.php b/Plugin/unraid/pages/scheduler.php index f9b6bf3..5afec36 100644 --- a/Plugin/unraid/pages/scheduler.php +++ b/Plugin/unraid/pages/scheduler.php @@ -68,6 +68,10 @@ require_once dirname(__DIR__) . '/include/ai_profiles.php'; // The chat component itself now, not only the store: the assistant panel in the right-hand pane // is an instance of it rather than a second implementation. require_once dirname(__DIR__) . '/include/ai_chat.php'; +// The shared settings renderer. This page used to draw conf fields itself, which meant every +// boolean was a text box and every credential was legible — 329 of its 704 fields deserved a +// better control than the one they got. +require_once dirname(__DIR__) . '/include/confui.php'; // Live values for the `$VAR` markers in pages/readme/*.md. Conf variables, plus the derived // path constants — those are not conf keys, but they are exactly what a reader needs resolved @@ -1083,6 +1087,7 @@ Still the same two servers, two households, the same media stack running itself. vv_ai_profiles_script(); vv_ai_chat_store_script(); vv_ai_chat_assets(); + vv_conf_ui_assets(); ?>