Spaces:
Running
Running
Replace native sliders with custom vertical sliders (side-by-side per group)
Browse files
app.py
CHANGED
|
@@ -356,14 +356,15 @@ CSS = """
|
|
| 356 |
#col-container { max-width: 1200px; margin: 0 auto; }
|
| 357 |
.dark .gradio-container { color: var(--body-text-color); }
|
| 358 |
|
| 359 |
-
/* Compact
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
|
|
|
| 363 |
.gnm-group {
|
| 364 |
border: 1px solid var(--border-color-primary);
|
| 365 |
border-radius: 8px;
|
| 366 |
-
padding: 8px 10px
|
| 367 |
margin-bottom: 10px;
|
| 368 |
background: var(--block-background-fill);
|
| 369 |
}
|
|
@@ -379,10 +380,21 @@ CSS = """
|
|
| 379 |
font-size: 0.95rem;
|
| 380 |
margin: 0;
|
| 381 |
}
|
| 382 |
-
/*
|
| 383 |
-
.gnm-
|
| 384 |
-
|
| 385 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
}
|
| 387 |
/* Small, unobtrusive per-group reset button. */
|
| 388 |
.gnm-reset-btn {
|
|
@@ -393,6 +405,75 @@ CSS = """
|
|
| 393 |
font-size: 0.8rem !important;
|
| 394 |
line-height: 1.4 !important;
|
| 395 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 396 |
"""
|
| 397 |
|
| 398 |
INTRO = """
|
|
@@ -402,11 +483,169 @@ INTRO = """
|
|
| 402 |
"""
|
| 403 |
|
| 404 |
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 410 |
|
| 411 |
|
| 412 |
# Collects (reset_button, [sliders]) pairs so each group's reset is wired to
|
|
@@ -418,7 +657,7 @@ def _group_section(title, build_sliders):
|
|
| 418 |
"""Render one feature/parameter group as a compact, bordered section.
|
| 419 |
|
| 420 |
The section has a header (group label + a per-group ↺ Reset button) and its
|
| 421 |
-
sliders
|
| 422 |
callable that creates and returns the group's list of sliders (in order).
|
| 423 |
The per-group reset button is registered in GROUP_RESETS and wired later to
|
| 424 |
reset only this group's sliders back to their defaults.
|
|
@@ -430,7 +669,7 @@ def _group_section(title, build_sliders):
|
|
| 430 |
"↺ Reset", variant="secondary", size="sm",
|
| 431 |
elem_classes="gnm-reset-btn", scale=0, min_width=0,
|
| 432 |
)
|
| 433 |
-
with gr.
|
| 434 |
group_sliders = build_sliders()
|
| 435 |
GROUP_RESETS.append((reset, group_sliders))
|
| 436 |
return group_sliders
|
|
|
|
| 356 |
#col-container { max-width: 1200px; margin: 0 auto; }
|
| 357 |
.dark .gradio-container { color: var(--body-text-color); }
|
| 358 |
|
| 359 |
+
/* Compact grouped sections. Each feature/parameter group (Head Shape, Left
|
| 360 |
+
Eye, Mouth, Neck, Gaze, Translation, ...) is rendered as its own bordered
|
| 361 |
+
section with a header row (group label + per-group Reset) and its sliders
|
| 362 |
+
laid out side-by-side underneath (each slider is now a custom VERTICAL
|
| 363 |
+
slider, so several fit in one row instead of being stacked). */
|
| 364 |
.gnm-group {
|
| 365 |
border: 1px solid var(--border-color-primary);
|
| 366 |
border-radius: 8px;
|
| 367 |
+
padding: 8px 10px 8px 10px;
|
| 368 |
margin-bottom: 10px;
|
| 369 |
background: var(--block-background-fill);
|
| 370 |
}
|
|
|
|
| 380 |
font-size: 0.95rem;
|
| 381 |
margin: 0;
|
| 382 |
}
|
| 383 |
+
/* Sliders row: wrap the vertical sliders side-by-side within the group. */
|
| 384 |
+
.gnm-sliders {
|
| 385 |
+
flex-wrap: wrap !important;
|
| 386 |
+
gap: 4px !important;
|
| 387 |
+
align-items: flex-start !important;
|
| 388 |
+
}
|
| 389 |
+
/* Each vertical slider is a narrow HTML block; let it size to its content
|
| 390 |
+
rather than expanding to fill the row. */
|
| 391 |
+
.gnm-vslider {
|
| 392 |
+
flex: 0 0 auto !important;
|
| 393 |
+
min-width: 0 !important;
|
| 394 |
+
width: auto !important;
|
| 395 |
+
border: none !important;
|
| 396 |
+
padding: 0 !important;
|
| 397 |
+
background: transparent !important;
|
| 398 |
}
|
| 399 |
/* Small, unobtrusive per-group reset button. */
|
| 400 |
.gnm-reset-btn {
|
|
|
|
| 405 |
font-size: 0.8rem !important;
|
| 406 |
line-height: 1.4 !important;
|
| 407 |
}
|
| 408 |
+
|
| 409 |
+
/* --- Custom vertical slider look (matches native Gradio slider styling) --- */
|
| 410 |
+
.vslider-root {
|
| 411 |
+
display: flex;
|
| 412 |
+
flex-direction: column;
|
| 413 |
+
align-items: center;
|
| 414 |
+
width: 62px;
|
| 415 |
+
padding: 4px 2px 2px 2px;
|
| 416 |
+
user-select: none;
|
| 417 |
+
-webkit-user-select: none;
|
| 418 |
+
box-sizing: border-box;
|
| 419 |
+
}
|
| 420 |
+
.vslider-label {
|
| 421 |
+
font-size: 0.72rem;
|
| 422 |
+
line-height: 1.05;
|
| 423 |
+
text-align: center;
|
| 424 |
+
color: var(--body-text-color);
|
| 425 |
+
min-height: 2.1em;
|
| 426 |
+
display: flex;
|
| 427 |
+
align-items: center;
|
| 428 |
+
justify-content: center;
|
| 429 |
+
margin-bottom: 4px;
|
| 430 |
+
overflow-wrap: anywhere;
|
| 431 |
+
}
|
| 432 |
+
.vslider-value {
|
| 433 |
+
font-size: 0.72rem;
|
| 434 |
+
font-variant-numeric: tabular-nums;
|
| 435 |
+
color: var(--body-text-color-subdued);
|
| 436 |
+
margin-bottom: 4px;
|
| 437 |
+
}
|
| 438 |
+
.vslider-track {
|
| 439 |
+
position: relative;
|
| 440 |
+
width: 6px;
|
| 441 |
+
height: 150px;
|
| 442 |
+
border-radius: 3px;
|
| 443 |
+
background: var(--slider-color, var(--neutral-200, #d1d5db));
|
| 444 |
+
cursor: pointer;
|
| 445 |
+
touch-action: none;
|
| 446 |
+
}
|
| 447 |
+
.dark .vslider-track {
|
| 448 |
+
background: var(--neutral-600, #4b5563);
|
| 449 |
+
}
|
| 450 |
+
/* Filled portion (from bottom up to the handle). */
|
| 451 |
+
.vslider-fill {
|
| 452 |
+
position: absolute;
|
| 453 |
+
left: 0;
|
| 454 |
+
bottom: 0;
|
| 455 |
+
width: 100%;
|
| 456 |
+
border-radius: 3px;
|
| 457 |
+
background: var(--slider-color, var(--color-accent, #ff7c00));
|
| 458 |
+
}
|
| 459 |
+
.vslider-handle {
|
| 460 |
+
position: absolute;
|
| 461 |
+
left: 50%;
|
| 462 |
+
width: 18px;
|
| 463 |
+
height: 18px;
|
| 464 |
+
border-radius: 50%;
|
| 465 |
+
background: var(--color-accent, #ff7c00);
|
| 466 |
+
border: 2px solid var(--background-fill-primary, #fff);
|
| 467 |
+
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
| 468 |
+
transform: translate(-50%, 50%);
|
| 469 |
+
cursor: grab;
|
| 470 |
+
}
|
| 471 |
+
.vslider-handle:active { cursor: grabbing; }
|
| 472 |
+
.vslider-minmax {
|
| 473 |
+
font-size: 0.62rem;
|
| 474 |
+
color: var(--body-text-color-subdued);
|
| 475 |
+
margin-top: 3px;
|
| 476 |
+
}
|
| 477 |
"""
|
| 478 |
|
| 479 |
INTRO = """
|
|
|
|
| 483 |
"""
|
| 484 |
|
| 485 |
|
| 486 |
+
# ---------------------------------------------------------------------------
|
| 487 |
+
# Custom VERTICAL slider component.
|
| 488 |
+
#
|
| 489 |
+
# gr.Slider only renders horizontally, so we build a drop-in vertical slider as
|
| 490 |
+
# an interactive custom HTML component (gr.HTML with html_template + js_on_load).
|
| 491 |
+
# It behaves like a native slider — it has a label, honours min/max/step, has a
|
| 492 |
+
# draggable handle and a live numeric value readout — but it is oriented
|
| 493 |
+
# vertically so several sliders sit side-by-side within a group. Its component
|
| 494 |
+
# value is the plain numeric slider value, so it plugs straight into the same
|
| 495 |
+
# `.change()` / reset / Examples wiring the native sliders used.
|
| 496 |
+
#
|
| 497 |
+
# html_template is a *static* markup skeleton (it does NOT reference ${value},
|
| 498 |
+
# so Gradio keeps the same DOM element across value updates); js_on_load wires
|
| 499 |
+
# the drag interaction (writes `props.value` -> fires `.change()`) and a
|
| 500 |
+
# `watch('value')` callback that repaints the handle/readout when the value is
|
| 501 |
+
# set externally (reset buttons, Examples, initial load).
|
| 502 |
+
# ---------------------------------------------------------------------------
|
| 503 |
+
VSLIDER_TEMPLATE = (
|
| 504 |
+
'<div class="vslider-root">'
|
| 505 |
+
'<div class="vslider-label">__LABEL__</div>'
|
| 506 |
+
'<div class="vslider-value">0</div>'
|
| 507 |
+
'<div class="vslider-track">'
|
| 508 |
+
'<div class="vslider-fill"></div>'
|
| 509 |
+
'<div class="vslider-handle"></div>'
|
| 510 |
+
"</div>"
|
| 511 |
+
'<div class="vslider-minmax">__MIN__ … __MAX__</div>'
|
| 512 |
+
"</div>"
|
| 513 |
+
)
|
| 514 |
+
|
| 515 |
+
VSLIDER_JS_ON_LOAD = """
|
| 516 |
+
(function () {
|
| 517 |
+
const MIN = __MIN__, MAX = __MAX__, STEP = __STEP__;
|
| 518 |
+
const root = element.querySelector(".vslider-root");
|
| 519 |
+
const track = element.querySelector(".vslider-track");
|
| 520 |
+
const fill = element.querySelector(".vslider-fill");
|
| 521 |
+
const handle = element.querySelector(".vslider-handle");
|
| 522 |
+
const valEl = element.querySelector(".vslider-value");
|
| 523 |
+
if (!root || !track || !handle) return;
|
| 524 |
+
|
| 525 |
+
function decimals() {
|
| 526 |
+
const s = String(STEP);
|
| 527 |
+
const i = s.indexOf(".");
|
| 528 |
+
return i < 0 ? 0 : (s.length - i - 1);
|
| 529 |
+
}
|
| 530 |
+
const DEC = decimals();
|
| 531 |
+
|
| 532 |
+
function clampSnap(v) {
|
| 533 |
+
if (STEP > 0) v = Math.round((v - MIN) / STEP) * STEP + MIN;
|
| 534 |
+
v = Math.min(MAX, Math.max(MIN, v));
|
| 535 |
+
// Kill floating-point fuzz from the snap.
|
| 536 |
+
return parseFloat(v.toFixed(Math.max(DEC, 6)));
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
function fmt(v) { return Number(v).toFixed(DEC); }
|
| 540 |
+
|
| 541 |
+
// Paint the handle / fill / readout for a value WITHOUT firing change.
|
| 542 |
+
function paint(v) {
|
| 543 |
+
v = Math.min(MAX, Math.max(MIN, Number(v)));
|
| 544 |
+
const frac = (MAX > MIN) ? (v - MIN) / (MAX - MIN) : 0; // 0=bottom,1=top
|
| 545 |
+
const h = track.clientHeight;
|
| 546 |
+
handle.style.bottom = (frac * h) + "px";
|
| 547 |
+
fill.style.height = (frac * h) + "px";
|
| 548 |
+
if (valEl) valEl.textContent = fmt(clampSnap(v));
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
function currentValue() {
|
| 552 |
+
let v = props.value;
|
| 553 |
+
if (v === null || v === undefined || v === "") return MIN < 0 && MAX > 0 ? 0 : MIN;
|
| 554 |
+
v = parseFloat(v);
|
| 555 |
+
if (isNaN(v)) return MIN < 0 && MAX > 0 ? 0 : MIN;
|
| 556 |
+
return v;
|
| 557 |
+
}
|
| 558 |
+
|
| 559 |
+
// Map a pointer Y position to a value (top of track = MAX, bottom = MIN).
|
| 560 |
+
function valueFromEvent(clientY) {
|
| 561 |
+
const rect = track.getBoundingClientRect();
|
| 562 |
+
let frac = (rect.bottom - clientY) / rect.height;
|
| 563 |
+
frac = Math.min(1, Math.max(0, frac));
|
| 564 |
+
return clampSnap(MIN + frac * (MAX - MIN));
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
+
let dragging = false;
|
| 568 |
+
|
| 569 |
+
function setFromPointer(clientY) {
|
| 570 |
+
const nv = valueFromEvent(clientY);
|
| 571 |
+
paint(nv);
|
| 572 |
+
if (nv !== parseFloat(props.value)) {
|
| 573 |
+
props.value = nv; // fires .change() -> recompute mesh
|
| 574 |
+
}
|
| 575 |
+
}
|
| 576 |
+
|
| 577 |
+
track.addEventListener("pointerdown", (e) => {
|
| 578 |
+
dragging = true;
|
| 579 |
+
try { track.setPointerCapture(e.pointerId); } catch (err) {}
|
| 580 |
+
setFromPointer(e.clientY);
|
| 581 |
+
e.preventDefault();
|
| 582 |
+
});
|
| 583 |
+
track.addEventListener("pointermove", (e) => {
|
| 584 |
+
if (!dragging) return;
|
| 585 |
+
setFromPointer(e.clientY);
|
| 586 |
+
e.preventDefault();
|
| 587 |
+
});
|
| 588 |
+
function endDrag(e) {
|
| 589 |
+
if (!dragging) return;
|
| 590 |
+
dragging = false;
|
| 591 |
+
try { track.releasePointerCapture(e.pointerId); } catch (err) {}
|
| 592 |
+
}
|
| 593 |
+
track.addEventListener("pointerup", endDrag);
|
| 594 |
+
track.addEventListener("pointercancel", endDrag);
|
| 595 |
+
|
| 596 |
+
// Repaint when the value is set from Python (reset / Examples / load).
|
| 597 |
+
watch("value", () => paint(currentValue()));
|
| 598 |
+
|
| 599 |
+
// Initial paint (handle geometry needs the track laid out first).
|
| 600 |
+
(function ready() {
|
| 601 |
+
if (!track.clientHeight) { setTimeout(ready, 30); return; }
|
| 602 |
+
paint(currentValue());
|
| 603 |
+
})();
|
| 604 |
+
})();
|
| 605 |
+
"""
|
| 606 |
+
|
| 607 |
+
|
| 608 |
+
def _js_num(x):
|
| 609 |
+
"""Format a Python number as a JS numeric literal."""
|
| 610 |
+
return repr(float(x))
|
| 611 |
+
|
| 612 |
+
|
| 613 |
+
def _vslider(label, minimum, maximum, value=0.0, step=None):
|
| 614 |
+
"""Create a custom vertical slider (drop-in replacement for gr.Slider).
|
| 615 |
+
|
| 616 |
+
Returns a gr.HTML component whose value is the numeric slider value. The
|
| 617 |
+
per-instance label / min / max / step are baked into its template and JS.
|
| 618 |
+
"""
|
| 619 |
+
step_val = step if step is not None else 0.0
|
| 620 |
+
tmpl = (
|
| 621 |
+
VSLIDER_TEMPLATE
|
| 622 |
+
.replace("__LABEL__", str(label))
|
| 623 |
+
.replace("__MIN__", _fmt_bound(minimum))
|
| 624 |
+
.replace("__MAX__", _fmt_bound(maximum))
|
| 625 |
+
)
|
| 626 |
+
js = (
|
| 627 |
+
VSLIDER_JS_ON_LOAD
|
| 628 |
+
.replace("__MIN__", _js_num(minimum))
|
| 629 |
+
.replace("__MAX__", _js_num(maximum))
|
| 630 |
+
.replace("__STEP__", _js_num(step_val))
|
| 631 |
+
)
|
| 632 |
+
return gr.HTML(
|
| 633 |
+
value=float(value),
|
| 634 |
+
html_template=tmpl,
|
| 635 |
+
js_on_load=js,
|
| 636 |
+
elem_classes="gnm-vslider",
|
| 637 |
+
container=False,
|
| 638 |
+
)
|
| 639 |
+
|
| 640 |
+
|
| 641 |
+
def _fmt_bound(x):
|
| 642 |
+
"""Pretty-print a min/max bound for the template (drop trailing .0)."""
|
| 643 |
+
xf = float(x)
|
| 644 |
+
return str(int(xf)) if xf == int(xf) else str(xf)
|
| 645 |
+
|
| 646 |
+
|
| 647 |
+
# Backwards-compatible alias: everything downstream builds sliders via _slider.
|
| 648 |
+
_slider = _vslider
|
| 649 |
|
| 650 |
|
| 651 |
# Collects (reset_button, [sliders]) pairs so each group's reset is wired to
|
|
|
|
| 657 |
"""Render one feature/parameter group as a compact, bordered section.
|
| 658 |
|
| 659 |
The section has a header (group label + a per-group ↺ Reset button) and its
|
| 660 |
+
(vertical) sliders laid out side-by-side underneath. `build_sliders` is a zero-arg
|
| 661 |
callable that creates and returns the group's list of sliders (in order).
|
| 662 |
The per-group reset button is registered in GROUP_RESETS and wired later to
|
| 663 |
reset only this group's sliders back to their defaults.
|
|
|
|
| 669 |
"↺ Reset", variant="secondary", size="sm",
|
| 670 |
elem_classes="gnm-reset-btn", scale=0, min_width=0,
|
| 671 |
)
|
| 672 |
+
with gr.Row(elem_classes="gnm-sliders"):
|
| 673 |
group_sliders = build_sliders()
|
| 674 |
GROUP_RESETS.append((reset, group_sliders))
|
| 675 |
return group_sliders
|