multimodalart HF Staff commited on
Commit
f6dee5b
·
verified ·
1 Parent(s): bf586cf

Replace native sliders with custom vertical sliders (side-by-side per group)

Browse files
Files changed (1) hide show
  1. app.py +255 -16
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, vertically-stacked grouped sections. Each feature/parameter group
360
- (Head Shape, Left Eye, Mouth, Neck, Gaze, Translation, ...) is rendered as
361
- its own bordered section with a header row (group label + per-group Reset)
362
- and its sliders stacked vertically underneath. */
 
363
  .gnm-group {
364
  border: 1px solid var(--border-color-primary);
365
  border-radius: 8px;
366
- padding: 8px 10px 4px 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
- /* Tighten vertical spacing between stacked sliders inside a group. */
383
- .gnm-group .gnm-sliders .gr-slider,
384
- .gnm-group .gnm-sliders > div {
385
- margin-bottom: 2px;
 
 
 
 
 
 
 
 
 
 
 
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
- def _slider(label, minimum, maximum, value=0.0, step=None):
406
- kw = dict(label=label, minimum=minimum, maximum=maximum, value=value)
407
- if step is not None:
408
- kw["step"] = step
409
- return gr.Slider(**kw)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 stacked vertically underneath. `build_sliders` is a zero-arg
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.Column(elem_classes="gnm-sliders"):
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