1use 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#[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 assert!((ratio - (1.0 - rtl_thumb)).abs() < f64::EPSILON);
646 }
647}