Skip to main content

gpui_rhai/
selection_area.rs

1//! Controlled Canvas object selection with native click/range/marquee preview.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use gpui::{
6    AnyElement, App, AppContext, Bounds, Context, Element, ElementId, Entity, FocusHandle,
7    GlobalElementId, InspectorElementId, InteractiveElement, IntoElement, KeyDownEvent, LayoutId,
8    Modifiers, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Render, Style, Styled,
9    Window, div, point, px, rgba, size,
10};
11
12use crate::{
13    ComponentStateSchema, EventSchema, GeometryBounds, ObjectField, PrimitiveContext,
14    PrimitiveDescriptor, PrimitiveHandler, PrimitiveId, PrimitiveInstance, PrimitiveInstanceId,
15    PrimitiveProps, PrimitiveTheme, Rgba8, UiValue, ValueSchema,
16};
17
18#[derive(Clone, Copy, Debug, Eq, PartialEq)]
19enum MarqueePolicy {
20    Intersect,
21    Enclose,
22}
23
24#[derive(Clone, Debug)]
25struct SelectionTarget {
26    key: String,
27    bounds: GeometryBounds,
28    disabled: bool,
29}
30
31#[derive(Clone)]
32struct SelectionConfig {
33    id: String,
34    targets: Vec<SelectionTarget>,
35    selected: BTreeSet<String>,
36    active: Option<String>,
37    anchor: Option<String>,
38    multiple: bool,
39    marquee: MarqueePolicy,
40    threshold: f64,
41    disabled: bool,
42    canvas_ref: crate::ElementRef,
43    focus: Option<FocusHandle>,
44    accent: Rgba8,
45}
46
47struct SelectionAreaEntity {
48    focus: FocusHandle,
49    config: SelectionConfig,
50    context: PrimitiveContext,
51    root_bounds: Option<Bounds<Pixels>>,
52    marquee_window: Option<Bounds<Pixels>>,
53}
54
55impl SelectionAreaEntity {
56    fn new(mut config: SelectionConfig, context: PrimitiveContext, cx: &mut Context<Self>) -> Self {
57        let focus = config.focus.clone().unwrap_or_else(|| cx.focus_handle());
58        config.focus = Some(focus.clone());
59        Self {
60            focus,
61            config,
62            context,
63            root_bounds: None,
64            marquee_window: None,
65        }
66    }
67
68    fn update(&mut self, mut config: SelectionConfig, context: PrimitiveContext) {
69        config.focus = Some(self.focus.clone());
70        self.config = config;
71        self.context = context;
72    }
73
74    fn local_point(&self, point: Point<Pixels>, cx: &App) -> Option<(f64, f64)> {
75        self.context
76            .canvas_local_point(&self.config.canvas_ref, point, cx)
77    }
78
79    fn mouse_down(&mut self, event: &MouseDownEvent, window: &mut Window, cx: &mut Context<Self>) {
80        if self.config.disabled || event.button != MouseButton::Left {
81            return;
82        }
83        let Some(local_start) = self.local_point(event.position, cx) else {
84            return;
85        };
86        self.focus.focus(window, cx);
87        let start_window = event.position;
88        let modifiers = event.modifiers;
89        let entity = cx.entity();
90        let update_entity = entity.clone();
91        let update = move |gesture: crate::interaction::GestureUpdate,
92                           _: &mut Window,
93                           cx: &mut App| {
94            update_entity.update(cx, |selection, cx| {
95                if gesture.moved() {
96                    selection.marquee_window = Some(pixel_bounds(start_window, gesture.current()));
97                    cx.notify();
98                }
99            });
100            crate::interaction::InteractionFlow::Continue
101        };
102        let finish_entity = entity.clone();
103        let finish =
104            move |gesture: crate::interaction::GestureUpdate, window: &mut Window, cx: &mut App| {
105                finish_entity.update(cx, |selection, cx| {
106                    let proposal = if gesture.moved() {
107                        let marquee = pixel_bounds(start_window, gesture.current());
108                        marquee_proposal(
109                            &selection.config,
110                            &window_quad(start_window, gesture.current())
111                                .into_iter()
112                                .filter_map(|point| selection.local_point(point, cx))
113                                .collect::<Vec<_>>(),
114                            marquee.size.width > px(0.0) && marquee.size.height > px(0.0),
115                            modifiers,
116                        )
117                    } else {
118                        click_proposal(&selection.config, local_start, modifiers)
119                    };
120                    selection.marquee_window = None;
121                    if let Some(proposal) = proposal {
122                        selection.context.propose(
123                            "selection_change",
124                            proposal_value(proposal),
125                            window,
126                            cx,
127                        );
128                    }
129                    cx.notify();
130                });
131            };
132        let cancel = move |_: &mut Window, cx: &mut App| {
133            entity.update(cx, |selection, cx| {
134                selection.marquee_window = None;
135                cx.notify();
136            });
137        };
138        let owner = self.context.interaction_owner(&self.config.id);
139        self.context.begin_interaction(
140            crate::interaction::NativeGesture::new(
141                owner,
142                event.position,
143                cx.entity_id(),
144                update,
145                finish,
146                cancel,
147            )
148            .with_threshold(self.config.threshold),
149            window,
150            cx,
151        );
152        cx.stop_propagation();
153    }
154
155    fn key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
156        if self.config.disabled {
157            return;
158        }
159        let eligible = self
160            .config
161            .targets
162            .iter()
163            .filter(|target| !target.disabled)
164            .map(|target| target.key.as_str())
165            .collect::<Vec<_>>();
166        if eligible.is_empty() {
167            return;
168        }
169        let current = self
170            .config
171            .active
172            .as_deref()
173            .and_then(|active| eligible.iter().position(|key| *key == active))
174            .unwrap_or(0);
175        let next = match event.keystroke.key.as_str() {
176            "left" | "up" => current.saturating_sub(1),
177            "right" | "down" => (current + 1).min(eligible.len() - 1),
178            "home" => 0,
179            "end" => eligible.len() - 1,
180            "space" => {
181                let key = eligible[current];
182                let mut selected = if self.config.multiple {
183                    self.config.selected.clone()
184                } else {
185                    BTreeSet::new()
186                };
187                if self.config.multiple && !selected.insert(key.to_owned()) {
188                    selected.remove(key);
189                } else {
190                    selected.insert(key.to_owned());
191                }
192                self.emit(
193                    selection_proposal(selected, Some(key.to_owned()), self.config.anchor.clone()),
194                    window,
195                    cx,
196                );
197                cx.stop_propagation();
198                return;
199            }
200            _ => return,
201        };
202        let key = eligible[next].to_owned();
203        let proposal = if event.keystroke.modifiers.shift && self.config.multiple {
204            range_proposal(&self.config, &key)
205        } else {
206            selection_proposal(
207                BTreeSet::from([key.clone()]),
208                Some(key.clone()),
209                Some(key.clone()),
210            )
211        };
212        self.emit(proposal, window, cx);
213        cx.stop_propagation();
214    }
215
216    fn emit(&self, proposal: SelectionProposal, window: &mut Window, cx: &mut App) {
217        if proposal.selected != self.config.selected
218            || proposal.active != self.config.active
219            || proposal.anchor != self.config.anchor
220        {
221            self.context
222                .propose("selection_change", proposal_value(proposal), window, cx);
223        }
224    }
225}
226
227impl Render for SelectionAreaEntity {
228    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
229        let owner = self.context.interaction_owner(&self.config.id);
230        self.context.present_interaction(owner.clone());
231        if self.config.disabled {
232            self.context.cancel_interaction(&owner, window, cx);
233        }
234        let mut root = div()
235            .size_full()
236            .relative()
237            .track_focus(&self.focus.clone().tab_stop(!self.config.disabled))
238            .on_mouse_down(MouseButton::Left, cx.listener(Self::mouse_down))
239            .on_key_down(cx.listener(Self::key_down));
240        if let (Some(marquee), Some(root_bounds)) = (self.marquee_window, self.root_bounds) {
241            let color = self.config.accent.as_rgba_hex();
242            root = root.child(
243                div()
244                    .absolute()
245                    .left(marquee.left() - root_bounds.left())
246                    .top(marquee.top() - root_bounds.top())
247                    .w(marquee.size.width)
248                    .h(marquee.size.height)
249                    .bg(rgba((color & 0xffff_ff00) | 0x20))
250                    .border_1()
251                    .border_color(rgba(color)),
252            );
253        }
254        root.child(SelectionBoundsRecorder {
255            selection: cx.entity(),
256        })
257    }
258}
259
260struct SelectionBoundsRecorder {
261    selection: Entity<SelectionAreaEntity>,
262}
263
264impl Element for SelectionBoundsRecorder {
265    type RequestLayoutState = ();
266    type PrepaintState = ();
267
268    fn id(&self) -> Option<ElementId> {
269        None
270    }
271
272    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
273        None
274    }
275
276    fn request_layout(
277        &mut self,
278        _: Option<&GlobalElementId>,
279        _: Option<&InspectorElementId>,
280        window: &mut Window,
281        cx: &mut App,
282    ) -> (LayoutId, ()) {
283        (window.request_layout(Style::default(), None, cx), ())
284    }
285
286    fn prepaint(
287        &mut self,
288        _: Option<&GlobalElementId>,
289        _: Option<&InspectorElementId>,
290        bounds: Bounds<Pixels>,
291        (): &mut (),
292        _: &mut Window,
293        cx: &mut App,
294    ) {
295        self.selection
296            .update(cx, |selection, _| selection.root_bounds = Some(bounds));
297    }
298
299    fn paint(
300        &mut self,
301        _: Option<&GlobalElementId>,
302        _: Option<&InspectorElementId>,
303        _: Bounds<Pixels>,
304        (): &mut (),
305        (): &mut (),
306        _: &mut Window,
307        _: &mut App,
308    ) {
309    }
310}
311
312impl IntoElement for SelectionBoundsRecorder {
313    type Element = Self;
314
315    fn into_element(self) -> Self::Element {
316        self
317    }
318}
319
320#[derive(Default)]
321pub struct SelectionAreaPrimitiveHandler {
322    instances: BTreeMap<PrimitiveInstanceId, Entity<SelectionAreaEntity>>,
323}
324
325impl PrimitiveHandler for SelectionAreaPrimitiveHandler {
326    fn uses_primary_focus(&self) -> bool {
327        true
328    }
329
330    fn render(
331        &mut self,
332        instance: &PrimitiveInstance,
333        context: &PrimitiveContext,
334        theme: &PrimitiveTheme,
335        _: &mut Window,
336        cx: &mut App,
337    ) -> Result<AnyElement, String> {
338        let id = instance
339            .id
340            .clone()
341            .ok_or_else(|| "SelectionAreaPrimitive requires a stable key".to_owned())?;
342        let config = parse_config(
343            &instance.node.props,
344            instance.focus_handle().cloned(),
345            theme,
346        )?;
347        let entity = if let Some(entity) = self.instances.get(&id) {
348            entity.clone()
349        } else {
350            let entity = cx.new(|cx| SelectionAreaEntity::new(config.clone(), context.clone(), cx));
351            self.instances.insert(id.clone(), entity.clone());
352            entity
353        };
354        entity.update(cx, |selection, _| selection.update(config, context.clone()));
355        Ok(entity.into_any_element())
356    }
357
358    fn unmount(&mut self, instance: &PrimitiveInstanceId) {
359        self.instances.remove(instance);
360    }
361}
362
363#[derive(Clone, Debug, Eq, PartialEq)]
364struct SelectionProposal {
365    selected: BTreeSet<String>,
366    active: Option<String>,
367    anchor: Option<String>,
368}
369
370fn selection_proposal(
371    selected: BTreeSet<String>,
372    active: Option<String>,
373    anchor: Option<String>,
374) -> SelectionProposal {
375    SelectionProposal {
376        selected,
377        active,
378        anchor,
379    }
380}
381
382fn click_proposal(
383    config: &SelectionConfig,
384    point: (f64, f64),
385    modifiers: Modifiers,
386) -> Option<SelectionProposal> {
387    let hit = config
388        .targets
389        .iter()
390        .rev()
391        .find(|target| !target.disabled && contains(target.bounds, point));
392    let Some(hit) = hit else {
393        return (!config.selected.is_empty() || config.active.is_some())
394            .then(|| selection_proposal(BTreeSet::new(), None, None));
395    };
396    if modifiers.shift && config.multiple {
397        return Some(range_proposal(config, &hit.key));
398    }
399    let toggle = (modifiers.platform || modifiers.control) && config.multiple;
400    let mut selected = if toggle {
401        config.selected.clone()
402    } else {
403        BTreeSet::new()
404    };
405    if toggle && selected.contains(&hit.key) {
406        selected.remove(&hit.key);
407    } else {
408        selected.insert(hit.key.clone());
409    }
410    Some(selection_proposal(
411        selected,
412        Some(hit.key.clone()),
413        Some(hit.key.clone()),
414    ))
415}
416
417fn range_proposal(config: &SelectionConfig, key: &str) -> SelectionProposal {
418    let anchor = config.anchor.as_deref().unwrap_or(key);
419    let positions = config
420        .targets
421        .iter()
422        .enumerate()
423        .map(|(index, target)| (target.key.as_str(), index))
424        .collect::<BTreeMap<_, _>>();
425    let Some(&start) = positions.get(anchor) else {
426        return selection_proposal(
427            BTreeSet::from([key.to_owned()]),
428            Some(key.to_owned()),
429            Some(key.to_owned()),
430        );
431    };
432    let Some(&end) = positions.get(key) else {
433        return selection_proposal(
434            config.selected.clone(),
435            config.active.clone(),
436            config.anchor.clone(),
437        );
438    };
439    let (start, end) = (start.min(end), start.max(end));
440    let selected = config.targets[start..=end]
441        .iter()
442        .filter(|target| !target.disabled)
443        .map(|target| target.key.clone())
444        .collect();
445    selection_proposal(selected, Some(key.to_owned()), Some(anchor.to_owned()))
446}
447
448fn marquee_proposal(
449    config: &SelectionConfig,
450    polygon: &[(f64, f64)],
451    has_window_area: bool,
452    modifiers: Modifiers,
453) -> Option<SelectionProposal> {
454    if polygon.len() != 4 || !has_window_area {
455        return None;
456    }
457    let mut hits = config
458        .targets
459        .iter()
460        .filter(|target| !target.disabled)
461        .filter(|target| match config.marquee {
462            MarqueePolicy::Intersect => polygon_intersects_rect(polygon, target.bounds),
463            MarqueePolicy::Enclose => rect_inside_polygon(target.bounds, polygon),
464        })
465        .map(|target| target.key.clone())
466        .collect::<BTreeSet<_>>();
467    if !config.multiple
468        && hits.len() > 1
469        && let Some(key) = config
470            .targets
471            .iter()
472            .rev()
473            .find(|target| hits.contains(&target.key))
474            .map(|target| target.key.clone())
475    {
476        hits = BTreeSet::from([key]);
477    }
478    let additive = config.multiple && (modifiers.shift || modifiers.platform || modifiers.control);
479    let selected = if additive {
480        config.selected.union(&hits).cloned().collect()
481    } else {
482        hits
483    };
484    let active = config
485        .targets
486        .iter()
487        .rev()
488        .find(|target| selected.contains(&target.key))
489        .map(|target| target.key.clone());
490    let proposal = selection_proposal(selected, active, config.anchor.clone());
491    (proposal.selected != config.selected || proposal.active != config.active).then_some(proposal)
492}
493
494fn window_quad(start: Point<Pixels>, end: Point<Pixels>) -> [Point<Pixels>; 4] {
495    let left = start.x.min(end.x);
496    let right = start.x.max(end.x);
497    let top = start.y.min(end.y);
498    let bottom = start.y.max(end.y);
499    [
500        point(left, top),
501        point(right, top),
502        point(right, bottom),
503        point(left, bottom),
504    ]
505}
506
507fn rect_corners(bounds: GeometryBounds) -> [(f64, f64); 4] {
508    [
509        (bounds.x, bounds.y),
510        (bounds.x + bounds.width, bounds.y),
511        (bounds.x + bounds.width, bounds.y + bounds.height),
512        (bounds.x, bounds.y + bounds.height),
513    ]
514}
515
516fn point_in_polygon(point: (f64, f64), polygon: &[(f64, f64)]) -> bool {
517    let Some(anchor) = polygon.first() else {
518        return false;
519    };
520    let twice_area = (0..polygon.len())
521        .map(|index| {
522            let next = (index + 1) % polygon.len();
523            let a = (polygon[index].0 - anchor.0, polygon[index].1 - anchor.1);
524            let b = (polygon[next].0 - anchor.0, polygon[next].1 - anchor.1);
525            a.0.mul_add(b.1, -b.0 * a.1)
526        })
527        .sum::<f64>();
528    let coordinate_scale = polygon
529        .iter()
530        .flat_map(|(x, y)| [(x - anchor.0).abs(), (y - anchor.1).abs()])
531        .fold(0.0_f64, f64::max)
532        .max(f64::MIN_POSITIVE);
533    let point_count = f64::from(u32::try_from(polygon.len()).unwrap_or(u32::MAX));
534    let area_epsilon = f64::EPSILON * coordinate_scale * coordinate_scale * point_count * 16.0;
535    if twice_area.abs() <= area_epsilon {
536        return false;
537    }
538    let mut sign = 0.0_f64;
539    for index in 0..polygon.len() {
540        let a = polygon[index];
541        let b = polygon[(index + 1) % polygon.len()];
542        let cross = (b.0 - a.0).mul_add(point.1 - a.1, -(b.1 - a.1) * (point.0 - a.0));
543        let cross_epsilon = f64::EPSILON
544            * ((b.0 - a.0).abs() * (point.1 - a.1).abs()
545                + (b.1 - a.1).abs() * (point.0 - a.0).abs())
546            * 16.0;
547        if cross.abs() <= cross_epsilon {
548            continue;
549        }
550        if sign == 0.0 {
551            sign = cross.signum();
552        } else if sign * cross < 0.0 {
553            return false;
554        }
555    }
556    true
557}
558
559fn segments_intersect(a: (f64, f64), b: (f64, f64), c: (f64, f64), d: (f64, f64)) -> bool {
560    fn orientation(a: (f64, f64), b: (f64, f64), c: (f64, f64)) -> f64 {
561        let lhs = (b.0 - a.0) * (c.1 - a.1);
562        let rhs = (b.1 - a.1) * (c.0 - a.0);
563        let cross = (b.0 - a.0).mul_add(c.1 - a.1, -rhs);
564        if cross.abs() <= f64::EPSILON * (lhs.abs() + rhs.abs()) * 16.0 {
565            0.0
566        } else {
567            cross
568        }
569    }
570    let (o1, o2, o3, o4) = (
571        orientation(a, b, c),
572        orientation(a, b, d),
573        orientation(c, d, a),
574        orientation(c, d, b),
575    );
576    let on_segment = |a: (f64, f64), b: (f64, f64), point: (f64, f64)| {
577        let epsilon = f64::EPSILON * (a.0 - b.0).abs().max((a.1 - b.1).abs()) * 16.0;
578        point.0 >= a.0.min(b.0) - epsilon
579            && point.0 <= a.0.max(b.0) + epsilon
580            && point.1 >= a.1.min(b.1) - epsilon
581            && point.1 <= a.1.max(b.1) + epsilon
582    };
583    (o1 == 0.0 && on_segment(a, b, c))
584        || (o2 == 0.0 && on_segment(a, b, d))
585        || (o3 == 0.0 && on_segment(c, d, a))
586        || (o4 == 0.0 && on_segment(c, d, b))
587        || ((o1 > 0.0 && o2 < 0.0 || o1 < 0.0 && o2 > 0.0)
588            && (o3 > 0.0 && o4 < 0.0 || o3 < 0.0 && o4 > 0.0))
589}
590
591fn rect_inside_polygon(bounds: GeometryBounds, polygon: &[(f64, f64)]) -> bool {
592    rect_corners(bounds)
593        .into_iter()
594        .all(|corner| point_in_polygon(corner, polygon))
595}
596
597fn polygon_intersects_rect(polygon: &[(f64, f64)], bounds: GeometryBounds) -> bool {
598    let corners = rect_corners(bounds);
599    corners
600        .iter()
601        .copied()
602        .any(|corner| point_in_polygon(corner, polygon))
603        || polygon.iter().copied().any(|point| contains(bounds, point))
604        || (0..polygon.len()).any(|polygon_edge| {
605            (0..corners.len()).any(|rect_edge| {
606                segments_intersect(
607                    polygon[polygon_edge],
608                    polygon[(polygon_edge + 1) % polygon.len()],
609                    corners[rect_edge],
610                    corners[(rect_edge + 1) % corners.len()],
611                )
612            })
613        })
614}
615
616fn pixel_bounds(start: Point<Pixels>, end: Point<Pixels>) -> Bounds<Pixels> {
617    Bounds::new(
618        point(start.x.min(end.x), start.y.min(end.y)),
619        size((start.x - end.x).abs(), (start.y - end.y).abs()),
620    )
621}
622
623fn contains(bounds: GeometryBounds, point: (f64, f64)) -> bool {
624    point.0 >= bounds.x
625        && point.0 <= bounds.x + bounds.width
626        && point.1 >= bounds.y
627        && point.1 <= bounds.y + bounds.height
628}
629
630fn parse_config(
631    props: &PrimitiveProps,
632    focus: Option<FocusHandle>,
633    theme: &PrimitiveTheme,
634) -> Result<SelectionConfig, String> {
635    let targets = parse_targets(props)?;
636    let selected = string_set(props, "selected_keys")?;
637    let active = optional_string(props, "active_key")?;
638    let anchor = optional_string(props, "anchor_key")?;
639    if selected
640        .iter()
641        .any(|key| !targets.iter().any(|target| &target.key == key))
642        || active
643            .as_ref()
644            .is_some_and(|key| !targets.iter().any(|target| &target.key == key))
645        || anchor
646            .as_ref()
647            .is_some_and(|key| !targets.iter().any(|target| &target.key == key))
648    {
649        return Err("selection keys must reference declared targets".to_owned());
650    }
651    let marquee = match props.string("marquee") {
652        None | Some("intersect") => MarqueePolicy::Intersect,
653        Some("enclose") => MarqueePolicy::Enclose,
654        Some(_) => return Err("selection marquee must be intersect or enclose".to_owned()),
655    };
656    let threshold = props.number("threshold").unwrap_or(4.0);
657    if !threshold.is_finite() || !(0.0..=64.0).contains(&threshold) {
658        return Err("selection threshold must be finite and in [0,64]".to_owned());
659    }
660    let canvas_ref = props
661        .element_ref("canvas_ref")
662        .cloned()
663        .ok_or_else(|| "selection canvas_ref is required".to_owned())?;
664    Ok(SelectionConfig {
665        id: format!(
666            "gpui-rhai-selection:{}:{}",
667            canvas_ref.id().component(),
668            canvas_ref.id().key()
669        ),
670        targets,
671        selected,
672        active,
673        anchor,
674        multiple: props.boolean("multiple").unwrap_or(true),
675        marquee,
676        threshold,
677        disabled: props.boolean("disabled").unwrap_or(false),
678        canvas_ref,
679        focus,
680        accent: theme
681            .color("accent")
682            .unwrap_or(Rgba8::from_rgba_hex(0x3b82_f6ff)),
683    })
684}
685
686fn parse_targets(props: &PrimitiveProps) -> Result<Vec<SelectionTarget>, String> {
687    let Some(UiValue::Array(values)) = props.data("targets") else {
688        return Err("selection targets must be an array".to_owned());
689    };
690    if values.len() > 10_000 {
691        return Err("selection targets exceed 10000".to_owned());
692    }
693    let mut keys = BTreeSet::new();
694    values
695        .iter()
696        .map(|value| {
697            let UiValue::Map(value) = value else {
698                return Err("selection target must be an object".to_owned());
699            };
700            let key = value
701                .get("key")
702                .and_then(ui_string)
703                .filter(|key| !key.is_empty() && key.len() <= 128)
704                .ok_or_else(|| "selection target key is invalid".to_owned())?
705                .to_owned();
706            if !keys.insert(key.clone()) {
707                return Err(format!("duplicate selection target `{key}`"));
708            }
709            let number = |name: &str| {
710                value
711                    .get(name)
712                    .and_then(ui_number)
713                    .ok_or_else(|| format!("selection target {name} is required"))
714            };
715            Ok(SelectionTarget {
716                key,
717                bounds: GeometryBounds::new(
718                    number("x")?,
719                    number("y")?,
720                    number("width")?,
721                    number("height")?,
722                )
723                .map_err(|error| error.to_string())?,
724                disabled: value.get("disabled").and_then(ui_bool).unwrap_or(false),
725            })
726        })
727        .collect()
728}
729
730fn string_set(props: &PrimitiveProps, name: &str) -> Result<BTreeSet<String>, String> {
731    let Some(UiValue::Array(values)) = props.data(name) else {
732        return Err(format!("{name} must be an array"));
733    };
734    values
735        .iter()
736        .map(|value| {
737            ui_string(value)
738                .map(str::to_owned)
739                .ok_or_else(|| format!("{name} entries must be strings"))
740        })
741        .collect()
742}
743
744fn ui_string(value: &UiValue) -> Option<&str> {
745    match value {
746        UiValue::String(value) => Some(value),
747        _ => None,
748    }
749}
750
751fn ui_number(value: &UiValue) -> Option<f64> {
752    match value {
753        UiValue::Float(value) => Some(*value),
754        UiValue::Integer(value) => value.to_string().parse().ok(),
755        _ => None,
756    }
757}
758
759fn ui_bool(value: &UiValue) -> Option<bool> {
760    match value {
761        UiValue::Bool(value) => Some(*value),
762        _ => None,
763    }
764}
765
766fn optional_string(props: &PrimitiveProps, name: &str) -> Result<Option<String>, String> {
767    match props.data(name) {
768        None | Some(UiValue::Null) => Ok(None),
769        Some(UiValue::String(value)) => Ok(Some(value.clone())),
770        _ => Err(format!("{name} must be null or a string")),
771    }
772}
773
774fn proposal_value(proposal: SelectionProposal) -> UiValue {
775    UiValue::Map(BTreeMap::from([
776        (
777            "selected_keys".to_owned(),
778            UiValue::Array(proposal.selected.into_iter().map(UiValue::String).collect()),
779        ),
780        (
781            "active_key".to_owned(),
782            proposal.active.map_or(UiValue::Null, UiValue::String),
783        ),
784        (
785            "anchor_key".to_owned(),
786            proposal.anchor.map_or(UiValue::Null, UiValue::String),
787        ),
788    ]))
789}
790
791fn proposal_schema() -> ValueSchema {
792    ValueSchema::object(BTreeMap::from([
793        (
794            "selected_keys".to_owned(),
795            ObjectField::required(ValueSchema::Array {
796                items: Box::new(ValueSchema::string()),
797                max_items: Some(10_000),
798            }),
799        ),
800        (
801            "active_key".to_owned(),
802            ObjectField::required(ValueSchema::optional(ValueSchema::string())),
803        ),
804        (
805            "anchor_key".to_owned(),
806            ObjectField::required(ValueSchema::optional(ValueSchema::string())),
807        ),
808    ]))
809}
810
811/// Build the native controlled Canvas selection schema.
812///
813/// # Panics
814///
815/// Panics only if the static primitive ID becomes invalid.
816#[must_use]
817pub fn selection_area_primitive_descriptor() -> PrimitiveDescriptor {
818    PrimitiveDescriptor {
819        id: PrimitiveId::parse("gpui_rhai.selection_area").expect("static primitive ID"),
820        export: "SelectionAreaPrimitive".to_owned(),
821        props: BTreeMap::from([
822            (
823                "targets".to_owned(),
824                ObjectField::required(ValueSchema::Array {
825                    items: Box::new(ValueSchema::Map {
826                        values: Box::new(ValueSchema::UiValue),
827                    }),
828                    max_items: Some(10_000),
829                }),
830            ),
831            (
832                "selected_keys".to_owned(),
833                ObjectField::required(ValueSchema::Array {
834                    items: Box::new(ValueSchema::string()),
835                    max_items: Some(10_000),
836                }),
837            ),
838            (
839                "active_key".to_owned(),
840                ObjectField::required(ValueSchema::optional(ValueSchema::string())),
841            ),
842            (
843                "anchor_key".to_owned(),
844                ObjectField::required(ValueSchema::optional(ValueSchema::string())),
845            ),
846            (
847                "multiple".to_owned(),
848                ObjectField::optional(ValueSchema::Bool).with_default(UiValue::Bool(true)),
849            ),
850            (
851                "marquee".to_owned(),
852                ObjectField::optional(ValueSchema::String {
853                    allowed: vec!["intersect".to_owned(), "enclose".to_owned()],
854                })
855                .with_default(UiValue::String("intersect".to_owned())),
856            ),
857            (
858                "threshold".to_owned(),
859                ObjectField::optional(ValueSchema::bounded_number(Some(0.0), Some(64.0))),
860            ),
861            (
862                "disabled".to_owned(),
863                ObjectField::optional(ValueSchema::Bool).with_default(UiValue::Bool(false)),
864            ),
865            (
866                "canvas_ref".to_owned(),
867                ObjectField::required(ValueSchema::Ref),
868            ),
869            (
870                "on_selection_change".to_owned(),
871                ObjectField::optional(ValueSchema::optional(ValueSchema::Callback)),
872            ),
873        ]),
874        events: BTreeMap::from([(
875            "selection_change".to_owned(),
876            EventSchema {
877                payload: proposal_schema(),
878            },
879        )]),
880        state: ComponentStateSchema::default(),
881        lifecycle: true,
882        effect: None,
883    }
884}
885
886#[cfg(test)]
887mod tests {
888    use super::*;
889
890    #[test]
891    fn marquee_policies_distinguish_intersection_and_enclosure() {
892        let marquee = vec![(0.0, 0.0), (20.0, 0.0), (20.0, 20.0), (0.0, 20.0)];
893        let crossing = GeometryBounds::new(15.0, 15.0, 20.0, 20.0).unwrap();
894        assert!(polygon_intersects_rect(&marquee, crossing));
895        assert!(!rect_inside_polygon(crossing, &marquee));
896    }
897
898    #[test]
899    fn degenerate_marquee_does_not_contain_far_collinear_points() {
900        let line = vec![(5.0, 10.0), (50.0, 10.0), (50.0, 10.0), (5.0, 10.0)];
901        assert!(!point_in_polygon((100.0, 10.0), &line));
902        assert!(!polygon_intersects_rect(
903            &line,
904            GeometryBounds::new(95.0, 5.0, 10.0, 10.0).unwrap()
905        ));
906    }
907
908    #[test]
909    fn polygon_predicates_preserve_translation_and_scale() {
910        for origin in [0.0, 499.0, 1000.0] {
911            for scale in [1.0, 1e-4, 1e-6] {
912                let polygon = vec![
913                    (origin, origin),
914                    (origin + 20.0 * scale, origin),
915                    (origin + 20.0 * scale, origin + 20.0 * scale),
916                    (origin, origin + 20.0 * scale),
917                ];
918                let inside = GeometryBounds::new(
919                    origin + 5.0 * scale,
920                    origin + 5.0 * scale,
921                    5.0 * scale,
922                    5.0 * scale,
923                )
924                .unwrap();
925                let outside = GeometryBounds::new(
926                    origin + 25.0 * scale,
927                    origin + 5.0 * scale,
928                    5.0 * scale,
929                    5.0 * scale,
930                )
931                .unwrap();
932                assert!(rect_inside_polygon(inside, &polygon));
933                assert!(polygon_intersects_rect(&polygon, inside));
934                assert!(!polygon_intersects_rect(&polygon, outside));
935            }
936        }
937    }
938}