1use 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#[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}