streamlit-segment-slider 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,760 @@
1
+ """A multi-handle range slider for Streamlit.
2
+
3
+ N draggable dividing points along [min_value, max_value] produce N+1 connected segments --
4
+ 2 dividing values -> 3 segments (e.g. a Cash/Growth/Income split), 3 dividing values -> 4
5
+ segments, and so on for any N. Pairs with either a pie chart + legend (the default) or, for a
6
+ more compact embed, per-segment name + percentage labels positioned directly on the track itself
7
+ with the chart hidden -- see segment_slider()'s own docstring for both display options.
8
+
9
+ from streamlit_segment_slider import segment_slider
10
+
11
+ cuts = segment_slider(
12
+ "Roughly how is it split?",
13
+ min_value=0, max_value=100, values=[25, 75],
14
+ segment_labels=["Cash", "Growth", "Income"],
15
+ key="my_split",
16
+ )
17
+
18
+ See example.py (in this project's root, not this package) for a runnable demo of both display
19
+ modes.
20
+ """
21
+
22
+ import streamlit as st
23
+
24
+ __all__ = ["segment_slider"]
25
+
26
+ HTML = """
27
+ <link
28
+ rel="stylesheet"
29
+ href="https://cdn.jsdelivr.net/npm/nouislider@15.8.1/dist/nouislider.min.css"
30
+ />
31
+ <div class="segment-widget">
32
+ <div class="slider-section">
33
+ <div class="slider-label"></div>
34
+ <div class="slider-wrap">
35
+ <div class="multi-slider"></div>
36
+ <div class="segment-labels"></div>
37
+ <div class="range-labels">
38
+ <span class="min-label"></span>
39
+ <span class="max-label"></span>
40
+ </div>
41
+ </div>
42
+ </div>
43
+ <div class="chart-section">
44
+ <div class="pie"></div>
45
+ <div class="legend"></div>
46
+ </div>
47
+ </div>
48
+ """
49
+
50
+ CSS = """
51
+ .segment-widget {
52
+ width: 100%;
53
+ font-family: var(--st-font);
54
+ color: var(--st-text-color);
55
+ }
56
+
57
+
58
+ /* ---------------------------------------------------------
59
+ SLIDER
60
+ --------------------------------------------------------- */
61
+
62
+ .slider-section {
63
+ width: 100%;
64
+ }
65
+
66
+ .slider-label {
67
+ font-size: 0.875rem;
68
+ font-weight: 400;
69
+ margin-bottom: 0.4rem;
70
+ }
71
+
72
+ /* Top padding leaves room for the handle tooltip, which is positioned above the track via
73
+ .noUi-tooltip's own `bottom: 160%` -- 1.8rem used to be a little short: measured live, the
74
+ tooltip's own top edge sat ~7.4px above this component's host element's top edge, and since
75
+ the host clips rather than scrolls past its own boundary (same root cause as the legend
76
+ overflow fixed elsewhere in this file), that ~7.4px of the tooltip was silently cut off on
77
+ every slider, in every mode, not just the compact one. 2.4rem (~38.4px) covers the measured
78
+ gap plus a safety margin. */
79
+ .slider-wrap {
80
+ padding: 2.4rem 0.5rem 0;
81
+ }
82
+
83
+
84
+ /* Slider track */
85
+ .multi-slider.noUi-target {
86
+ height: 6px;
87
+ border: none;
88
+ box-shadow: none;
89
+ background: var(--st-secondary-background-color);
90
+ border-radius: 999px;
91
+ }
92
+
93
+
94
+ /* Connections between handles */
95
+ .multi-slider .noUi-connect {
96
+ box-shadow: none;
97
+ }
98
+
99
+
100
+ /* Slider handles */
101
+ .multi-slider .noUi-handle {
102
+ width: 16px;
103
+ height: 16px;
104
+ right: -8px;
105
+ top: -5px;
106
+ border-radius: 50%;
107
+ background: var(--st-background-color);
108
+ border: 2px solid var(--st-primary-color);
109
+ box-shadow: none;
110
+ cursor: grab;
111
+ }
112
+
113
+ .multi-slider .noUi-handle:active {
114
+ cursor: grabbing;
115
+ }
116
+
117
+
118
+ /* Remove noUiSlider's default handle lines */
119
+ .multi-slider .noUi-handle::before,
120
+ .multi-slider .noUi-handle::after {
121
+ display: none;
122
+ }
123
+
124
+
125
+ /* Streamlit-like focus */
126
+ .multi-slider .noUi-handle:focus {
127
+ outline: none;
128
+ box-shadow:
129
+ 0 0 0 3px color-mix(
130
+ in srgb,
131
+ var(--st-primary-color) 25%,
132
+ transparent
133
+ );
134
+ }
135
+
136
+
137
+ /* Tooltip */
138
+ .multi-slider .noUi-tooltip {
139
+ font-family: var(--st-font);
140
+ font-size: 0.75rem;
141
+ color: var(--st-text-color);
142
+ background: var(--st-background-color);
143
+ border: 1px solid var(--st-border-color);
144
+ border-radius: var(--st-base-radius);
145
+ padding: 0.15rem 0.4rem;
146
+ box-shadow: none;
147
+ bottom: 160%;
148
+ }
149
+
150
+
151
+ /* Min/max labels */
152
+ .range-labels {
153
+ display: flex;
154
+ justify-content: space-between;
155
+ margin-top: 0.55rem;
156
+ color: var(--st-gray-text-color);
157
+ font-size: 0.75rem;
158
+ }
159
+
160
+
161
+ /* Per-segment name + percentage labels -- an alternative to .range-labels above (JS toggles
162
+ which one is visible; only one of the two is ever shown at once), each positioned/sized via
163
+ inline left/width set in JS to match that segment's own span of the track exactly. Hidden
164
+ (empty textContent) by JS for any segment too narrow to hold readable text -- see the JS's
165
+ own comment on those thresholds. */
166
+ .segment-labels {
167
+ position: relative;
168
+ height: 1rem;
169
+ margin-top: 0.5rem;
170
+ font-size: 0.75rem;
171
+ font-weight: 600;
172
+ font-variant-numeric: tabular-nums;
173
+ }
174
+
175
+ .segment-labels .segment-label {
176
+ position: absolute;
177
+ top: 0;
178
+ text-align: center;
179
+ white-space: nowrap;
180
+ overflow: hidden;
181
+ }
182
+
183
+
184
+ /* ---------------------------------------------------------
185
+ PIE CHART
186
+ --------------------------------------------------------- */
187
+ .chart-section {
188
+ margin-top: 2rem;
189
+ display: grid;
190
+ grid-template-columns:
191
+ minmax(150px, 210px)
192
+ minmax(180px, 1fr);
193
+ align-items: center;
194
+ gap: 2rem;
195
+ }
196
+
197
+
198
+ /* CSS pie chart */
199
+ .pie {
200
+ width: min(100%, 200px);
201
+ aspect-ratio: 1 / 1;
202
+ border-radius: 50%;
203
+ justify-self: center;
204
+ transition: background 60ms linear;
205
+ }
206
+
207
+ /* ---------------------------------------------------------
208
+ LEGEND
209
+ --------------------------------------------------------- */
210
+ /* max-height + overflow-y (direct fix) -- with enough segments, the legend's natural height
211
+ used to exceed the component's own fixed `height=`, and since the host clips at that boundary
212
+ rather than growing or scrolling, the overflow wasn't just unreadable, it was invisible: rows
213
+ past whatever fit were silently cut off, and the pie above it (vertically centered against the
214
+ now-oversized chart-section row) ended up only half-visible too, clipped by that same boundary.
215
+ 200px matches .pie's own max dimension below, so the two columns stay visually aligned up to
216
+ that point; past it, the legend scrolls internally instead of pushing the whole section taller
217
+ than the host will show. */
218
+ .legend {
219
+ display: flex;
220
+ flex-direction: column;
221
+ gap: 0.65rem;
222
+ max-height: 200px;
223
+ overflow-y: auto;
224
+ padding-right: 0.4rem;
225
+ scrollbar-width: thin;
226
+ }
227
+
228
+ .legend-row {
229
+ display: grid;
230
+ grid-template-columns: 12px 1fr auto;
231
+ align-items: center;
232
+ gap: 0.65rem;
233
+ font-size: 0.875rem;
234
+ }
235
+
236
+ .legend-color {
237
+ width: 12px;
238
+ height: 12px;
239
+ border-radius: 3px;
240
+ }
241
+
242
+ .legend-name {
243
+ color: var(--st-text-color);
244
+ }
245
+
246
+ .legend-value {
247
+ color: var(--st-gray-text-color);
248
+ font-variant-numeric: tabular-nums;
249
+ }
250
+
251
+
252
+ /* ---------------------------------------------------------
253
+ RESPONSIVE
254
+ --------------------------------------------------------- */
255
+ @media (max-width: 600px) {
256
+ .chart-section {
257
+ grid-template-columns: 1fr;
258
+ }
259
+ .pie {
260
+ width: 180px;
261
+ }
262
+ }
263
+ """
264
+
265
+
266
+ JS = """
267
+ import noUiSlider from
268
+ "https://cdn.jsdelivr.net/npm/nouislider@15.8.1/dist/nouislider.min.mjs";
269
+
270
+ export default function(component) {
271
+ const {
272
+ parentElement,
273
+ data,
274
+ setStateValue
275
+ } = component;
276
+
277
+
278
+ const slider =
279
+ parentElement.querySelector(".multi-slider");
280
+ const label =
281
+ parentElement.querySelector(".slider-label");
282
+ const segmentLabels =
283
+ parentElement.querySelector(".segment-labels");
284
+ const minLabel =
285
+ parentElement.querySelector(".min-label");
286
+ const maxLabel =
287
+ parentElement.querySelector(".max-label");
288
+ const chartSection =
289
+ parentElement.querySelector(".chart-section");
290
+ const pie =
291
+ parentElement.querySelector(".pie");
292
+ const legend =
293
+ parentElement.querySelector(".legend");
294
+
295
+ /* -----------------------------------------------------
296
+ DISPLAY OPTIONS -- two independent toggles (see
297
+ segment_slider()'s own docstring). rangeLabels (the
298
+ plain min/max row) always shows regardless of
299
+ show_segment_labels now -- direct feedback that
300
+ hiding the actual range endpoints (e.g. 0/100) once
301
+ segment_labels took over that row was a real loss of
302
+ information, not a redundant duplicate of it; the two
303
+ rows now stack instead of replacing one another.
304
+ ----------------------------------------------------- */
305
+ chartSection.style.display =
306
+ data.show_chart ? "grid" : "none";
307
+ segmentLabels.style.display =
308
+ data.show_segment_labels ? "block" : "none";
309
+
310
+ /* -----------------------------------------------------
311
+ SEGMENT COUNT -- driven entirely by how many dividing
312
+ values were passed in (N values -> N+1 segments), not
313
+ a fixed constant. A fresh handle count only matters on
314
+ slider creation below; re-renders just re-derive this
315
+ from whatever data.values currently holds.
316
+ ----------------------------------------------------- */
317
+ const numSegments =
318
+ data.values.length + 1;
319
+
320
+ /* -----------------------------------------------------
321
+ TEXT -- an empty/falsy label hides its own element
322
+ entirely (not just empty text), so a caller that
323
+ doesn't want a caption above the track (e.g. one
324
+ embedding this compactly, where the segment labels
325
+ already say what the slider's for) doesn't pay for
326
+ that row's reserved height either.
327
+ ----------------------------------------------------- */
328
+ label.textContent = data.label;
329
+ label.style.display = data.label ? "block" : "none";
330
+ minLabel.textContent = data.min;
331
+ maxLabel.textContent = data.max;
332
+
333
+ /* -----------------------------------------------------
334
+ GET STREAMLIT CHART COLORS
335
+ ----------------------------------------------------- */
336
+ const widget =
337
+ parentElement.querySelector(".segment-widget");
338
+ const styles =
339
+ getComputedStyle(widget);
340
+
341
+ function cssVar(name, fallback) {
342
+ const value =
343
+ styles.getPropertyValue(name).trim();
344
+ return value || fallback;
345
+ }
346
+
347
+ let palette = [];
348
+ const categorical =
349
+ styles
350
+ .getPropertyValue("--st-chart-categorical-colors")
351
+ .trim();
352
+
353
+ if (categorical) {
354
+ palette =
355
+ categorical
356
+ .split(",")
357
+ .map(c =>
358
+ c
359
+ .trim()
360
+ .replace(/^["']|["']$/g, "")
361
+ );
362
+ }
363
+
364
+ /* Fallback to Streamlit semantic colors if the theme didn't expose a categorical palette */
365
+ if (palette.length === 0) {
366
+ palette = [
367
+ cssVar("--st-blue-color", "#0068c9"),
368
+ cssVar("--st-orange-color", "#ff8700"),
369
+ cssVar("--st-green-color", "#09ab3b"),
370
+ cssVar("--st-violet-color", "#803df5"),
371
+ cssVar("--st-red-color", "#ff2b2b")
372
+ ];
373
+ }
374
+
375
+ /* One color per segment -- cycles (modulo) if there are more segments than colors in the
376
+ palette, a graceful fallback for an unusually high segment count rather than a hard error;
377
+ realistic uses (budget splits, allocations) are expected to stay well under the palette
378
+ size. */
379
+ const colors =
380
+ Array.from(
381
+ { length: numSegments },
382
+ (_, i) => palette[i % palette.length]
383
+ );
384
+
385
+ /* -----------------------------------------------------
386
+ NUMBER FORMAT
387
+ ----------------------------------------------------- */
388
+ const decimals =
389
+ data.decimals ?? 0;
390
+ function formatValue(value) {
391
+ return Number(value)
392
+ .toFixed(decimals);
393
+ }
394
+
395
+ /* -----------------------------------------------------
396
+ DRAW PIE + LEGEND
397
+ ----------------------------------------------------- */
398
+ function render(values) {
399
+ values =
400
+ values
401
+ .map(Number)
402
+ .sort((a, b) => a - b);
403
+
404
+ const total =
405
+ data.max - data.min;
406
+
407
+ /* N dividing values -> N+1 segment widths, via consecutive differences across
408
+ [min, ...values, max]. */
409
+ const boundaryPoints = [
410
+ data.min,
411
+ ...values,
412
+ data.max
413
+ ];
414
+ const segmentValues = [];
415
+ for (let i = 0; i < boundaryPoints.length - 1; i++) {
416
+ segmentValues.push(
417
+ boundaryPoints[i + 1] - boundaryPoints[i]
418
+ );
419
+ }
420
+
421
+ const percentages =
422
+ segmentValues.map(
423
+ value => value / total * 100
424
+ );
425
+
426
+ /* Cumulative percentage boundaries for the conic-gradient stops (0 .. 100), built as a
427
+ running sum. */
428
+ const boundaries = [0];
429
+ percentages.forEach(
430
+ p => boundaries.push(
431
+ boundaries[boundaries.length - 1] + p
432
+ )
433
+ );
434
+
435
+ /* Pie + legend -- skipped entirely when show_chart is off, same data this function
436
+ always computes either way (boundaries/percentages feed the segment labels below too). */
437
+ if (data.show_chart) {
438
+ /* Pie -- stop list built dynamically (one "<color> <start>% <end>%" term per segment). */
439
+ const stops =
440
+ colors
441
+ .map(
442
+ (color, i) =>
443
+ `${color} ${boundaries[i]}% ${boundaries[i + 1]}%`
444
+ )
445
+ .join(",\\n");
446
+ pie.style.background =
447
+ `conic-gradient(${stops})`;
448
+
449
+ /* Legend */
450
+ legend.replaceChildren();
451
+ segmentValues.forEach(
452
+ (value, index) => {
453
+ const row =
454
+ document.createElement("div");
455
+ row.className =
456
+ "legend-row";
457
+ const swatch =
458
+ document.createElement("span");
459
+ swatch.className =
460
+ "legend-color";
461
+ swatch.style.background =
462
+ colors[index];
463
+ const name =
464
+ document.createElement("span");
465
+ name.className =
466
+ "legend-name";
467
+ name.textContent =
468
+ data.segment_labels[index];
469
+ const amount =
470
+ document.createElement("span");
471
+ amount.className =
472
+ "legend-value";
473
+ amount.textContent =
474
+ `${formatValue(value)} (${percentages[index].toFixed(1)}%)`;
475
+ row.append(
476
+ swatch,
477
+ name,
478
+ amount
479
+ );
480
+
481
+ legend.appendChild(row);
482
+ }
483
+ );
484
+ }
485
+
486
+ /* Per-segment name + percentage labels -- the compact alternative to the chart. Each
487
+ label sits directly over its own segment's span of the track (left/width set to the
488
+ same boundaries the pie's conic-gradient stops use), so it stays aligned with the
489
+ colored region below even while dragging. Three width tiers (not one all-or-nothing
490
+ cutoff) since the segment's own name (data.segment_labels[index], e.g. "Growth") is
491
+ usually longer than just its percentage and needs more room to read without
492
+ overlapping its neighbors: wide enough for both (>= 18%) shows "Name NN%"; too narrow
493
+ for the name but not the number (>= 8%) shows just "NN%"; anything narrower than that
494
+ is left blank entirely -- the legend (when shown) or the handle tooltips still carry
495
+ the exact numbers either way. */
496
+ if (data.show_segment_labels) {
497
+ segmentLabels.replaceChildren();
498
+ percentages.forEach(
499
+ (pct, index) => {
500
+ const span =
501
+ document.createElement("span");
502
+ span.className =
503
+ "segment-label";
504
+ span.style.left =
505
+ `${boundaries[index]}%`;
506
+ span.style.width =
507
+ `${pct}%`;
508
+ span.style.color =
509
+ colors[index];
510
+ const pctText =
511
+ `${pct.toFixed(0)}%`;
512
+ span.textContent =
513
+ pct >= 18
514
+ ? `${data.segment_labels[index]} ${pctText}`
515
+ : pct >= 8
516
+ ? pctText
517
+ : "";
518
+ segmentLabels.appendChild(span);
519
+ }
520
+ );
521
+ }
522
+ }
523
+
524
+ /*
525
+ Store the newest render function on the slider.
526
+ That matters because Streamlit can rerun this JavaScript
527
+ after Python state changes without recreating the slider.
528
+ */
529
+ slider._streamlitRender = render;
530
+
531
+ /* -----------------------------------------------------
532
+ CREATE SLIDER ONCE
533
+ ----------------------------------------------------- */
534
+ if (!slider.noUiSlider) {
535
+ noUiSlider.create(
536
+ slider,
537
+ {
538
+ start: data.values,
539
+ step: data.step,
540
+ range: {
541
+ min: data.min,
542
+ max: data.max
543
+ },
544
+ /*
545
+ N handles create N+1 connected regions --
546
+ one "true" per segment, not a fixed entry count.
547
+ */
548
+ connect: Array(numSegments).fill(true),
549
+ behaviour: "tap",
550
+ tooltips: {
551
+ to: value =>
552
+ formatValue(value),
553
+ from: value =>
554
+ Number(value)
555
+ }
556
+ }
557
+ );
558
+
559
+ /* Color the N slider regions */
560
+ const connections =
561
+ slider.querySelectorAll(
562
+ ".noUi-connect"
563
+ );
564
+
565
+ connections.forEach(
566
+ (connection, index) => {
567
+ connection.style.background =
568
+ colors[index];
569
+ }
570
+ );
571
+
572
+
573
+ /*
574
+ UPDATE fires continuously while dragging.
575
+ Only redraw the chart here.
576
+ No Streamlit rerun.
577
+ */
578
+
579
+ slider.noUiSlider.on(
580
+ "update",
581
+ (
582
+ values,
583
+ handle,
584
+ unencoded
585
+ ) => {
586
+
587
+ slider._streamlitRender(
588
+ unencoded
589
+ );
590
+ }
591
+ );
592
+
593
+ /*
594
+ CHANGE fires when the user releases the handle.
595
+
596
+ NOW send the values back to Python.
597
+ */
598
+ slider.noUiSlider.on(
599
+ "change",
600
+ (
601
+ values,
602
+ handle,
603
+ unencoded
604
+ ) => {
605
+ setStateValue(
606
+ "value",
607
+ unencoded.map(
608
+ value =>
609
+ Number(
610
+ value.toFixed(decimals)
611
+ )
612
+ )
613
+ );
614
+ }
615
+ );
616
+ }
617
+
618
+ /* -----------------------------------------------------
619
+ SYNC FROM PYTHON
620
+ ----------------------------------------------------- */
621
+ else {
622
+ const current =
623
+ slider.noUiSlider
624
+ .get(true)
625
+ .map(Number);
626
+ const incoming =
627
+ data.values.map(Number);
628
+ const changed =
629
+ incoming.some(
630
+ (value, index) =>
631
+ Math.abs(
632
+ value - current[index]
633
+ ) > 1e-9
634
+ );
635
+ if (changed) {
636
+ slider.noUiSlider.set(
637
+ incoming
638
+ );
639
+ }
640
+
641
+ render(incoming);
642
+ }
643
+ }
644
+ """
645
+
646
+ _segment_slider_component = st.components.v2.component(
647
+ name="segment_slider",
648
+ html=HTML,
649
+ css=CSS,
650
+ js=JS,
651
+ )
652
+
653
+
654
+ def _decimal_places(step):
655
+ text = f"{step:.10f}".rstrip("0")
656
+ if "." not in text:
657
+ return 0
658
+ return len(text.split(".")[1])
659
+
660
+
661
+ def segment_slider(
662
+ label,
663
+ min_value,
664
+ max_value,
665
+ values,
666
+ *,
667
+ step=1,
668
+ segment_labels=None,
669
+ key=None,
670
+ show_chart=True,
671
+ show_segment_labels=False,
672
+ height=None,
673
+ ):
674
+ """A slider with a variable number of draggable dividing points along [min_value, max_value],
675
+ paired with a pie chart + legend -- N dividing values (`values`) produce N+1 connected
676
+ segments (e.g. 2 values -> 3 segments, a Cash/Growth/Income-style split; 3 values -> 4
677
+ segments). `segment_labels`, if given, must have exactly N+1 entries (one per segment, left
678
+ to right); left as None to get generic "Segment 1..N+1" labels.
679
+
680
+ `show_chart=False` drops the pie + legend section entirely -- useful when embedding this
681
+ compactly, e.g. once per row in a repeating list, where the full pie+legend design reads as
682
+ too much space per instance. `show_segment_labels=True` replaces the plain min/max row under
683
+ the track with each segment's own name + percentage (e.g. "Growth 80%"), color-matched and
684
+ positioned directly over that segment's own span of the track -- meant as a compact substitute
685
+ for the legend when the chart is hidden, though either option can be toggled independently of
686
+ the other. A segment too narrow to fit its name falls back to just its percentage, and one too
687
+ narrow for either is left blank rather than overlapping its neighbors.
688
+
689
+ `height` is exposed (not hardcoded) so a caller can size this to its own layout; left as None,
690
+ it resolves to 390px with the chart shown, or ~110-140px with the chart hidden (less again if
691
+ `label` is also left empty, since that row then takes no space at all) -- override it
692
+ explicitly for anything in between.
693
+
694
+ Returns the current list of N dividing values (floats, always sorted ascending -- noUiSlider
695
+ itself keeps handles from crossing, so this never needs to re-sort or re-validate what comes
696
+ back).
697
+ """
698
+ num_points = len(values)
699
+ if num_points < 1:
700
+ raise ValueError(
701
+ "segment_slider requires at least one dividing value (so at least two segments)."
702
+ )
703
+ num_segments = num_points + 1
704
+
705
+ if segment_labels is None:
706
+ segment_labels = [f"Segment {i + 1}" for i in range(num_segments)]
707
+ if len(segment_labels) != num_segments:
708
+ raise ValueError(
709
+ f"segment_labels must contain exactly {num_segments} label(s) for {num_points} "
710
+ f"dividing value(s) ({num_points} value(s) -> {num_segments} segments)."
711
+ )
712
+
713
+ default_values = list(map(float, values))
714
+
715
+ # A keyed Components v2 component stores its state as a dictionary in Session State.
716
+ state = st.session_state.get(key, {})
717
+ if isinstance(state, dict):
718
+ current_values = state.get("value", default_values)
719
+ else:
720
+ current_values = default_values
721
+
722
+ if height is None:
723
+ # Each branch measured, not guessed, against the rendered widget's own bounding box, plus
724
+ # a small safety margin -- a too-generous default here leaves a visible gap of dead space
725
+ # before whatever comes after this widget on the page; a too-tight one silently clips real
726
+ # content (the min/max row, or a handle tooltip) against the host's own boundary, which
727
+ # doesn't scroll or show any sign anything's missing -- see this file's own CSS comments on
728
+ # .slider-wrap and .legend for two real bugs that came from exactly that. 390 (chart shown)
729
+ # is the original design height, unaffected by label. Chart hidden: measured 125px tall
730
+ # with a label shown, 96px with label="" (the label row itself hides entirely when empty)
731
+ # -- re-measured after the min/max row became always-visible (it used to be swapped out for
732
+ # the segment labels, not shown alongside them), which is why these are taller than this
733
+ # same measurement was before that change.
734
+ if show_chart:
735
+ height = 390
736
+ elif label:
737
+ height = 140
738
+ else:
739
+ height = 110
740
+
741
+ result = _segment_slider_component(
742
+ data={
743
+ "label": label,
744
+ "min": float(min_value),
745
+ "max": float(max_value),
746
+ "values": current_values,
747
+ "step": float(step),
748
+ "decimals": _decimal_places(step),
749
+ "segment_labels": list(segment_labels),
750
+ "show_chart": bool(show_chart),
751
+ "show_segment_labels": bool(show_segment_labels),
752
+ },
753
+ default={"value": default_values},
754
+ on_value_change=lambda: None,
755
+ key=key,
756
+ width="stretch",
757
+ height=height,
758
+ )
759
+
760
+ return list(result.value)
@@ -0,0 +1,139 @@
1
+ Metadata-Version: 2.5
2
+ Name: streamlit-segment-slider
3
+ Version: 0.1.0
4
+ Summary: A multi-handle range slider for Streamlit -- split a range into any number of segments, with a pie chart/legend or compact inline labels.
5
+ Project-URL: Homepage, https://github.com/pjpeacock/streamlit-segment-slider
6
+ Project-URL: Repository, https://github.com/pjpeacock/streamlit-segment-slider
7
+ Project-URL: Issues, https://github.com/pjpeacock/streamlit-segment-slider/issues
8
+ Author-email: pjpeacock <philip.j.peacock@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: component,range-slider,slider,streamlit,streamlit-component
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Software Development :: Widget Sets
17
+ Requires-Python: >=3.9
18
+ Requires-Dist: streamlit>=1.61.1
19
+ Description-Content-Type: text/markdown
20
+
21
+ # streamlit-segment-slider
22
+
23
+ A multi-handle range slider for Streamlit. Drag any number of dividing points along a range to
24
+ split it into that many segments -- 2 dividing points make 3 segments (a Cash/Growth/Income-style
25
+ split), 3 points make 4, and so on for any count. Pairs with a pie chart + legend by default, or
26
+ with per-segment name + percentage labels positioned directly on the track itself, for a much more
27
+ compact embed (e.g. one per row in a repeating list).
28
+
29
+ Built on [Streamlit Custom Components v2](https://docs.streamlit.io/develop/api-reference/custom-components/st.components.v2.component)
30
+ and [noUiSlider](https://refreshless.com/nouislider/) (loaded from a CDN at runtime -- see
31
+ **Requirements** below). Pure Python, inline component -- no npm install, no build step.
32
+
33
+ [GitHub repository](https://github.com/pjpeacock/streamlit-segment-slider)
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install streamlit-segment-slider
39
+ ```
40
+
41
+ ## Usage
42
+
43
+ ```python
44
+ import streamlit as st
45
+ from streamlit_segment_slider import segment_slider
46
+
47
+ cuts = segment_slider(
48
+ "Roughly how is it split?",
49
+ min_value=0,
50
+ max_value=100,
51
+ values=[25, 75],
52
+ segment_labels=["Cash", "Growth", "Income"],
53
+ key="my_split",
54
+ )
55
+
56
+ cash_pct = cuts[0]
57
+ growth_pct = cuts[1] - cuts[0]
58
+ income_pct = 100 - cuts[1]
59
+ ```
60
+
61
+ Run `example.py` in this repo for a live demo of both display modes:
62
+
63
+ ```bash
64
+ streamlit run example.py
65
+ ```
66
+
67
+ ## API
68
+
69
+ ```python
70
+ segment_slider(
71
+ label,
72
+ min_value,
73
+ max_value,
74
+ values,
75
+ *,
76
+ step=1,
77
+ segment_labels=None,
78
+ key=None,
79
+ show_chart=True,
80
+ show_segment_labels=False,
81
+ height=None,
82
+ ) -> list[float]
83
+ ```
84
+
85
+ | Parameter | Description |
86
+ |---|---|
87
+ | `label` | Caption shown above the track. Pass `""` to hide that row entirely (not just render it empty) -- useful in compact mode, where the segment labels already say what the slider is for. |
88
+ | `min_value`, `max_value` | The full range of the track. |
89
+ | `values` | The current dividing points (N values -> N+1 segments). Only used as the seed value the first time this widget's `key` is ever rendered -- after that, Streamlit's own session state owns the live value, same as `value=`/`default=` on any other widget. |
90
+ | `step` | Granularity of each handle's movement. |
91
+ | `segment_labels` | Exactly N+1 names, one per segment left to right. Left as `None` for generic "Segment 1".."Segment N+1" labels. |
92
+ | `key` | Required to track state the normal Streamlit way if you use more than one slider on a page. |
93
+ | `show_chart` | Set `False` to drop the pie chart + legend entirely. |
94
+ | `show_segment_labels` | Set `True` to label each segment's own name + percentage directly on the track (replaces the plain min/max row). A segment too narrow for its name falls back to just its percentage; one too narrow for either is left blank. Meant to be paired with `show_chart=False` for a compact embed, though either can be toggled independently. |
95
+ | `height` | Component height in pixels. Left as `None`, it resolves automatically based on `show_chart`/`label` (390px with the chart shown, ~110-140px without) -- override for anything in between. |
96
+
97
+ Returns the current list of N dividing values, always sorted ascending (noUiSlider itself keeps
98
+ handles from crossing).
99
+
100
+ ## Requirements
101
+
102
+ - Streamlit >= 1.61.1 (verified against this version; `st.components.v2` is a newer API, so older
103
+ releases won't have it).
104
+ - A network path to `cdn.jsdelivr.net` at runtime -- noUiSlider's JS/CSS load from there (pinned
105
+ to `15.8.1`, not `latest`), not from a bundled/vendored copy. If your deployment blocks that CDN,
106
+ this component won't render.
107
+
108
+ ## Limitations
109
+
110
+ Tested directly (not just inferred) up to 30 segments on one slider -- it never errors, but a few
111
+ things degrade past a point:
112
+
113
+ - **Colors repeat after 10 segments.** The default Streamlit theme exposes exactly 10 categorical
114
+ colors; segment 11 reuses segment 1's color, 12 reuses 2's, and so on. Two differently-named
115
+ segments can end up visually identical once you're past 10.
116
+ - **Track labels (`show_segment_labels=True`) need room.** A segment narrower than ~8% of the
117
+ range shows no label at all; one narrower than ~18% shows just its percentage, not its name.
118
+ With many segments (or a few very small ones), most labels go blank -- the handle tooltips above
119
+ the track still show the exact cut points either way.
120
+ - **The legend scrolls, not grows, once content is taller than it.** `show_chart=True`'s legend is
121
+ capped at 200px with its own scrollbar for exactly this reason -- without it, a long legend used
122
+ to silently get clipped by the component's fixed `height`, with no scrollbar and no visible sign
123
+ anything was cut off. If you expect many segments, either keep `show_chart=False` (the compact
124
+ label mode doesn't have this problem) or expect your users to scroll the legend.
125
+ - **Numeric ranges only** -- `min_value`/`max_value`/`values` are all cast to `float`. Unlike
126
+ native `st.slider`, there's no date/time/datetime support.
127
+ - **Shadow DOM isolation.** This component renders with `isolate_styles=True` (the CCv2 default),
128
+ so it's in a shadow root: app-level CSS injected via `st.markdown(unsafe_allow_html=True)` won't
129
+ reach its internals, and a plain `document.querySelector` (including from most browser
130
+ automation/testing tools) won't find them either -- use a tool that pierces shadow roots
131
+ (e.g. Playwright's own locators do this automatically).
132
+ - **`st.session_state[key]` is a dict, not a plain list.** Components v2 stores a mounted
133
+ component's state as `{"value": [...]}` under its `key`. Use `segment_slider()`'s own return
134
+ value (always a plain `list[float]`) rather than reading `st.session_state[key]` directly, unless
135
+ you specifically want that wrapper shape.
136
+
137
+ ## License
138
+
139
+ MIT -- see [LICENSE](LICENSE).
@@ -0,0 +1,5 @@
1
+ streamlit_segment_slider/__init__.py,sha256=N5ZX2dYIj9lhb1UdfM384NYnaQ6X9aoF5EkxQ3CrVW8,24854
2
+ streamlit_segment_slider-0.1.0.dist-info/METADATA,sha256=pHPuU1hfepHNDE4F3uujhNfo1GG4h2UM0k301yzUVWw,6758
3
+ streamlit_segment_slider-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
4
+ streamlit_segment_slider-0.1.0.dist-info/licenses/LICENSE,sha256=8Xx0zRZK_u0RuEAH3QDvNQFcmpPPqgdUKpghzjrKXf0,1066
5
+ streamlit_segment_slider-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pjpeacock
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.