Skip to main content

gpui_rhai/
range_input.rs

1//! Generic retained single-value range-input behavior for styled Rhai components.
2
3use std::collections::BTreeMap;
4
5use gpui::{
6    AnyElement, App, AppContext, Bounds, Context, CursorStyle, Element, ElementId, Entity,
7    FocusHandle, GlobalElementId, InspectorElementId, InteractiveElement, IntoElement,
8    KeyDownEvent, LayoutId, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Render,
9    Styled, Window, div, px, relative,
10};
11
12use crate::{
13    ComponentStateSchema, EventSchema, ObjectField, PrimitiveContext, PrimitiveDescriptor,
14    PrimitiveHandler, PrimitiveId, PrimitiveInstance, PrimitiveInstanceId, PrimitiveProps,
15    PrimitiveTheme, Style, TextDirection, UiValue, ValueSchema,
16};
17
18#[derive(Clone)]
19struct RangeInputConfig {
20    value: f64,
21    min: f64,
22    max: f64,
23    step: f64,
24    orientation: RangeOrientation,
25    disabled: bool,
26    track_style: Style,
27    fill_style: Style,
28    thumb_style: Style,
29    theme: PrimitiveTheme,
30}
31
32#[derive(Clone, Copy, Debug, Eq, PartialEq)]
33pub(crate) enum RangeOrientation {
34    Horizontal,
35    Vertical,
36}
37
38struct RangeInputEntity {
39    focus: FocusHandle,
40    controlled: f64,
41    preview: f64,
42    min: f64,
43    max: f64,
44    step: f64,
45    orientation: RangeOrientation,
46    disabled: bool,
47    dragging: bool,
48    bounds: Option<Bounds<Pixels>>,
49    events: PrimitiveContext,
50    interaction_key: String,
51    track_style: Style,
52    fill_style: Style,
53    thumb_style: Style,
54    theme: PrimitiveTheme,
55}
56
57impl RangeInputEntity {
58    fn new(
59        config: RangeInputConfig,
60        events: PrimitiveContext,
61        interaction_key: String,
62        cx: &mut Context<Self>,
63    ) -> Self {
64        let value = normalize_value(config.value, config.min, config.max, config.step);
65        Self {
66            focus: cx.focus_handle(),
67            controlled: value,
68            preview: value,
69            min: config.min,
70            max: config.max,
71            step: config.step,
72            orientation: config.orientation,
73            disabled: config.disabled,
74            dragging: false,
75            bounds: None,
76            events,
77            interaction_key,
78            track_style: config.track_style,
79            fill_style: config.fill_style,
80            thumb_style: config.thumb_style,
81            theme: config.theme,
82        }
83    }
84
85    fn update_props(
86        &mut self,
87        config: RangeInputConfig,
88        events: PrimitiveContext,
89        cx: &mut Context<Self>,
90    ) {
91        self.min = config.min;
92        self.max = config.max;
93        self.step = config.step;
94        self.orientation = config.orientation;
95        self.disabled = config.disabled;
96        self.controlled = normalize_value(config.value, config.min, config.max, config.step);
97        if !self.dragging || self.disabled {
98            self.preview = self.controlled;
99        }
100        if self.disabled {
101            self.dragging = false;
102        }
103        self.focus = self.focus.clone().tab_stop(!self.disabled);
104        self.events = events;
105        self.track_style = config.track_style;
106        self.fill_style = config.fill_style;
107        self.thumb_style = config.thumb_style;
108        self.theme = config.theme;
109        cx.notify();
110    }
111
112    fn ratio(&self) -> f64 {
113        ((self.preview - self.min) / (self.max - self.min)).clamp(0.0, 1.0)
114    }
115
116    fn value_at(&self, position: Point<Pixels>) -> f64 {
117        let Some(bounds) = self.bounds else {
118            return self.preview;
119        };
120        range_value_at(
121            bounds,
122            position,
123            self.orientation,
124            self.theme.direction(),
125            self.min,
126            self.max,
127            self.step,
128        )
129    }
130
131    fn mouse_down(&mut self, event: &MouseDownEvent, window: &mut Window, cx: &mut Context<Self>) {
132        if self.disabled {
133            return;
134        }
135        self.focus.focus(window, cx);
136        self.dragging = true;
137        self.preview = self.value_at(event.position);
138        let entity = cx.weak_entity();
139        let update_entity = entity.clone();
140        let update =
141            move |gesture: crate::interaction::GestureUpdate, _: &mut Window, cx: &mut App| {
142                update_entity
143                    .update(cx, |input, cx| {
144                        if input.disabled {
145                            return;
146                        }
147                        input.preview = input.value_at(gesture.current());
148                        cx.notify();
149                    })
150                    .map_or(crate::interaction::InteractionFlow::Cancel, |()| {
151                        crate::interaction::InteractionFlow::Continue
152                    })
153            };
154        let finish_entity = entity.clone();
155        let finish =
156            move |gesture: crate::interaction::GestureUpdate, window: &mut Window, cx: &mut App| {
157                let _ = finish_entity.update(cx, |input, cx| {
158                    if input.disabled {
159                        input.dragging = false;
160                        input.preview = input.controlled;
161                        cx.notify();
162                        return;
163                    }
164                    input.preview = input.value_at(gesture.current());
165                    input.dragging = false;
166                    input.emit_change(input.preview, window, cx);
167                    cx.notify();
168                });
169            };
170        let cancel = move |_: &mut Window, cx: &mut App| {
171            let _ = entity.update(cx, |input, cx| {
172                input.dragging = false;
173                input.preview = input.controlled;
174                cx.notify();
175            });
176        };
177        let owner = self.events.interaction_owner(&self.interaction_key);
178        self.events.begin_interaction(
179            crate::interaction::NativeGesture::new(
180                owner,
181                event.position,
182                cx.entity_id(),
183                update,
184                finish,
185                cancel,
186            ),
187            window,
188            cx,
189        );
190    }
191
192    fn key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
193        if self.disabled {
194            return;
195        }
196        let key = event.keystroke.key.as_str();
197        let direction = self.theme.direction();
198        let delta = match (self.orientation, key, direction) {
199            (RangeOrientation::Horizontal, "left", TextDirection::LeftToRight)
200            | (RangeOrientation::Horizontal, "right", TextDirection::RightToLeft)
201            | (RangeOrientation::Vertical, "down", _) => Some(-self.step),
202            (RangeOrientation::Horizontal, "right", TextDirection::LeftToRight)
203            | (RangeOrientation::Horizontal, "left", TextDirection::RightToLeft)
204            | (RangeOrientation::Vertical, "up", _) => Some(self.step),
205            _ => None,
206        };
207        let next = if key == "home" {
208            Some(self.min)
209        } else if key == "end" {
210            Some(self.max)
211        } else {
212            delta.map(|delta| self.preview + delta)
213        };
214        if let Some(next) = next {
215            self.preview = normalize_value(next, self.min, self.max, self.step);
216            self.emit_change(self.preview, window, cx);
217            cx.stop_propagation();
218            cx.notify();
219        }
220    }
221
222    fn emit_change(&self, value: f64, window: &mut Window, cx: &mut Context<Self>) {
223        self.events
224            .propose("change", UiValue::Float(value), window, cx);
225    }
226}
227
228impl Render for RangeInputEntity {
229    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
230        let owner = self.events.interaction_owner(&self.interaction_key);
231        self.events.present_interaction(owner.clone());
232        if self.disabled {
233            self.events.cancel_interaction(&owner, window, cx);
234        }
235        let ratio = self.ratio();
236        let direction = self.theme.direction();
237        let mut fill = crate::renderer::apply_style_override(
238            div().absolute(),
239            &self.fill_style,
240            &self.theme,
241            direction,
242        );
243        let mut thumb = crate::renderer::apply_style_override(
244            div().absolute(),
245            &self.thumb_style,
246            &self.theme,
247            direction,
248        );
249        let track = crate::renderer::apply_style_override(
250            div().relative(),
251            &self.track_style,
252            &self.theme,
253            direction,
254        );
255        let track = match self.orientation {
256            RangeOrientation::Horizontal => {
257                let visual_ratio = horizontal_thumb_ratio(ratio, direction);
258                fill = fill.top(px(0.0)).bottom(px(0.0));
259                fill = if direction == TextDirection::RightToLeft {
260                    fill.right(px(0.0)).w(relative(fraction_f32(ratio)))
261                } else {
262                    fill.left(px(0.0)).w(relative(fraction_f32(ratio)))
263                };
264                thumb = thumb
265                    .left(relative(fraction_f32(visual_ratio)))
266                    .top(relative(0.5))
267                    .ml(px(-7.0))
268                    .mt(px(-7.0));
269                track.child(fill).child(thumb)
270            }
271            RangeOrientation::Vertical => {
272                fill = fill
273                    .left(px(0.0))
274                    .right(px(0.0))
275                    .bottom(px(0.0))
276                    .h(relative(fraction_f32(ratio)));
277                thumb = thumb
278                    .bottom(relative(fraction_f32(ratio)))
279                    .left(relative(0.5))
280                    .mb(px(-7.0))
281                    .ml(px(-7.0));
282                track.child(fill).child(thumb)
283            }
284        };
285        div()
286            .id("gpui-rhai-range-input")
287            .relative()
288            .size_full()
289            .flex()
290            .items_center()
291            .justify_center()
292            .track_focus(&self.focus)
293            .tab_stop(!self.disabled)
294            .cursor(if self.disabled {
295                CursorStyle::OperationNotAllowed
296            } else {
297                CursorStyle::PointingHand
298            })
299            .on_key_down(cx.listener(Self::key_down))
300            .on_mouse_down(MouseButton::Left, cx.listener(Self::mouse_down))
301            .child(track)
302            .child(RangeBoundsRecorder { input: cx.entity() })
303            .opacity(if self.disabled { 0.62 } else { 1.0 })
304    }
305}
306
307struct RangeBoundsRecorder {
308    input: Entity<RangeInputEntity>,
309}
310
311impl Element for RangeBoundsRecorder {
312    type RequestLayoutState = AnyElement;
313    type PrepaintState = ();
314
315    fn id(&self) -> Option<ElementId> {
316        None
317    }
318
319    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
320        None
321    }
322
323    fn request_layout(
324        &mut self,
325        _: Option<&GlobalElementId>,
326        _: Option<&InspectorElementId>,
327        window: &mut Window,
328        cx: &mut App,
329    ) -> (LayoutId, Self::RequestLayoutState) {
330        let mut child = div()
331            .absolute()
332            .top(px(0.0))
333            .right(px(0.0))
334            .bottom(px(0.0))
335            .left(px(0.0))
336            .into_any_element();
337        let layout = child.request_layout(window, cx);
338        (layout, child)
339    }
340
341    fn prepaint(
342        &mut self,
343        _: Option<&GlobalElementId>,
344        _: Option<&InspectorElementId>,
345        bounds: Bounds<Pixels>,
346        child: &mut Self::RequestLayoutState,
347        window: &mut Window,
348        cx: &mut App,
349    ) {
350        child.prepaint(window, cx);
351        self.input
352            .update(cx, |input, _| input.bounds = Some(bounds));
353    }
354
355    fn paint(
356        &mut self,
357        _: Option<&GlobalElementId>,
358        _: Option<&InspectorElementId>,
359        _: Bounds<Pixels>,
360        child: &mut Self::RequestLayoutState,
361        (): &mut Self::PrepaintState,
362        window: &mut Window,
363        cx: &mut App,
364    ) {
365        child.paint(window, cx);
366    }
367}
368
369impl IntoElement for RangeBoundsRecorder {
370    type Element = Self;
371
372    fn into_element(self) -> Self::Element {
373        self
374    }
375}
376
377#[derive(Default)]
378pub struct RangeInputPrimitiveHandler {
379    instances: BTreeMap<PrimitiveInstanceId, Entity<RangeInputEntity>>,
380}
381
382impl PrimitiveHandler for RangeInputPrimitiveHandler {
383    fn accessibility_actions(
384        &self,
385        _instance: &PrimitiveInstanceId,
386    ) -> Vec<gpui::AccessibleAction> {
387        vec![
388            gpui::AccessibleAction::Focus,
389            gpui::AccessibleAction::Decrement,
390            gpui::AccessibleAction::Increment,
391            gpui::AccessibleAction::SetValue,
392        ]
393    }
394
395    fn perform_accessibility_action(
396        &mut self,
397        instance: &PrimitiveInstanceId,
398        action: gpui::AccessibleAction,
399        data: Option<&gpui::accesskit::ActionData>,
400        window: &mut Window,
401        cx: &mut App,
402    ) -> Result<(), String> {
403        let entity = self
404            .instances
405            .get(instance)
406            .cloned()
407            .ok_or_else(|| "range input accessibility target is stale".to_owned())?;
408        if action == gpui::AccessibleAction::Focus {
409            let (disabled, focus) = {
410                let input = entity.read(cx);
411                (input.disabled, input.focus.clone())
412            };
413            if disabled {
414                return Err("disabled range input cannot receive focus".to_owned());
415            }
416            focus.focus(window, cx);
417            return Ok(());
418        }
419        entity.update(cx, |input, cx| {
420            if input.disabled {
421                return Err("disabled range input cannot change value".to_owned());
422            }
423            let requested = match action {
424                gpui::AccessibleAction::Decrement => input.preview - input.step,
425                gpui::AccessibleAction::Increment => input.preview + input.step,
426                gpui::AccessibleAction::SetValue => match data {
427                    Some(gpui::accesskit::ActionData::NumericValue(value)) => *value,
428                    Some(gpui::accesskit::ActionData::Value(value)) => value
429                        .parse::<f64>()
430                        .map_err(|_| "range input SetValue requires numeric data".to_owned())?,
431                    _ => return Err("range input SetValue requires numeric data".to_owned()),
432                },
433                _ => return Err("unsupported range input accessibility action".to_owned()),
434            };
435            input.preview = normalize_value(requested, input.min, input.max, input.step);
436            input.emit_change(input.preview, window, cx);
437            cx.notify();
438            Ok(())
439        })
440    }
441
442    fn render(
443        &mut self,
444        instance: &PrimitiveInstance,
445        events: &PrimitiveContext,
446        theme: &PrimitiveTheme,
447        _: &mut Window,
448        cx: &mut App,
449    ) -> Result<AnyElement, String> {
450        let id = instance
451            .id
452            .clone()
453            .ok_or_else(|| "RangeInputPrimitive requires a stable key".to_owned())?;
454        let config = parse_config(&instance.node.props, theme)?;
455        let entity = if let Some(entity) = self.instances.get(&id) {
456            entity.clone()
457        } else {
458            let events = events.clone();
459            let interaction_key = format!("{}:{}", id.key(), id.node());
460            let entity =
461                cx.new(|cx| RangeInputEntity::new(config.clone(), events, interaction_key, cx));
462            self.instances.insert(id, entity.clone());
463            entity
464        };
465        entity.update(cx, |input, cx| {
466            input.update_props(config, events.clone(), cx);
467        });
468        Ok(entity.into_any_element())
469    }
470
471    fn unmount(&mut self, instance: &PrimitiveInstanceId) {
472        self.instances.remove(instance);
473    }
474}
475
476fn parse_config(
477    props: &PrimitiveProps,
478    theme: &PrimitiveTheme,
479) -> Result<RangeInputConfig, String> {
480    let value = props
481        .number("value")
482        .ok_or_else(|| "range value is required".to_owned())?;
483    let min = props.number("min").unwrap_or(0.0);
484    let max = props.number("max").unwrap_or(100.0);
485    let step = props.number("step").unwrap_or(1.0);
486    if !value.is_finite() || !min.is_finite() || !max.is_finite() || !step.is_finite() {
487        return Err("range values must be finite".to_owned());
488    }
489    if max <= min {
490        return Err("range max must be greater than min".to_owned());
491    }
492    if step <= 0.0 || step > max - min {
493        return Err("range step must be positive and no larger than max - min".to_owned());
494    }
495    let orientation = match props.string("orientation") {
496        None | Some("horizontal") => RangeOrientation::Horizontal,
497        Some("vertical") => RangeOrientation::Vertical,
498        Some(other) => return Err(format!("unknown range orientation `{other}`")),
499    };
500    Ok(RangeInputConfig {
501        value,
502        min,
503        max,
504        step,
505        orientation,
506        disabled: props.boolean("disabled").unwrap_or(false),
507        track_style: props.style("track_style").cloned().unwrap_or_default(),
508        fill_style: props.style("fill_style").cloned().unwrap_or_default(),
509        thumb_style: props.style("thumb_style").cloned().unwrap_or_default(),
510        theme: theme.clone(),
511    })
512}
513
514pub(crate) fn normalize_value(value: f64, min: f64, max: f64, step: f64) -> f64 {
515    let snapped = min + ((value.clamp(min, max) - min) / step).round() * step;
516    snapped.clamp(min, max)
517}
518
519pub(crate) fn horizontal_thumb_ratio(ratio: f64, direction: TextDirection) -> f64 {
520    if direction == TextDirection::RightToLeft {
521        1.0 - ratio
522    } else {
523        ratio
524    }
525}
526
527pub(crate) fn fraction_f32(value: f64) -> f32 {
528    value.to_string().parse().unwrap_or(0.0)
529}
530
531pub(crate) fn range_value_at(
532    bounds: Bounds<Pixels>,
533    position: Point<Pixels>,
534    orientation: RangeOrientation,
535    direction: TextDirection,
536    min: f64,
537    max: f64,
538    step: f64,
539) -> f64 {
540    let ratio = match orientation {
541        RangeOrientation::Horizontal => {
542            let width = f64::from(bounds.size.width).max(f64::EPSILON);
543            let ratio =
544                ((f64::from(position.x) - f64::from(bounds.origin.x)) / width).clamp(0.0, 1.0);
545            if direction == TextDirection::RightToLeft {
546                1.0 - ratio
547            } else {
548                ratio
549            }
550        }
551        RangeOrientation::Vertical => {
552            let height = f64::from(bounds.size.height).max(f64::EPSILON);
553            1.0 - ((f64::from(position.y) - f64::from(bounds.origin.y)) / height).clamp(0.0, 1.0)
554        }
555    };
556    normalize_value(min + ratio * (max - min), min, max, step)
557}
558
559/// Build the compile-time generic range-input primitive schema.
560///
561/// # Panics
562///
563/// Panics only if the static built-in primitive ID becomes invalid.
564#[must_use]
565pub fn range_input_primitive_descriptor() -> PrimitiveDescriptor {
566    PrimitiveDescriptor {
567        id: PrimitiveId::parse("gpui_rhai.range_input").expect("static primitive ID"),
568        export: "RangeInputPrimitive".to_owned(),
569        props: BTreeMap::from([
570            (
571                "value".to_owned(),
572                ObjectField::required(ValueSchema::number()),
573            ),
574            (
575                "min".to_owned(),
576                ObjectField::required(ValueSchema::number()),
577            ),
578            (
579                "max".to_owned(),
580                ObjectField::required(ValueSchema::number()),
581            ),
582            (
583                "step".to_owned(),
584                ObjectField::required(ValueSchema::number()),
585            ),
586            (
587                "orientation".to_owned(),
588                ObjectField::required(ValueSchema::String {
589                    allowed: vec!["horizontal".to_owned(), "vertical".to_owned()],
590                }),
591            ),
592            (
593                "disabled".to_owned(),
594                ObjectField::optional(ValueSchema::Bool).with_default(UiValue::Bool(false)),
595            ),
596            (
597                "track_style".to_owned(),
598                ObjectField::required(ValueSchema::Style),
599            ),
600            (
601                "fill_style".to_owned(),
602                ObjectField::required(ValueSchema::Style),
603            ),
604            (
605                "thumb_style".to_owned(),
606                ObjectField::required(ValueSchema::Style),
607            ),
608            (
609                "on_change".to_owned(),
610                ObjectField::optional(ValueSchema::optional(ValueSchema::Callback)),
611            ),
612        ]),
613        events: BTreeMap::from([(
614            "change".to_owned(),
615            EventSchema {
616                payload: ValueSchema::number(),
617            },
618        )]),
619        state: ComponentStateSchema::default(),
620        lifecycle: true,
621        effect: None,
622    }
623}
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628
629    #[test]
630    fn range_values_clamp_and_snap_to_step() {
631        assert!(normalize_value(-2.0, 0.0, 10.0, 0.5).abs() < f64::EPSILON);
632        assert!((normalize_value(3.26, 0.0, 10.0, 0.5) - 3.5).abs() < f64::EPSILON);
633        assert!((normalize_value(12.0, 0.0, 10.0, 0.5) - 10.0).abs() < f64::EPSILON);
634    }
635
636    #[test]
637    fn horizontal_rtl_geometry_keeps_the_fill_between_minimum_and_thumb() {
638        let ratio = 0.2;
639        let ltr_thumb = horizontal_thumb_ratio(ratio, TextDirection::LeftToRight);
640        let rtl_thumb = horizontal_thumb_ratio(ratio, TextDirection::RightToLeft);
641        assert!((ltr_thumb - 0.2_f64).abs() < f64::EPSILON);
642        assert!((rtl_thumb - 0.8_f64).abs() < f64::EPSILON);
643        // The LTR fill occupies [0, thumb]. The RTL fill is right-anchored and
644        // occupies [thumb, 1], so both represent the same semantic value.
645        assert!((ratio - (1.0 - rtl_thumb)).abs() < f64::EPSILON);
646    }
647}