1use std::cell::RefCell;
4use std::collections::{BTreeMap, BTreeSet};
5use std::rc::Rc;
6
7use gpui::{
8 AnyElement, App, AppContext, Background, Bounds, Context, DispatchPhase, Edges, Element,
9 ElementId, Entity, FocusHandle, GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId,
10 InteractiveElement, IntoElement, KeyDownEvent, LayoutId, MouseButton, MouseDownEvent,
11 PaintQuad, ParentElement, Pixels, Point, Render, Style, Styled, Window, div, point, px,
12 relative, rgba, size,
13};
14
15use crate::{
16 ComponentStateSchema, EventSchema, GeometryBounds, ObjectField, PrimitiveContext,
17 PrimitiveDescriptor, PrimitiveHandler, PrimitiveId, PrimitiveInstance, PrimitiveInstanceId,
18 PrimitiveProps, PrimitiveTheme, Rgba8, UiValue, ValueSchema,
19};
20
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22enum SortDirection {
23 Vertical,
24 Horizontal,
25 Grid,
26}
27
28impl SortDirection {
29 fn parse(value: &str) -> Option<Self> {
30 match value {
31 "vertical" => Some(Self::Vertical),
32 "horizontal" => Some(Self::Horizontal),
33 "grid" => Some(Self::Grid),
34 _ => None,
35 }
36 }
37
38 const fn uses_vertical_targets(self) -> bool {
39 matches!(self, Self::Vertical)
40 }
41}
42
43#[derive(Clone, Copy, Debug, Eq, PartialEq)]
44enum Placement {
45 Before,
46 After,
47}
48
49impl Placement {
50 const fn as_str(self) -> &'static str {
51 match self {
52 Self::Before => "before",
53 Self::After => "after",
54 }
55 }
56}
57
58#[derive(Clone)]
59struct SortableConfig {
60 id: String,
61 collection: String,
62 item_key: String,
63 source_index: Option<usize>,
64 source_snapshot: Option<UiValue>,
65 previous_key: Option<String>,
66 next_key: Option<String>,
67 first_key: String,
68 last_key: String,
69 direction: SortDirection,
70 threshold: f64,
71 disabled: bool,
72 item_ref: Option<crate::ElementRef>,
73 focus: Option<FocusHandle>,
74 accent: Rgba8,
75}
76
77#[derive(Clone, Debug, PartialEq)]
78struct SortableFingerprint {
79 collection: String,
80 item_key: String,
81 source_index: Option<usize>,
82 source_snapshot: Option<UiValue>,
83 previous_key: Option<String>,
84 next_key: Option<String>,
85 direction: SortDirection,
86 disabled: bool,
87}
88
89#[derive(Clone)]
90struct SortableState(Rc<RefCell<SortableFingerprint>>);
91
92impl SortableState {
93 fn new(config: &SortableConfig) -> Self {
94 Self(Rc::new(RefCell::new(sortable_fingerprint(config))))
95 }
96}
97
98fn sortable_fingerprint(config: &SortableConfig) -> SortableFingerprint {
99 SortableFingerprint {
100 collection: config.collection.clone(),
101 item_key: config.item_key.clone(),
102 source_index: config.source_index,
103 source_snapshot: config.source_snapshot.clone(),
104 previous_key: config.previous_key.clone(),
105 next_key: config.next_key.clone(),
106 direction: config.direction,
107 disabled: config.disabled,
108 }
109}
110
111struct SortablePrepaint {
112 hitbox: Hitbox,
113 before_hitbox: Hitbox,
114 after_hitbox: Hitbox,
115 item_bounds: Bounds<Pixels>,
116}
117
118struct SortableElement {
119 config: SortableConfig,
120 context: PrimitiveContext,
121}
122
123impl IntoElement for SortableElement {
124 type Element = Self;
125
126 fn into_element(self) -> Self::Element {
127 self
128 }
129}
130
131impl Element for SortableElement {
132 type RequestLayoutState = ();
133 type PrepaintState = SortablePrepaint;
134
135 fn id(&self) -> Option<ElementId> {
136 Some(ElementId::Name(self.config.id.clone().into()))
137 }
138
139 fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
140 None
141 }
142
143 fn request_layout(
144 &mut self,
145 _: Option<&GlobalElementId>,
146 _: Option<&InspectorElementId>,
147 window: &mut Window,
148 cx: &mut App,
149 ) -> (LayoutId, ()) {
150 (
151 window.request_layout(
152 Style {
153 size: size(relative(1.0).into(), relative(1.0).into()),
154 ..Style::default()
155 },
156 None,
157 cx,
158 ),
159 (),
160 )
161 }
162
163 fn prepaint(
164 &mut self,
165 _: Option<&GlobalElementId>,
166 _: Option<&InspectorElementId>,
167 bounds: Bounds<Pixels>,
168 (): &mut (),
169 window: &mut Window,
170 cx: &mut App,
171 ) -> SortablePrepaint {
172 let state = window
173 .use_state(cx, |_, _| SortableState::new(&self.config))
174 .read(cx)
175 .clone();
176 let changed = {
177 let next = sortable_fingerprint(&self.config);
178 let mut fingerprint = state.0.borrow_mut();
179 let changed = *fingerprint != next;
180 *fingerprint = next;
181 changed
182 };
183 let source = source_owner(&self.context, &self.config);
184 if changed {
185 self.context.cancel_interaction(&source, window, cx);
186 }
187 let item_bounds = self
188 .config
189 .item_ref
190 .as_ref()
191 .and_then(|reference| self.context.element_bounds(reference, cx))
192 .map_or(bounds, geometry_bounds);
193 SortablePrepaint {
194 hitbox: window.insert_hitbox(bounds, HitboxBehavior::Normal),
195 before_hitbox: window.insert_hitbox(
196 target_half(item_bounds, self.config.direction, Placement::Before),
197 HitboxBehavior::Normal,
198 ),
199 after_hitbox: window.insert_hitbox(
200 target_half(item_bounds, self.config.direction, Placement::After),
201 HitboxBehavior::Normal,
202 ),
203 item_bounds,
204 }
205 }
206
207 fn paint(
208 &mut self,
209 _: Option<&GlobalElementId>,
210 _: Option<&InspectorElementId>,
211 _: Bounds<Pixels>,
212 (): &mut (),
213 prepaint: &mut SortablePrepaint,
214 window: &mut Window,
215 _: &mut App,
216 ) {
217 let source = source_owner(&self.context, &self.config);
218 self.context.present_interaction(source.clone());
219 register_target(
220 &self.context,
221 &self.config,
222 prepaint.item_bounds,
223 Placement::Before,
224 prepaint.before_hitbox.clone(),
225 window,
226 );
227 register_target(
228 &self.context,
229 &self.config,
230 prepaint.item_bounds,
231 Placement::After,
232 prepaint.after_hitbox.clone(),
233 window,
234 );
235 paint_sortable_feedback(&self.context, &self.config, prepaint.item_bounds, window);
236 if !self.config.disabled {
237 register_pointer_source(prepaint, &self.config, &self.context, window);
238 }
239 }
240}
241
242fn geometry_bounds(bounds: GeometryBounds) -> Bounds<Pixels> {
243 Bounds::new(
244 point(px(f64_to_f32(bounds.x)), px(f64_to_f32(bounds.y))),
245 size(px(f64_to_f32(bounds.width)), px(f64_to_f32(bounds.height))),
246 )
247}
248
249fn source_owner(
250 context: &PrimitiveContext,
251 config: &SortableConfig,
252) -> crate::interaction::InteractionOwner {
253 context.interaction_owner(&config.id).child("source")
254}
255
256fn target_owner(
257 context: &PrimitiveContext,
258 config: &SortableConfig,
259 placement: Placement,
260) -> crate::interaction::InteractionOwner {
261 context
262 .interaction_owner(&config.id)
263 .child(placement.as_str())
264}
265
266fn payload_type(config: &SortableConfig) -> String {
267 format!("gpui-rhai/sortable/{}", config.collection)
268}
269
270fn target_id(config: &SortableConfig, anchor: &str, placement: Placement) -> String {
271 format!("{}:{}:{}", config.collection, placement.as_str(), anchor)
272}
273
274fn drag_spec(
275 context: &PrimitiveContext,
276 config: &SortableConfig,
277 notify: gpui::EntityId,
278) -> crate::interaction::ApplicationDragSpec {
279 crate::interaction::ApplicationDragSpec::new(
280 source_owner(context, config),
281 config.item_key.clone(),
282 payload_type(config),
283 UiValue::Map(BTreeMap::from([(
284 "key".to_owned(),
285 UiValue::String(config.item_key.clone()),
286 )])),
287 crate::interaction::DragOperation::Move,
288 notify,
289 )
290 .with_collection(
291 config.collection.clone(),
292 config.source_index,
293 config.source_snapshot.clone(),
294 )
295}
296
297fn target_half(
298 bounds: Bounds<Pixels>,
299 direction: SortDirection,
300 placement: Placement,
301) -> Bounds<Pixels> {
302 if direction.uses_vertical_targets() {
303 let half = bounds.size.height / 2.0;
304 Bounds::new(
305 point(
306 bounds.origin.x,
307 bounds.origin.y
308 + if placement == Placement::After {
309 half
310 } else {
311 px(0.0)
312 },
313 ),
314 size(bounds.size.width, half),
315 )
316 } else {
317 let half = bounds.size.width / 2.0;
318 Bounds::new(
319 point(
320 bounds.origin.x
321 + if placement == Placement::After {
322 half
323 } else {
324 px(0.0)
325 },
326 bounds.origin.y,
327 ),
328 size(half, bounds.size.height),
329 )
330 }
331}
332
333fn register_target(
334 context: &PrimitiveContext,
335 config: &SortableConfig,
336 bounds: Bounds<Pixels>,
337 placement: Placement,
338 hitbox: Hitbox,
339 window: &mut Window,
340) {
341 let half = target_half(bounds, config.direction, placement);
342 let Ok(bounds) = GeometryBounds::new(
343 f64::from(half.origin.x),
344 f64::from(half.origin.y),
345 f64::from(half.size.width),
346 f64::from(half.size.height),
347 ) else {
348 return;
349 };
350 let target_context = context.clone();
351 let anchor = config.item_key.clone();
352 let previous = config.previous_key.clone();
353 let next = config.next_key.clone();
354 context.register_drop_target(crate::interaction::DropTargetRegistration::new(
355 target_owner(context, config, placement),
356 target_id(config, &config.item_key, placement),
357 bounds,
358 BTreeSet::from([payload_type(config)]),
359 BTreeSet::from([crate::interaction::DragOperation::Move]),
360 100,
361 hitbox,
362 context.ancestor_scroll_handles(),
363 window.current_view(),
364 move |drag, position, window, cx| {
365 let source = drag.source_id();
366 let no_op = source == anchor
367 || match placement {
368 Placement::Before => previous.as_deref() == Some(source),
369 Placement::After => next.as_deref() == Some(source),
370 };
371 if no_op {
372 return;
373 }
374 target_context.propose(
375 "reorder",
376 reorder_value(source, &anchor, placement, position),
377 window,
378 cx,
379 );
380 },
381 ));
382}
383
384fn register_pointer_source(
385 prepaint: &SortablePrepaint,
386 config: &SortableConfig,
387 context: &PrimitiveContext,
388 window: &mut Window,
389) {
390 let hitbox = prepaint.hitbox.clone();
391 let config = config.clone();
392 let context = context.clone();
393 let view = window.current_view();
394 window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
395 if phase != DispatchPhase::Bubble
396 || event.button != MouseButton::Left
397 || !hitbox.is_hovered(window)
398 {
399 return;
400 }
401 if let Some(focus) = config.focus.as_ref() {
402 focus.focus(window, cx);
403 }
404 let spec = drag_spec(&context, &config, view);
405 context.begin_application_drag(
406 spec,
407 event.position,
408 config.threshold,
409 |_, _, _, _| {},
410 window,
411 cx,
412 );
413 cx.stop_propagation();
414 });
415}
416
417fn reorder_value(
418 source: &str,
419 anchor: &str,
420 placement: Placement,
421 position: Point<Pixels>,
422) -> UiValue {
423 UiValue::Map(BTreeMap::from([
424 ("source_key".to_owned(), UiValue::String(source.to_owned())),
425 ("anchor_key".to_owned(), UiValue::String(anchor.to_owned())),
426 (
427 "placement".to_owned(),
428 UiValue::String(placement.as_str().to_owned()),
429 ),
430 ("x".to_owned(), UiValue::Float(f64::from(position.x))),
431 ("y".to_owned(), UiValue::Float(f64::from(position.y))),
432 ]))
433}
434
435fn paint_sortable_feedback(
436 context: &PrimitiveContext,
437 config: &SortableConfig,
438 bounds: Bounds<Pixels>,
439 window: &mut Window,
440) {
441 if context.app_drag_source_active(&source_owner(context, config)) {
442 window.paint_quad(PaintQuad {
443 bounds,
444 corner_radii: px(0.0).into(),
445 background: Background::from(rgba((config.accent.as_rgba_hex() & 0xffff_ff00) | 0x12)),
446 border_widths: Edges::all(px(1.0)),
447 border_color: rgba(config.accent.as_rgba_hex()).into(),
448 border_style: gpui::BorderStyle::default(),
449 });
450 }
451 for placement in [Placement::Before, Placement::After] {
452 if context.drop_target_state(&target_owner(context, config, placement))
453 != crate::interaction::DropTargetState::Active
454 {
455 continue;
456 }
457 let indicator = if config.direction.uses_vertical_targets() {
458 let y = if placement == Placement::Before {
459 bounds.top()
460 } else {
461 bounds.bottom() - px(2.0)
462 };
463 Bounds::new(point(bounds.left(), y), size(bounds.size.width, px(2.0)))
464 } else {
465 let x = if placement == Placement::Before {
466 bounds.left()
467 } else {
468 bounds.right() - px(2.0)
469 };
470 Bounds::new(point(x, bounds.top()), size(px(2.0), bounds.size.height))
471 };
472 window.paint_quad(PaintQuad {
473 bounds: indicator,
474 corner_radii: px(0.0).into(),
475 background: Background::from(rgba(config.accent.as_rgba_hex())),
476 border_widths: Edges::all(px(0.0)),
477 border_color: rgba(0x0000_0000).into(),
478 border_style: gpui::BorderStyle::default(),
479 });
480 }
481}
482
483struct SortableEntity {
484 focus: FocusHandle,
485 config: SortableConfig,
486 context: PrimitiveContext,
487}
488
489impl SortableEntity {
490 fn new(mut config: SortableConfig, context: PrimitiveContext, cx: &mut Context<Self>) -> Self {
491 let focus = config.focus.clone().unwrap_or_else(|| cx.focus_handle());
492 config.focus = Some(focus.clone());
493 Self {
494 focus,
495 config,
496 context,
497 }
498 }
499
500 fn update(&mut self, mut config: SortableConfig, context: PrimitiveContext) {
501 config.focus = Some(self.focus.clone());
502 self.config = config;
503 self.context = context;
504 }
505
506 fn key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
507 if self.config.disabled || !event.keystroke.modifiers.alt {
508 return;
509 }
510 let target = match event.keystroke.key.as_str() {
511 "up" | "left" => self
512 .config
513 .previous_key
514 .as_ref()
515 .map(|anchor| (anchor, Placement::Before)),
516 "down" | "right" => self
517 .config
518 .next_key
519 .as_ref()
520 .map(|anchor| (anchor, Placement::After)),
521 "home" => Some((&self.config.first_key, Placement::Before)),
522 "end" => Some((&self.config.last_key, Placement::After)),
523 _ => return,
524 };
525 let Some((anchor, placement)) = target else {
526 cx.stop_propagation();
527 return;
528 };
529 if anchor != &self.config.item_key {
530 self.context.propose(
531 "reorder",
532 reorder_value(
533 &self.config.item_key,
534 anchor,
535 placement,
536 point(px(0.0), px(0.0)),
537 ),
538 window,
539 cx,
540 );
541 }
542 cx.stop_propagation();
543 }
544}
545
546impl Render for SortableEntity {
547 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
548 div()
549 .size_full()
550 .track_focus(&self.focus.clone().tab_stop(!self.config.disabled))
551 .on_key_down(cx.listener(Self::key_down))
552 .child(SortableElement {
553 config: self.config.clone(),
554 context: self.context.clone(),
555 })
556 }
557}
558
559#[derive(Default)]
560pub struct SortablePrimitiveHandler {
561 instances: BTreeMap<PrimitiveInstanceId, Entity<SortableEntity>>,
562}
563
564impl PrimitiveHandler for SortablePrimitiveHandler {
565 fn uses_primary_focus(&self) -> bool {
566 true
567 }
568
569 fn render(
570 &mut self,
571 instance: &PrimitiveInstance,
572 context: &PrimitiveContext,
573 theme: &PrimitiveTheme,
574 _: &mut Window,
575 cx: &mut App,
576 ) -> Result<AnyElement, String> {
577 let id = instance
578 .id
579 .clone()
580 .ok_or_else(|| "SortableItemPrimitive requires a stable key".to_owned())?;
581 let config = parse_config(
582 &instance.node.props,
583 instance.focus_handle().cloned(),
584 theme,
585 )?;
586 let entity = if let Some(entity) = self.instances.get(&id) {
587 entity.clone()
588 } else {
589 let entity = cx.new(|cx| SortableEntity::new(config.clone(), context.clone(), cx));
590 self.instances.insert(id.clone(), entity.clone());
591 entity
592 };
593 entity.update(cx, |sortable, _| {
594 sortable.update(config, context.clone());
595 });
596 Ok(entity.into_any_element())
597 }
598
599 fn unmount(&mut self, instance: &PrimitiveInstanceId) {
600 self.instances.remove(instance);
601 }
602}
603
604fn parse_config(
605 props: &PrimitiveProps,
606 focus: Option<FocusHandle>,
607 theme: &PrimitiveTheme,
608) -> Result<SortableConfig, String> {
609 let list_id = required_safe_string(props, "list_id")?;
610 let collection = required_collection_id(props)?;
611 let item_key = required_safe_string(props, "item_key")?;
612 let first_key = required_safe_string(props, "first_key")?;
613 let last_key = required_safe_string(props, "last_key")?;
614 let direction = props
615 .string("direction")
616 .and_then(SortDirection::parse)
617 .ok_or_else(|| "sortable direction must be vertical, horizontal, or grid".to_owned())?;
618 let threshold = props.number("threshold").unwrap_or(4.0);
619 if !threshold.is_finite() || !(0.0..=64.0).contains(&threshold) {
620 return Err("sortable threshold must be finite and in [0,64]".to_owned());
621 }
622 Ok(SortableConfig {
623 id: format!("gpui-rhai-sortable:{list_id}:{item_key}"),
624 collection,
625 item_key,
626 source_index: props.usize("source_index"),
627 source_snapshot: props.data("source_item").cloned(),
628 previous_key: optional_string(props, "previous_key")?,
629 next_key: optional_string(props, "next_key")?,
630 first_key,
631 last_key,
632 direction,
633 threshold,
634 disabled: props.boolean("disabled").unwrap_or(false),
635 item_ref: props.element_ref("item_ref").cloned(),
636 focus,
637 accent: theme
638 .color("accent")
639 .unwrap_or(Rgba8::from_rgba_hex(0x3b82_f6ff)),
640 })
641}
642
643fn required_safe_string(props: &PrimitiveProps, name: &str) -> Result<String, String> {
644 let value = props
645 .string(name)
646 .ok_or_else(|| format!("{name} is required"))?;
647 if value.is_empty() || value.len() > 128 || value.chars().any(char::is_control) {
648 Err(format!("{name} must be 1-128 non-control characters"))
649 } else {
650 Ok(value.to_owned())
651 }
652}
653
654fn required_collection_id(props: &PrimitiveProps) -> Result<String, String> {
655 let value = props
656 .string("collection_id")
657 .ok_or_else(|| "collection_id is required".to_owned())?;
658 if value.is_empty() || value.len() > 1_024 || value.chars().any(char::is_control) {
659 Err("collection_id must be 1-1024 non-control characters".to_owned())
660 } else {
661 Ok(value.to_owned())
662 }
663}
664
665fn optional_string(props: &PrimitiveProps, name: &str) -> Result<Option<String>, String> {
666 match props.data(name) {
667 None | Some(UiValue::Null) => Ok(None),
668 Some(UiValue::String(value))
669 if !value.is_empty() && value.len() <= 128 && !value.chars().any(char::is_control) =>
670 {
671 Ok(Some(value.clone()))
672 }
673 _ => Err(format!("{name} must be null or a safe string")),
674 }
675}
676
677fn reorder_schema() -> ValueSchema {
678 ValueSchema::object(BTreeMap::from([
679 (
680 "source_key".to_owned(),
681 ObjectField::required(ValueSchema::string()),
682 ),
683 (
684 "anchor_key".to_owned(),
685 ObjectField::required(ValueSchema::string()),
686 ),
687 (
688 "placement".to_owned(),
689 ObjectField::required(ValueSchema::String {
690 allowed: vec!["before".to_owned(), "after".to_owned()],
691 }),
692 ),
693 ("x".to_owned(), ObjectField::required(ValueSchema::number())),
694 ("y".to_owned(), ObjectField::required(ValueSchema::number())),
695 ]))
696}
697
698#[must_use]
704pub fn sortable_primitive_descriptor() -> PrimitiveDescriptor {
705 let optional_string = || ValueSchema::optional(ValueSchema::string());
706 PrimitiveDescriptor {
707 id: PrimitiveId::parse("gpui_rhai.sortable_item").expect("static primitive ID"),
708 export: "SortableItemPrimitive".to_owned(),
709 props: BTreeMap::from([
710 (
711 "list_id".to_owned(),
712 ObjectField::required(ValueSchema::string()),
713 ),
714 (
715 "collection_id".to_owned(),
716 ObjectField::required(ValueSchema::string()),
717 ),
718 (
719 "item_key".to_owned(),
720 ObjectField::required(ValueSchema::string()),
721 ),
722 (
723 "source_index".to_owned(),
724 ObjectField::optional(ValueSchema::optional(ValueSchema::Integer {
725 min: Some(0),
726 max: None,
727 })),
728 ),
729 (
730 "source_item".to_owned(),
731 ObjectField::optional(ValueSchema::optional(ValueSchema::UiValue)),
732 ),
733 (
734 "previous_key".to_owned(),
735 ObjectField::required(optional_string()),
736 ),
737 (
738 "next_key".to_owned(),
739 ObjectField::required(optional_string()),
740 ),
741 (
742 "first_key".to_owned(),
743 ObjectField::required(ValueSchema::string()),
744 ),
745 (
746 "last_key".to_owned(),
747 ObjectField::required(ValueSchema::string()),
748 ),
749 (
750 "direction".to_owned(),
751 ObjectField::required(ValueSchema::String {
752 allowed: vec![
753 "vertical".to_owned(),
754 "horizontal".to_owned(),
755 "grid".to_owned(),
756 ],
757 }),
758 ),
759 (
760 "threshold".to_owned(),
761 ObjectField::optional(ValueSchema::bounded_number(Some(0.0), Some(64.0))),
762 ),
763 (
764 "disabled".to_owned(),
765 ObjectField::optional(ValueSchema::Bool).with_default(UiValue::Bool(false)),
766 ),
767 (
768 "item_ref".to_owned(),
769 ObjectField::optional(ValueSchema::optional(ValueSchema::Ref)),
770 ),
771 (
772 "on_reorder".to_owned(),
773 ObjectField::optional(ValueSchema::optional(ValueSchema::Callback)),
774 ),
775 ]),
776 events: BTreeMap::from([(
777 "reorder".to_owned(),
778 EventSchema {
779 payload: reorder_schema(),
780 },
781 )]),
782 state: ComponentStateSchema::default(),
783 lifecycle: true,
784 effect: None,
785 }
786}
787
788fn f64_to_f32(value: f64) -> f32 {
789 value.to_string().parse().unwrap_or_else(|_| {
790 if value.is_sign_negative() {
791 f32::MIN
792 } else {
793 f32::MAX
794 }
795 })
796}
797
798#[cfg(test)]
799mod tests {
800 #[test]
801 fn adjacent_and_self_moves_are_no_ops() {
802 let before = |source: &str, anchor: &str, previous: Option<&str>| {
803 source == anchor || previous == Some(source)
804 };
805 let after = |source: &str, anchor: &str, next: Option<&str>| {
806 source == anchor || next == Some(source)
807 };
808 assert!(before("b", "b", Some("a")));
809 assert!(before("a", "b", Some("a")));
810 assert!(after("b", "b", Some("c")));
811 assert!(after("c", "b", Some("c")));
812 assert!(!before("c", "b", Some("a")));
813 assert!(!after("a", "b", Some("c")));
814 }
815}