Skip to main content

gpui_rhai/
sortable.rs

1//! Keyed controlled sorting over the shared application drag runtime.
2
3use 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/// Build the native keyed sortable-item interaction schema.
699///
700/// # Panics
701///
702/// Panics only if the static primitive ID becomes invalid.
703#[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}