1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
//! Metric slider — a single-value slider whose central UI element is the
//! value itself, rendered large in the header.
//!
//! [`MetricSlider`] generalises the design of
//! [`PercentSlider`](crate::PercentSlider) to an arbitrary numeric range. The
//! visual identity is the same: a prominent headline value above a rail, with
//! optional ticks and a drag callout. [`PercentSlider`](crate::PercentSlider)
//! is the `0.0..=100.0` + "%" preset over this widget.
//!
//! Use `MetricSlider` when the value carries its own absolute meaning (a
//! buffer size in GiB, a latency budget in milliseconds, a refresh-rate
//! target). Use [`PercentSlider`](crate::PercentSlider) when the value is a
//! fraction of a total and the percentage is the user-facing unit.
//!
//! Three snap modes are available, in order of specificity:
//! [`step`](MetricSlider::step) snaps to multiples of a fixed size
//! (relative to the start of the range);
//! [`steps`](MetricSlider::steps) snaps to `n` evenly-spaced positions
//! including both endpoints; [`stops`](MetricSlider::stops) snaps to an
//! explicit, possibly non-uniform list of positions. When `steps` or `stops`
//! is set, the tick row renders at exactly those positions and the arrow
//! keys jump between them.
use std::ops::RangeInclusive;
use egui::{
Color32, CornerRadius, CursorIcon, Event, EventFilter, Key, Pos2, Rect, Response, Sense, Shape,
Stroke, StrokeKind, Ui, Vec2, Widget, WidgetInfo, WidgetText, WidgetType,
};
use crate::slider::{SliderHandle, paint_handle};
use crate::theme::{Accent, BASELINE_FRAC, Theme, placeholder_galley, with_alpha};
/// A single-value slider whose central UI element is the value itself.
///
/// ```no_run
/// # use elegance::{Accent, MetricSlider};
/// # egui::__run_test_ui(|ui| {
/// let mut buffer = 16.0_f32;
/// ui.add(
/// MetricSlider::new(&mut buffer, 0.0..=32.0)
/// .label("Buffer size")
/// .suffix("GiB")
/// .stops([4.0, 8.0, 16.0, 32.0])
/// .accent(Accent::Amber),
/// );
/// # });
/// ```
#[must_use = "Call `ui.add(...)` to render the widget."]
pub struct MetricSlider<'a> {
value: &'a mut f32,
range: RangeInclusive<f32>,
label: Option<WidgetText>,
accent: Accent,
show_ticks: bool,
step: Option<f32>,
stops: Option<Vec<f32>>,
decimals: usize,
suffix: Option<String>,
headline_fmt: Option<Box<dyn Fn(f32) -> String + 'a>>,
tick_fmt: Option<Box<dyn Fn(f32) -> String + 'a>>,
callout_fmt: Option<Box<dyn Fn(f32) -> String + 'a>>,
desired_width: Option<f32>,
handle: SliderHandle,
}
impl<'a> std::fmt::Debug for MetricSlider<'a> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MetricSlider")
.field("range", &self.range)
.field("accent", &self.accent)
.field("show_ticks", &self.show_ticks)
.field("step", &self.step)
.field("stops", &self.stops)
.field("decimals", &self.decimals)
.field("suffix", &self.suffix)
.field("desired_width", &self.desired_width)
.field("handle", &self.handle)
.finish()
}
}
impl<'a> MetricSlider<'a> {
/// Create a slider bound to `value`, clamped to `range`.
///
/// Non-finite, swapped, or zero-width ranges are silently sanitised to a
/// safe default; callers should pass a well-formed range.
pub fn new(value: &'a mut f32, range: RangeInclusive<f32>) -> Self {
Self {
value,
range: sanitize_range(range),
label: None,
accent: Accent::Sky,
show_ticks: true,
step: None,
stops: None,
decimals: 0,
suffix: None,
headline_fmt: None,
tick_fmt: None,
callout_fmt: None,
desired_width: None,
handle: SliderHandle::Circle,
}
}
/// Add a small label in the top-left of the header row.
#[inline]
pub fn label(mut self, label: impl Into<WidgetText>) -> Self {
self.label = Some(label.into());
self
}
/// Pick the fill colour from one of the theme accents. Default: [`Accent::Sky`].
#[inline]
pub fn accent(mut self, accent: Accent) -> Self {
self.accent = accent;
self
}
/// Show or hide the tick row beneath the track. Default: `true`.
#[inline]
pub fn show_ticks(mut self, show: bool) -> Self {
self.show_ticks = show;
self
}
/// Snap the value to multiples of `step` (relative to the start of the
/// range). For a `0.0..=32.0` range with `step(4.0)` the snap positions
/// are `0, 4, 8, …, 32`.
///
/// Mutually exclusive with [`steps`](Self::steps) and
/// [`stops`](Self::stops); calling this clears either of them.
#[inline]
pub fn step(mut self, step: f32) -> Self {
self.step = Some(step);
self.stops = None;
self
}
/// Snap to `n` evenly-spaced positions across the range, including both
/// endpoints. `steps(2)` snaps to `{start, end}`, `steps(5)` to five
/// equally-spaced positions, and so on. Values below `2` are promoted to
/// `2`.
///
/// When set, the tick row renders at exactly those positions and the
/// arrow keys jump between adjacent stops. Mutually exclusive with
/// [`step`](Self::step) and [`stops`](Self::stops); calling this clears
/// either of them.
#[inline]
pub fn steps(mut self, n: usize) -> Self {
let n = n.max(2);
let (s, e) = (*self.range.start(), *self.range.end());
let span = e - s;
let last = (n - 1) as f32;
self.stops = Some((0..n).map(|i| s + (i as f32 / last) * span).collect());
self.step = None;
self
}
/// Snap to an explicit, possibly non-uniform list of positions inside the
/// range. Out-of-range, `NaN`, and duplicate values are filtered out; the
/// result is sorted ascending. If fewer than two valid positions remain,
/// falls back to `[range.start(), range.end()]`.
///
/// When set, the tick row renders at exactly these positions and the
/// arrow keys jump between adjacent stops. Mutually exclusive with
/// [`step`](Self::step) and [`steps`](Self::steps); calling this clears
/// either of them.
pub fn stops(mut self, positions: impl IntoIterator<Item = f32>) -> Self {
let (s, e) = (*self.range.start(), *self.range.end());
let mut v: Vec<f32> = positions
.into_iter()
.filter(|p| p.is_finite() && (s..=e).contains(p))
.collect();
v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
v.dedup_by(|a, b| (*a - *b).abs() < f32::EPSILON);
if v.len() < 2 {
v = vec![s, e];
}
self.stops = Some(v);
self.step = None;
self
}
/// Number of decimal places in the default headline and tick formatters.
/// Default: `0`. Ignored when an explicit
/// [`headline_fmt`](Self::headline_fmt) or [`tick_fmt`](Self::tick_fmt)
/// is set.
#[inline]
pub fn decimals(mut self, n: usize) -> Self {
self.decimals = n;
self
}
/// Render a small, muted unit string baseline-aligned with the headline
/// value. Use this for short units like `"%"`, `"GiB"`, `"ms"`. Default:
/// no suffix.
#[inline]
pub fn suffix(mut self, suffix: impl Into<String>) -> Self {
self.suffix = Some(suffix.into());
self
}
/// Supply a callback to format the entire headline value text. The
/// callback receives the current value (in the slider's range) and
/// returns the string rendered large in the top-right of the widget.
/// When unset, the headline renders `format!("{value:.decimals$}")`.
///
/// The [`suffix`](Self::suffix) (if set) is still drawn after the
/// formatted text; pass `None` for the suffix when the formatter already
/// includes the unit.
#[inline]
pub fn headline_fmt(mut self, fmt: impl Fn(f32) -> String + 'a) -> Self {
self.headline_fmt = Some(Box::new(fmt));
self
}
/// Supply a callback to format tick-row labels. The callback receives
/// each tick's value (in the slider's range) and returns the label to
/// render beneath the track. When unset, ticks render
/// `format!("{value:.decimals$}")`.
#[inline]
pub fn tick_fmt(mut self, fmt: impl Fn(f32) -> String + 'a) -> Self {
self.tick_fmt = Some(Box::new(fmt));
self
}
/// Supply a callback to format the drag-callout text. The callback
/// receives the current value and returns the string to render in the
/// callout above the thumb while the user drags. When unset, no callout
/// is shown.
#[inline]
pub fn callout_fmt(mut self, fmt: impl Fn(f32) -> String + 'a) -> Self {
self.callout_fmt = Some(Box::new(fmt));
self
}
/// Override the slider width. Defaults to `ui.available_width()`.
#[inline]
pub fn desired_width(mut self, width: f32) -> Self {
self.desired_width = Some(width);
self
}
/// Pick the thumb shape. Default: [`SliderHandle::Circle`]. Switch to
/// [`SliderHandle::Line`] for a thin vertical bar instead of the standard
/// circular knob.
#[inline]
pub fn handle(mut self, handle: SliderHandle) -> Self {
self.handle = handle;
self
}
}
impl<'a> Widget for MetricSlider<'a> {
fn ui(self, ui: &mut Ui) -> Response {
let theme = Theme::current(ui.ctx());
let p = &theme.palette;
let t = &theme.typography;
let accent_fill = p.accent_fill(self.accent);
let (range_start, range_end) = (*self.range.start(), *self.range.end());
let range_span = (range_end - range_start).max(f32::EPSILON);
let value_size = (t.heading * 1.5).round().max(20.0);
let sign_size = t.label;
let label_size = t.label;
let tick_size = t.small;
let probe = placeholder_galley(ui, "0", value_size, true, f32::INFINITY);
let header_h = probe.size().y;
let track_h: f32 = 6.0;
let thumb_d: f32 = 14.0;
let row_track_h = thumb_d.max(22.0);
let header_gap: f32 = 8.0;
let tick_gap: f32 = if self.show_ticks { 4.0 } else { 0.0 };
let tick_row_h: f32 = if self.show_ticks {
tick_size + 8.0
} else {
0.0
};
let total_h = header_h + header_gap + row_track_h + tick_gap + tick_row_h;
let total_w = self
.desired_width
.unwrap_or_else(|| ui.available_width())
.max(180.0);
let mut current = if self.value.is_nan() {
range_start
} else {
*self.value
};
current = current.clamp(range_start, range_end);
let step = self.step.filter(|s| s.is_finite() && *s > 0.0);
let stops = self.stops.as_deref();
// Snap a raw value (in the slider's range) to whichever snap mode
// (if any) is configured: explicit stops > step > continuous.
// Step rounds relative to `range_start` so the endpoints of the
// range are always reachable.
let snap = |raw: f32| -> f32 {
let v = if let Some(stops) = stops {
nearest_stop(stops, raw)
} else if let Some(s) = step {
range_start + ((raw - range_start) / s).round() * s
} else {
raw
};
v.clamp(range_start, range_end)
};
let (rect, mut response) =
ui.allocate_exact_size(Vec2::new(total_w, total_h), Sense::click_and_drag());
let thumb_pad = thumb_d * 0.5;
let track_y = rect.min.y + header_h + header_gap + row_track_h * 0.5;
let track_left = rect.min.x + thumb_pad;
let track_right = rect.max.x - thumb_pad;
let track_span = (track_right - track_left).max(1.0);
let track_rect = Rect::from_min_max(
Pos2::new(rect.min.x, track_y - track_h * 0.5),
Pos2::new(rect.max.x, track_y + track_h * 0.5),
);
if response.is_pointer_button_down_on()
&& let Some(pos) = response.interact_pointer_pos()
{
let clamped_x = pos.x.clamp(track_left, track_right);
let frac = ((clamped_x - track_left) / track_span).clamp(0.0, 1.0);
let next = snap(range_start + frac * range_span);
if (next - current).abs() > f32::EPSILON {
current = next;
*self.value = current;
response.mark_changed();
}
}
// Keyboard nudges when the slider has focus. Left / Right step by
// `step` (or 1% of the range span); Shift bumps to 10x; Home / End
// jump to the endpoints. Up / Down are intentionally left to egui's
// focus navigation so the user can move between stacked controls
// vertically.
//
// `set_focus_lock_filter` claims horizontal arrows for this widget
// while it's focused; without it, egui's focus system consumes
// Left / Right for spatial navigation before the value handler below
// ever sees them.
if response.has_focus() {
ui.memory_mut(|m| {
m.set_focus_lock_filter(
response.id,
EventFilter {
horizontal_arrows: true,
..Default::default()
},
);
});
let small_step = step.unwrap_or(range_span / 100.0);
let big_step = small_step * 10.0;
let events = ui.input(|input| input.events.clone());
for ev in &events {
if let Event::Key {
key,
pressed: true,
modifiers,
..
} = ev
{
let nudge = if modifiers.shift {
big_step
} else {
small_step
};
let raw = match (stops, key) {
(Some(stops), Key::ArrowLeft) => adjacent_stop(stops, current, -1),
(Some(stops), Key::ArrowRight) => adjacent_stop(stops, current, 1),
(Some(stops), Key::Home) => stops.first().copied(),
(Some(stops), Key::End) => stops.last().copied(),
(None, Key::ArrowLeft) => Some(current - nudge),
(None, Key::ArrowRight) => Some(current + nudge),
(None, Key::Home) => Some(range_start),
(None, Key::End) => Some(range_end),
_ => None,
};
if let Some(next) = raw {
let next = snap(next);
if (next - current).abs() > f32::EPSILON {
current = next;
*self.value = current;
response.mark_changed();
}
}
}
}
}
if response.hovered() {
ui.ctx().set_cursor_icon(CursorIcon::Grab);
}
if response.is_pointer_button_down_on() {
ui.ctx().set_cursor_icon(CursorIcon::Grabbing);
}
if ui.is_rect_visible(rect) {
let painter = ui.painter().clone();
// ---- Header row ----------------------------------------------
let decimals = self.decimals;
let num_text = match &self.headline_fmt {
Some(f) => f(current),
None => format!("{current:.decimals$}"),
};
let num_galley = placeholder_galley(ui, &num_text, value_size, true, f32::INFINITY);
let baseline_y = rect.min.y + num_galley.size().y * BASELINE_FRAC;
let (num_x, label_right_edge) = if let Some(suffix) = &self.suffix {
let sign_galley = placeholder_galley(ui, suffix, sign_size, false, f32::INFINITY);
let sign_x = rect.max.x - sign_galley.size().x;
let num_x = sign_x - 2.0 - num_galley.size().x;
painter.galley(
Pos2::new(sign_x, baseline_y - sign_galley.size().y * BASELINE_FRAC),
sign_galley,
p.text_muted,
);
(num_x, num_x)
} else {
let num_x = rect.max.x - num_galley.size().x;
(num_x, num_x)
};
painter.galley(Pos2::new(num_x, rect.min.y), num_galley, p.text);
if let Some(label) = &self.label {
let label_wrap = (label_right_edge - rect.min.x - 8.0).max(0.0);
let label_galley =
placeholder_galley(ui, label.text(), label_size, false, label_wrap);
let label_top = baseline_y - label_galley.size().y * BASELINE_FRAC;
painter.galley(Pos2::new(rect.min.x, label_top), label_galley, p.text);
}
// ---- Track ---------------------------------------------------
let frac = ((current - range_start) / range_span).clamp(0.0, 1.0);
let thumb_x = track_left + track_span * frac;
let thumb_center = Pos2::new(thumb_x, track_y);
let track_radius = CornerRadius::same((track_h * 0.5).round() as u8);
painter.rect(
track_rect,
track_radius,
p.input_bg,
Stroke::new(1.0, p.border),
StrokeKind::Inside,
);
if thumb_x > track_rect.min.x + 0.5 {
let fill_rect = Rect::from_min_max(
Pos2::new(track_rect.min.x, track_rect.min.y),
Pos2::new(thumb_x, track_rect.max.y),
);
painter.rect_filled(fill_rect, track_radius, accent_fill);
}
// Faint 10% interior divisions, slightly stronger at the midpoint.
// Painted on top of both the filled and unfilled portions so the
// proportion reads at a glance like a battery indicator without
// breaking the smooth-fill feel. These are rail-tenths, not
// value-tenths; they are decorative and independent of range.
let div_base = if p.is_dark {
Color32::WHITE
} else {
Color32::BLACK
};
for i in 1..10 {
let f = i as f32 / 10.0;
let x = track_rect.min.x + track_rect.width() * f;
let alpha = if i == 5 { 48 } else { 24 };
painter.line_segment(
[
Pos2::new(x, track_rect.min.y + 1.5),
Pos2::new(x, track_rect.max.y - 1.5),
],
Stroke::new(1.0, with_alpha(div_base, alpha)),
);
}
let active = response.has_focus() || response.is_pointer_button_down_on();
let halo = active.then(|| with_alpha(accent_fill, 55));
let line_body = if p.is_dark {
p.text
} else {
p.accent_hover(self.accent)
};
let (body, ring) = match self.handle {
SliderHandle::Circle => (p.text, Stroke::new(2.0, accent_fill)),
SliderHandle::Line => (line_body, Stroke::NONE),
};
paint_handle(
&painter,
self.handle,
thumb_center,
thumb_d,
body,
ring,
halo,
);
// ---- Ticks ---------------------------------------------------
// With explicit stops: render a tick at each, all medium weight
// (no major/minor distinction). Without stops: the default
// quartile row, with start / midpoint / end drawn slightly
// heavier than the quarter marks.
if self.show_ticks {
let tick_top_y = rect.min.y + header_h + header_gap + row_track_h + tick_gap;
let tick_fmt = |v: f32| -> String {
match &self.tick_fmt {
Some(f) => f(v),
None => format!("{v:.decimals$}"),
}
};
let ticks: Vec<(f32, String, bool)> = match stops {
Some(stops) => stops
.iter()
.map(|v| {
let frac = ((*v - range_start) / range_span).clamp(0.0, 1.0);
(frac, tick_fmt(*v), false)
})
.collect(),
None => [0.00_f32, 0.25, 0.50, 0.75, 1.00]
.into_iter()
.enumerate()
.map(|(i, f)| {
let v = range_start + f * range_span;
let major = i % 2 == 0;
(f, tick_fmt(v), major)
})
.collect(),
};
for (f, label, major) in &ticks {
let x = track_left + track_span * f;
let tick_h = if *major { 5.0 } else { 3.5 };
let tick_color = if *major { p.text_muted } else { p.border };
painter.line_segment(
[Pos2::new(x, tick_top_y), Pos2::new(x, tick_top_y + tick_h)],
Stroke::new(1.0, tick_color),
);
let g = placeholder_galley(ui, label, tick_size, false, f32::INFINITY);
let raw_x = x - g.size().x * 0.5;
let tx = raw_x.max(rect.min.x).min(rect.max.x - g.size().x);
let ty = tick_top_y + tick_h + 1.0;
painter.galley(Pos2::new(tx, ty), g, p.text_faint);
}
}
// ---- Drag callout --------------------------------------------
if response.is_pointer_button_down_on()
&& let Some(fmt) = &self.callout_fmt
{
paint_callout(
ui,
&theme,
&painter,
thumb_center,
rect,
thumb_d,
fmt(current),
);
}
}
let label_str = self
.label
.as_ref()
.map(|l| l.text().to_string())
.unwrap_or_default();
response.widget_info(|| WidgetInfo::labeled(WidgetType::Slider, true, &label_str));
response
}
}
fn sanitize_range(range: RangeInclusive<f32>) -> RangeInclusive<f32> {
let (mut s, mut e) = range.into_inner();
if !s.is_finite() || !e.is_finite() {
s = 0.0;
e = 100.0;
} else if s > e {
std::mem::swap(&mut s, &mut e);
}
if (e - s).abs() < f32::EPSILON {
s = 0.0;
e = 100.0;
}
s..=e
}
fn paint_callout(
ui: &egui::Ui,
theme: &Theme,
painter: &egui::Painter,
thumb_center: Pos2,
widget_rect: Rect,
thumb_d: f32,
text: String,
) {
let p = &theme.palette;
let t = &theme.typography;
let g = placeholder_galley(ui, &text, t.label, false, f32::INFINITY);
let pad_x: f32 = 9.0;
let pad_y: f32 = 5.0;
let tail_h: f32 = 5.0;
let cw = g.size().x + 2.0 * pad_x;
let ch = g.size().y + 2.0 * pad_y;
let half_w = cw * 0.5;
let edge_pad = 4.0;
let cx = thumb_center.x.clamp(
widget_rect.min.x + half_w + edge_pad,
widget_rect.max.x - half_w - edge_pad,
);
let cy_bottom = thumb_center.y - thumb_d * 0.5 - tail_h - 2.0;
let crect = Rect::from_min_max(
Pos2::new(cx - half_w, cy_bottom - ch),
Pos2::new(cx + half_w, cy_bottom),
);
let fill = p.depth_tint(p.input_bg, 0.04);
let border = p.border;
painter.rect(
crect,
CornerRadius::same(5),
fill,
Stroke::new(1.0, border),
StrokeKind::Inside,
);
let tail_x = thumb_center
.x
.clamp(crect.min.x + tail_h + 2.0, crect.max.x - tail_h - 2.0);
let tail_top_y = crect.max.y;
let tip = Pos2::new(tail_x, tail_top_y + tail_h);
let left = Pos2::new(tail_x - tail_h, tail_top_y);
let right = Pos2::new(tail_x + tail_h, tail_top_y);
painter.add(Shape::convex_polygon(
vec![left, tip, right],
fill,
Stroke::new(1.0, border),
));
painter.line_segment(
[
Pos2::new(tail_x - tail_h + 1.0, tail_top_y),
Pos2::new(tail_x + tail_h - 1.0, tail_top_y),
],
Stroke::new(1.5, fill),
);
painter.galley(
Pos2::new(crect.min.x + pad_x, crect.min.y + pad_y),
g,
p.text,
);
}
/// Snap `raw` to the nearest entry in `stops`. `stops` is assumed sorted and
/// non-empty; the builder maintains both invariants.
fn nearest_stop(stops: &[f32], raw: f32) -> f32 {
let mut best = stops[0];
let mut best_d = (raw - best).abs();
for &s in &stops[1..] {
let d = (raw - s).abs();
if d < best_d {
best_d = d;
best = s;
}
}
best
}
/// Return the stop immediately above (`dir > 0`) or below (`dir < 0`) `current`,
/// or `None` if `current` is already at the corresponding extreme.
fn adjacent_stop(stops: &[f32], current: f32, dir: i32) -> Option<f32> {
let eps = 0.001;
if dir > 0 {
stops.iter().copied().find(|&s| s > current + eps)
} else {
stops.iter().rev().copied().find(|&s| s < current - eps)
}
}