Skip to main content

teksilo_widgets/common/
ordered_move.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! One command for "move this item somewhere else in an ordered collection".
5//!
6//! Several widgets in this crate let the user reorder something by dragging it,
7//! and WCAG 2.2 SC 2.5.7 requires every one of them to be reachable without a
8//! drag. The four obligations that discharges it — a menu row, a keyboard
9//! chord, an AccessKit custom action, one utterance on commit — are the same
10//! four everywhere, so they are written once, here. A consumer supplies only
11//! what is genuinely its own: how its collection commits a move, and what the
12//! moved thing is called.
13//!
14//! The consumers today are the five data views' rows and tiles and a tab in a
15//! `TabBar`. `docs/drag-operation-census.md` lists the reordering drags that
16//! still have no command, each with what it needs; adding one is meant to be
17//! this module plus a chord, not a new implementation.
18//!
19//! ## The four moves
20//!
21//! [`OrderedMove`] is deliberately four values and not a signed step. "Move to
22//! the far end" is not "step repeatedly": a keyboard user pressing `Alt+Home`
23//! makes **one** model change and hears **one** utterance, where repeating a
24//! step would make one of each per press.
25//!
26//! ## Why a drop spec and not a destination index
27//!
28//! [`OrderedMove::as_row_drop`] returns the `(target, position)` pair a
29//! *pointer* drop would have carried, not the destination index. The five data
30//! views commit a reorder by handing that pair to the bound source's
31//! `accept_drop` / `reorder_within`, and the source — not the view — owns what
32//! it means. Routing the alternative through the destination index instead
33//! would be a second implementation of the same operation, and a second
34//! implementation is exactly what an alternative must not be: it can reach a
35//! different end state than the drag, which is the one failure mode a
36//! single-pointer alternative cannot have.
37//!
38//! The [`destination`](OrderedMove::destination) index is still reported,
39//! because the caller needs it for what happens *after* the model change —
40//! following the selection, scrolling the moved thing back into view, and
41//! naming its new position in the announcement.
42
43use teksilo_core::event::{Key, Modifiers};
44use teksilo_core::widget_id::WidgetId;
45use teksilo_i18n::{LocalizedString, lit};
46
47/// Which way a move goes within an ordered collection.
48///
49/// See the [module documentation](self) for why the far ends are their own
50/// variants rather than a repeated step.
51#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
52pub enum OrderedMove {
53    /// One place earlier in the collection's order.
54    Prev,
55    /// One place later.
56    Next,
57    /// All the way to the first position.
58    First,
59    /// All the way to the last position.
60    Last,
61}
62
63/// How a collection's order reads on screen.
64///
65/// This decides only what the four moves are **called** — the arithmetic is the
66/// same either way. A vertical list moves rows up and down; a tab strip or a
67/// column header moves them left and right.
68#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
69pub enum MoveAxis {
70    /// Rows in a list: Up / Down / To top / To bottom.
71    Vertical,
72    /// Tabs, columns: Left / Right / To start / To end.
73    Horizontal,
74}
75
76/// A move that changes an item's **parent** rather than its position among its
77/// siblings.
78///
79/// The tree views' row drag can do two things a flat list's cannot: drop
80/// *between* siblings (which is [`OrderedMove`]) and drop *into* another row,
81/// which reparents. Both are the same drag; only the second needs its own
82/// vocabulary, because "one place earlier" says nothing about depth.
83///
84/// The two directions are the outliner convention every editor ships:
85/// indenting makes the row a child of the sibling above it, outdenting makes it
86/// the next sibling of its own parent.
87#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
88pub enum TreeMove {
89    /// Become a child of the previous sibling.
90    Indent,
91    /// Become the next sibling of the current parent.
92    Outdent,
93}
94
95impl TreeMove {
96    /// Both directions, in the order they are offered.
97    pub const ALL: [TreeMove; 2] = [Self::Indent, Self::Outdent];
98
99    /// What this move is called.
100    pub fn label(self) -> LocalizedString {
101        match self {
102            Self::Indent => lit!("Move Into Previous"),
103            Self::Outdent => lit!("Move Out One Level"),
104        }
105    }
106
107    /// Decode the keyboard chord for the platform this build targets.
108    pub fn from_key(key: Key, modifiers: Modifiers, rtl: bool) -> Option<Self> {
109        Self::from_key_for(
110            crate::common::list_nav::ListNavConvention::CURRENT,
111            key,
112            modifiers,
113            rtl,
114        )
115    }
116
117    /// [`from_key`](Self::from_key) against an explicit convention, so both
118    /// platform branches are reachable from one host's test run — the
119    /// [`common::text_nav`](crate::common::text_nav) pattern.
120    ///
121    /// Two spellings, and the split is forced rather than chosen:
122    ///
123    /// * The **accelerator plus `]` / `[`** works everywhere. It is what every
124    ///   outliner on macOS binds to increase and decrease indent, and
125    ///   `Modifiers::command()` makes it ⌘ there and Ctrl elsewhere.
126    /// * **`Alt` plus the horizontal arrows** — the outliner convention on
127    ///   Windows and Linux — is **not** bound on macOS, because `⌥→` / `⌥←`
128    ///   already expand and collapse a whole subtree there
129    ///   ([`list_nav::mac_alias`](crate::common::list_nav::mac_alias), which
130    ///   AppKit's own outline view claims). Binding it would take a working
131    ///   chord away to add one that has another spelling.
132    ///
133    /// Only the arrow spelling mirrors under RTL: the arrows name a direction on
134    /// screen, and the chevron flips there. `]` and `[` name indent and outdent
135    /// outright.
136    pub(crate) fn from_key_for(
137        convention: crate::common::list_nav::ListNavConvention,
138        key: Key,
139        modifiers: Modifiers,
140        rtl: bool,
141    ) -> Option<Self> {
142        if modifiers.shift() {
143            return None;
144        }
145        if modifiers.command() && !modifiers.alt() {
146            return match key {
147                Key::Character(']') => Some(Self::Indent),
148                Key::Character('[') => Some(Self::Outdent),
149                _ => None,
150            };
151        }
152        if convention != crate::common::list_nav::ListNavConvention::Desktop
153            || !modifiers.alt()
154            || modifiers.command()
155        {
156            return None;
157        }
158        let (indent, outdent) = if rtl {
159            (Key::ArrowLeft, Key::ArrowRight)
160        } else {
161            (Key::ArrowRight, Key::ArrowLeft)
162        };
163        if key == indent {
164            Some(Self::Indent)
165        } else if key == outdent {
166            Some(Self::Outdent)
167        } else {
168            None
169        }
170    }
171}
172
173/// The `(target, position)` pair a pointer drop would have carried, plus where
174/// the moved item ends up.
175///
176/// See the [module documentation](self) for why the commit travels as a drop
177/// rather than as a destination index.
178#[derive(Clone, Copy, PartialEq, Eq, Debug)]
179pub struct RowDrop {
180    /// The row the drop lands on.
181    pub target: usize,
182    /// Which side of `target` it lands on.
183    pub position: teksilo_data::DropPosition,
184    /// Where the moved row ends up once the source has applied it.
185    pub destination: usize,
186}
187
188impl RowDrop {
189    /// The drop that moves the item at `from` to `destination`.
190    ///
191    /// `Before` when the item travels backwards, `After` when it travels
192    /// forwards — the same two gaps a drag would have released over, so the
193    /// source's own index arithmetic (which shifts by one when the removal is
194    /// above the insertion point) is exercised unchanged.
195    ///
196    /// Public and destination-based, not only `OrderedMove`-based, because a
197    /// 2-D grid also moves a tile by a whole row at a time and that is not one
198    /// of the four named moves.
199    pub fn between(from: usize, destination: usize) -> Self {
200        use teksilo_data::DropPosition;
201        let position = if destination < from {
202            DropPosition::Before
203        } else {
204            DropPosition::After
205        };
206        Self {
207            target: destination,
208            position,
209            destination,
210        }
211    }
212}
213
214impl OrderedMove {
215    /// The four moves, in the order they are offered to the user.
216    pub const ALL: [OrderedMove; 4] = [Self::Prev, Self::Next, Self::First, Self::Last];
217
218    /// Where index `from` lands in a collection of `count` items, or `None`
219    /// when the move would change nothing.
220    ///
221    /// A move that changes nothing is not offered: it earns no menu row, no
222    /// custom action and no announcement. That is what keeps a row already at
223    /// the top from advertising "Move to top".
224    pub fn destination(self, from: usize, count: usize) -> Option<usize> {
225        if from >= count {
226            return None;
227        }
228        let dest = match self {
229            Self::Prev => from.checked_sub(1)?,
230            Self::Next => {
231                let next = from + 1;
232                if next >= count {
233                    return None;
234                }
235                next
236            }
237            Self::First => 0,
238            Self::Last => count - 1,
239        };
240        (dest != from).then_some(dest)
241    }
242
243    /// The drop a pointer would have made to perform this move, or `None` when
244    /// the move changes nothing.
245    pub fn as_row_drop(self, from: usize, count: usize) -> Option<RowDrop> {
246        Some(RowDrop::between(from, self.destination(from, count)?))
247    }
248
249    /// The moves available from `from` in a collection of `count`.
250    pub fn available(from: usize, count: usize) -> Vec<OrderedMove> {
251        Self::ALL
252            .into_iter()
253            .filter(|mv| mv.destination(from, count).is_some())
254            .collect()
255    }
256
257    /// What this move is called on the given axis.
258    pub fn label(self, axis: MoveAxis) -> LocalizedString {
259        match (self, axis) {
260            (Self::Prev, MoveAxis::Vertical) => lit!("Move Up"),
261            (Self::Next, MoveAxis::Vertical) => lit!("Move Down"),
262            (Self::First, MoveAxis::Vertical) => lit!("Move to Top"),
263            (Self::Last, MoveAxis::Vertical) => lit!("Move to Bottom"),
264            (Self::Prev, MoveAxis::Horizontal) => lit!("Move Left"),
265            (Self::Next, MoveAxis::Horizontal) => lit!("Move Right"),
266            (Self::First, MoveAxis::Horizontal) => lit!("Move to Start"),
267            (Self::Last, MoveAxis::Horizontal) => lit!("Move to End"),
268        }
269    }
270
271    /// Decode the keyboard chord that performs a move on the given axis.
272    ///
273    /// `Alt` plus the axis' own two arrows steps; `Alt+Home` / `Alt+End` go to
274    /// the ends. `Alt` is what every outliner and tab strip already uses for
275    /// this (and what the three data views that had a keyboard reorder before
276    /// this module used), so the chord is not new vocabulary.
277    ///
278    /// `rtl` swaps the two horizontal arrows, because on the horizontal axis
279    /// the arrow keys name a *direction on screen* while the move names a
280    /// position in the order. `Home`/`End` are unaffected: they name the order's
281    /// ends, which do not flip.
282    pub fn from_key(key: Key, modifiers: Modifiers, axis: MoveAxis, rtl: bool) -> Option<Self> {
283        if !modifiers.alt() || modifiers.shift() || modifiers.command() {
284            return None;
285        }
286        let (prev_key, next_key) = match (axis, rtl) {
287            (MoveAxis::Vertical, _) => (Key::ArrowUp, Key::ArrowDown),
288            (MoveAxis::Horizontal, false) => (Key::ArrowLeft, Key::ArrowRight),
289            (MoveAxis::Horizontal, true) => (Key::ArrowRight, Key::ArrowLeft),
290        };
291        if key == prev_key {
292            return Some(Self::Prev);
293        }
294        if key == next_key {
295            return Some(Self::Next);
296        }
297        match key {
298            Key::Home => Some(Self::First),
299            Key::End => Some(Self::Last),
300            _ => None,
301        }
302    }
303}
304
305/// The one utterance a completed move makes.
306///
307/// `name` is what the moved thing is called, where the widget knows: a row's
308/// text, a tab's title, a column's header. Where it does not, the position
309/// alone is still worth saying — a screen-reader user who pressed `Alt+Down`
310/// otherwise hears nothing at all and cannot tell a refused move from a
311/// successful one.
312///
313/// Positions are 1-based, because that is what every adapter announces for
314/// `pos_in_set` and a user hearing "moved to 0 of 12" would be right to be
315/// confused.
316pub fn move_announcement(name: Option<&str>, destination: usize, count: usize) -> String {
317    let position = destination + 1;
318    match name {
319        Some(name) if !name.is_empty() => {
320            lit!(format!("{name} moved to {position} of {count}")).resolve_now()
321        }
322        _ => lit!(format!("Moved to {position} of {count}")).resolve_now(),
323    }
324}
325
326/// The commit half of a data view's non-drag reorder.
327///
328/// The five data views differ in how they lay rows out, scroll them and paint
329/// them; they do not differ at all in how a reorder reaches the model. That
330/// part is this struct: the bound source's own drop-accept path, the drag-start
331/// key stash it depends on, and the typed payload a same-view drop carries.
332/// Each field is the closure the view already holds for its **pointer** drag,
333/// which is what makes the alternative the same operation rather than a second
334/// implementation of it.
335pub(crate) struct RowMover {
336    /// How many rows the collection holds right now.
337    pub len: std::rc::Rc<dyn Fn() -> usize>,
338    /// Record the dragged rows' stable keys, as a drag start would. The accept
339    /// path resolves identity from this stash and never from the payload's
340    /// indices, so a commit that skips it is refused.
341    pub stash: std::rc::Rc<dyn Fn(&[usize])>,
342    /// Build the same-view payload for a move starting at this row.
343    pub payload: std::rc::Rc<dyn Fn(usize) -> teksilo_core::drag_payload::DragPayload>,
344    /// The bound source's drop-accept path.
345    pub accept: std::rc::Rc<
346        dyn Fn(
347            &teksilo_core::drag_payload::DragPayload,
348            usize,
349            teksilo_data::DropPosition,
350            crate::data_views::ViewId,
351        ) -> bool,
352    >,
353    /// This view's identity, so the accept path reads the drop as same-view.
354    pub view: crate::data_views::ViewId,
355    /// What the row at this index is called, where the view knows. `ListView`
356    /// and its siblings know when the application opted into type-ahead by
357    /// giving them a label resolver; otherwise the announcement names the
358    /// position alone.
359    pub name: std::rc::Rc<dyn Fn(usize) -> Option<String>>,
360}
361
362impl RowMover {
363    /// The moves available from `from`, in the order they are offered.
364    pub fn available(&self, from: usize) -> Vec<OrderedMove> {
365        OrderedMove::available(from, (self.len)())
366    }
367
368    /// Perform `mv` on the row at `from` through the source's own commit path.
369    ///
370    /// Returns the destination index and the one utterance the move makes, or
371    /// `None` when the move was unavailable or the source refused it. A refusal
372    /// is silent on purpose: the source declining a reorder is not an event, and
373    /// speaking "moved" when nothing moved is worse than saying nothing.
374    pub fn commit(&self, mv: OrderedMove, from: usize) -> Option<(usize, String)> {
375        let count = (self.len)();
376        self.commit_to(from, mv.destination(from, count)?)
377    }
378
379    /// Move the row at `from` to `destination`, for a caller whose move is not
380    /// one of the four named ones — a grid tile moving a whole row at a time.
381    pub fn commit_to(&self, from: usize, destination: usize) -> Option<(usize, String)> {
382        let count = (self.len)();
383        if from >= count || destination >= count || destination == from {
384            return None;
385        }
386        let drop = RowDrop::between(from, destination);
387        // Resolved before the move, because afterwards the row is somewhere else.
388        let name = (self.name)(from);
389        (self.stash)(&[from]);
390        let payload = (self.payload)(from);
391        if !(self.accept)(&payload, drop.target, drop.position, self.view) {
392            return None;
393        }
394        Some((
395            drop.destination,
396            move_announcement(name.as_deref(), drop.destination, count),
397        ))
398    }
399}
400
401/// A move, already bound to the thing it moves.
402///
403/// Every one of the four obligations SC 2.5.7 imposes — a menu row, a keyboard
404/// chord, an AccessKit custom action, an utterance on commit — ends in the same
405/// call, so each consumer builds exactly one of these and the three surfaces
406/// below share it. That is what keeps the menu row, the chord and the AT action
407/// from being three implementations that can disagree.
408pub(crate) type PerformMove =
409    std::rc::Rc<dyn Fn(OrderedMove, &mut teksilo_core::widget::EventContext)>;
410
411/// The tree views' reparent, bound to nothing yet — the [`MoveRow`] twin for
412/// [`TreeMove`].
413pub(crate) type TreeReparentRow =
414    std::rc::Rc<dyn Fn(TreeMove, usize, &mut teksilo_core::widget::EventContext)>;
415
416/// The same thing before it knows which row it moves: what a view builds once
417/// and each of its rows then binds its own index into.
418pub(crate) type MoveRow =
419    std::rc::Rc<dyn Fn(OrderedMove, usize, &mut teksilo_core::widget::EventContext)>;
420
421/// Bind a view-level mover to one row's live index.
422///
423/// `index` is read at invocation, not at build: a virtualized row's index is
424/// only knowable through its anchor, and the anchor is what survives the
425/// reorder the command itself causes.
426pub(crate) fn bind_row(
427    perform: &MoveRow,
428    index: std::rc::Rc<dyn Fn() -> Option<usize>>,
429) -> PerformMove {
430    let perform = perform.clone();
431    std::rc::Rc::new(move |mv, ctx| {
432        if let Some(from) = index() {
433            perform(mv, from, ctx);
434        }
435    })
436}
437
438/// Append the available Move rows to a context menu.
439///
440/// Returns the list and whether anything was added, so a caller can keep its
441/// separators tidy when every move is unavailable (a one-row collection, or a
442/// view that is not reorderable).
443pub(crate) fn append_move_items(
444    mut list: crate::menu_list::MenuList,
445    perform: &PerformMove,
446    from: usize,
447    count: usize,
448    axis: MoveAxis,
449) -> (crate::menu_list::MenuList, bool) {
450    let mut added = false;
451    for mv in OrderedMove::available(from, count) {
452        let perform = perform.clone();
453        list = list.item(
454            crate::menu_item::MenuItem::new(mv.label(axis))
455                .on_activate_fn(move |ctx| perform(mv, ctx)),
456        );
457        added = true;
458    }
459    (list, added)
460}
461
462/// Advertise the available moves as AccessKit custom actions on a node.
463///
464/// The set is fixed when the node is built, which is correct because every
465/// commit path here changes the collection and so rebuilds the node. The
466/// *callback* still resolves the live index, so a move invoked against a stale
467/// set is a no-op rather than a move of the wrong item.
468pub(crate) fn add_move_custom_actions(
469    mut handlers: teksilo_core::widget_builder::HandlerSet,
470    perform: &PerformMove,
471    from: usize,
472    count: usize,
473    axis: MoveAxis,
474) -> teksilo_core::widget_builder::HandlerSet {
475    for mv in OrderedMove::available(from, count) {
476        let perform = perform.clone();
477        handlers = handlers.access_custom_action(mv.label(axis), move |ctx| perform(mv, ctx));
478    }
479    handlers
480}
481
482/// One row's worth of non-drag reorder commands, ready to install.
483///
484/// A data view calls [`install`](RowCommands::install) once per realized row
485/// and gets three of SC 2.5.7's four obligations at once: the AccessKit custom
486/// actions, the context menu carrying the same rows, and — because both call
487/// the same [`PerformMove`] the view's keyboard handler calls — the guarantee
488/// that all four routes reach the same model state. The fourth, the utterance,
489/// is inside the [`RowMover::commit`] the closure wraps.
490pub(crate) struct RowCommands {
491    /// The move, already bound to this row's live index.
492    pub perform: PerformMove,
493    /// The row's index when it was built, which decides which moves are
494    /// *offered*. The commit resolves the live index again.
495    pub from: usize,
496    /// How many rows the collection held when the row was built.
497    pub count: usize,
498    /// What the four moves are called here.
499    pub axis: MoveAxis,
500    /// Rows offered after the four moves. The tree views put indent / outdent
501    /// here: they change a row's parent rather than its position in a flat
502    /// order, so they are not `OrderedMove`s, but they are the same drag
503    /// (`DropPosition::Into`) and belong in the same menu.
504    pub extra: Vec<(
505        teksilo_i18n::LocalizedString,
506        std::rc::Rc<dyn Fn(&mut teksilo_core::widget::EventContext)>,
507    )>,
508}
509
510impl RowCommands {
511    /// Whether this row can be moved at all. A one-row collection offers
512    /// nothing, and offering an empty menu is worse than offering none.
513    pub fn is_empty(&self) -> bool {
514        OrderedMove::available(self.from, self.count).is_empty() && self.extra.is_empty()
515    }
516
517    /// The context menu for this row, or `None` when there is nothing to offer.
518    pub fn menu(&self) -> Option<crate::menu_list::MenuList> {
519        if self.is_empty() {
520            return None;
521        }
522        let (mut list, _) = append_move_items(
523            crate::menu_list::MenuList::new(),
524            &self.perform,
525            self.from,
526            self.count,
527            self.axis,
528        );
529        for (label, run) in &self.extra {
530            let run = run.clone();
531            list = list.item(
532                crate::menu_item::MenuItem::new(label.clone()).on_activate_fn(move |ctx| run(ctx)),
533            );
534        }
535        Some(list)
536    }
537
538    /// Install the AccessKit custom actions and the context menu on `row_id`.
539    ///
540    /// The menu goes on the row rather than on the view because the row is what
541    /// the command is *about*, and because the framework's factory is then the
542    /// one an application's own per-row menu shadows — a factory closer to the
543    /// click wins the walk, and an application that writes a row menu owns it.
544    pub fn install(self, ctx: &mut teksilo_core::build_context::BuildContext, row_id: WidgetId) {
545        if self.is_empty() {
546            return;
547        }
548        let mut handlers = add_move_custom_actions(
549            teksilo_core::widget_builder::HandlerSet::new(),
550            &self.perform,
551            self.from,
552            self.count,
553            self.axis,
554        );
555        for (label, run) in &self.extra {
556            let run = run.clone();
557            handlers = handlers.access_custom_action(label.clone(), move |ctx| run(ctx));
558        }
559        let menu_source = self;
560        handlers = handlers.context_menu(move |_pos, _ctx| {
561            menu_source
562                .menu()
563                .map(|list| Box::new(list) as Box<dyn teksilo_core::widget::Widget>)
564        });
565        ctx.apply_handlers(row_id, handlers);
566    }
567}
568
569/// The one utterance a completed reparent makes.
570///
571/// The level is what changed, and it is what the announcement says: after an
572/// indent or an outdent the row's *position* among its siblings is a different
573/// question from the one the user asked. Levels are 1-based, matching
574/// AccessKit's own `level`.
575pub fn reparent_announcement(name: Option<&str>, level: usize) -> String {
576    match name {
577        Some(name) if !name.is_empty() => {
578            lit!(format!("{name} moved to level {level}")).resolve_now()
579        }
580        _ => lit!(format!("Moved to level {level}")).resolve_now(),
581    }
582}
583
584#[cfg(test)]
585mod tests {
586    use super::*;
587    use teksilo_data::DropPosition;
588
589    #[test]
590    fn a_move_that_changes_nothing_is_not_offered() {
591        assert_eq!(OrderedMove::Prev.destination(0, 5), None);
592        assert_eq!(OrderedMove::First.destination(0, 5), None);
593        assert_eq!(OrderedMove::Next.destination(4, 5), None);
594        assert_eq!(OrderedMove::Last.destination(4, 5), None);
595        // A single item can go nowhere at all.
596        assert_eq!(OrderedMove::available(0, 1), Vec::new());
597        // And an index the collection does not hold is not a starting point.
598        for mv in OrderedMove::ALL {
599            assert_eq!(mv.destination(7, 5), None, "{mv:?}");
600        }
601        assert_eq!(OrderedMove::available(0, 0), Vec::new());
602    }
603
604    #[test]
605    fn the_middle_of_a_collection_offers_all_four() {
606        assert_eq!(OrderedMove::available(2, 5), OrderedMove::ALL.to_vec());
607        assert_eq!(OrderedMove::Prev.destination(2, 5), Some(1));
608        assert_eq!(OrderedMove::Next.destination(2, 5), Some(3));
609        assert_eq!(OrderedMove::First.destination(2, 5), Some(0));
610        assert_eq!(OrderedMove::Last.destination(2, 5), Some(4));
611    }
612
613    /// The gap a move releases over is the one a drag would have: `Before` when
614    /// the item travels backwards, `After` when forwards. The source's own
615    /// arithmetic then lands it on `destination` — asserted against a real
616    /// `ListModel` in `ordered_move_lands_where_a_drag_would`.
617    #[test]
618    fn each_move_carries_the_gap_a_drag_would_have_released_over() {
619        let up = OrderedMove::Prev.as_row_drop(3, 10).expect("available");
620        assert_eq!(
621            (up.target, up.position, up.destination),
622            (2, DropPosition::Before, 2)
623        );
624        let down = OrderedMove::Next.as_row_drop(3, 10).expect("available");
625        assert_eq!(
626            (down.target, down.position, down.destination),
627            (4, DropPosition::After, 4)
628        );
629        let top = OrderedMove::First.as_row_drop(3, 10).expect("available");
630        assert_eq!(
631            (top.target, top.position, top.destination),
632            (0, DropPosition::Before, 0)
633        );
634        let bottom = OrderedMove::Last.as_row_drop(3, 10).expect("available");
635        assert_eq!(
636            (bottom.target, bottom.position, bottom.destination),
637            (9, DropPosition::After, 9)
638        );
639    }
640
641    #[test]
642    fn the_axis_decides_only_the_wording() {
643        for mv in OrderedMove::ALL {
644            let vertical = mv.label(MoveAxis::Vertical).resolve_now();
645            let horizontal = mv.label(MoveAxis::Horizontal).resolve_now();
646            assert_ne!(vertical, horizontal, "{mv:?}");
647            assert_eq!(
648                mv.destination(2, 5),
649                mv.destination(2, 5),
650                "the arithmetic does not read the axis"
651            );
652        }
653        assert_eq!(
654            OrderedMove::Prev.label(MoveAxis::Vertical).resolve_now(),
655            "Move Up"
656        );
657        assert_eq!(
658            OrderedMove::Last.label(MoveAxis::Horizontal).resolve_now(),
659            "Move to End"
660        );
661    }
662
663    #[test]
664    fn alt_plus_the_axis_arrows_is_the_chord() {
665        use MoveAxis::{Horizontal, Vertical};
666        let alt = Modifiers::ALT;
667        assert_eq!(
668            OrderedMove::from_key(Key::ArrowUp, alt, Vertical, false),
669            Some(OrderedMove::Prev)
670        );
671        assert_eq!(
672            OrderedMove::from_key(Key::ArrowDown, alt, Vertical, false),
673            Some(OrderedMove::Next)
674        );
675        assert_eq!(
676            OrderedMove::from_key(Key::Home, alt, Vertical, false),
677            Some(OrderedMove::First)
678        );
679        assert_eq!(
680            OrderedMove::from_key(Key::End, alt, Vertical, false),
681            Some(OrderedMove::Last)
682        );
683        // The other axis' arrows are not this axis' chord.
684        assert_eq!(
685            OrderedMove::from_key(Key::ArrowLeft, alt, Vertical, false),
686            None
687        );
688        assert_eq!(
689            OrderedMove::from_key(Key::ArrowUp, alt, Horizontal, false),
690            None
691        );
692        // Without Alt, nothing — the bare arrows are navigation.
693        assert_eq!(
694            OrderedMove::from_key(Key::ArrowUp, Modifiers::NONE, Vertical, false),
695            None
696        );
697        // And Alt+Shift / Alt+Ctrl belong to selection and to word motion.
698        assert_eq!(
699            OrderedMove::from_key(
700                Key::ArrowUp,
701                Modifiers::ALT | Modifiers::SHIFT,
702                Vertical,
703                false
704            ),
705            None
706        );
707    }
708
709    #[test]
710    fn rtl_swaps_the_horizontal_arrows_but_not_the_ends() {
711        use MoveAxis::Horizontal;
712        let alt = Modifiers::ALT;
713        assert_eq!(
714            OrderedMove::from_key(Key::ArrowLeft, alt, Horizontal, true),
715            Some(OrderedMove::Next),
716            "in RTL the leftward arrow moves later in the order"
717        );
718        assert_eq!(
719            OrderedMove::from_key(Key::ArrowRight, alt, Horizontal, true),
720            Some(OrderedMove::Prev)
721        );
722        assert_eq!(
723            OrderedMove::from_key(Key::Home, alt, Horizontal, true),
724            Some(OrderedMove::First),
725            "Home names the order's start, which does not flip"
726        );
727    }
728
729    /// Both reparent spellings, and both platform branches, from one host.
730    #[test]
731    fn the_reparent_chord_reads_brackets_everywhere_and_arrows_off_macos() {
732        use crate::common::list_nav::ListNavConvention::{Desktop, Mac};
733        let cmd = Modifiers::COMMAND;
734        for convention in [Desktop, Mac] {
735            assert_eq!(
736                TreeMove::from_key_for(convention, Key::Character(']'), cmd, false),
737                Some(TreeMove::Indent),
738                "{convention:?}"
739            );
740            assert_eq!(
741                TreeMove::from_key_for(convention, Key::Character('['), cmd, false),
742                Some(TreeMove::Outdent),
743                "{convention:?}"
744            );
745            // The brackets name indent and outdent, so RTL leaves them alone.
746            assert_eq!(
747                TreeMove::from_key_for(convention, Key::Character(']'), cmd, true),
748                Some(TreeMove::Indent),
749                "{convention:?}"
750            );
751        }
752        let alt = Modifiers::ALT;
753        assert_eq!(
754            TreeMove::from_key_for(Desktop, Key::ArrowRight, alt, false),
755            Some(TreeMove::Indent)
756        );
757        assert_eq!(
758            TreeMove::from_key_for(Desktop, Key::ArrowLeft, alt, false),
759            Some(TreeMove::Outdent)
760        );
761        assert_eq!(
762            TreeMove::from_key_for(Desktop, Key::ArrowLeft, alt, true),
763            Some(TreeMove::Indent),
764            "the arrows mirror under RTL, as the chevron does"
765        );
766        // macOS spends ⌥→ / ⌥← on the subtree expand pair, so they are not the
767        // reparent chord there.
768        assert_eq!(
769            TreeMove::from_key_for(Mac, Key::ArrowRight, alt, false),
770            None,
771            "binding ⌥→ would take the subtree expand away"
772        );
773        assert_eq!(
774            TreeMove::from_key_for(Mac, Key::ArrowLeft, alt, false),
775            None
776        );
777        // And a bare bracket is a character, not a command.
778        assert_eq!(
779            TreeMove::from_key_for(Desktop, Key::Character(']'), Modifiers::NONE, false),
780            None
781        );
782    }
783
784    #[test]
785    fn the_utterance_names_the_new_position_one_based() {
786        assert_eq!(
787            move_announcement(Some("Beta"), 2, 12),
788            "Beta moved to 3 of 12"
789        );
790        assert_eq!(move_announcement(None, 0, 12), "Moved to 1 of 12");
791        assert_eq!(move_announcement(Some(""), 0, 3), "Moved to 1 of 3");
792    }
793
794    /// The load-bearing claim of the whole module: the alternative reaches the
795    /// same model state the drag reaches, for every one of the four moves,
796    /// through the source's own commit path.
797    #[test]
798    fn ordered_move_lands_where_a_drag_would() {
799        use teksilo_data::{DragSource, DropCommit};
800        use teksilo_data::{ListDataSource, ListModel};
801
802        for (mv, from, expected) in [
803            (OrderedMove::Prev, 3usize, vec!["a", "b", "d", "c", "e"]),
804            (OrderedMove::Next, 3, vec!["a", "b", "c", "e", "d"]),
805            (OrderedMove::First, 3, vec!["d", "a", "b", "c", "e"]),
806            (OrderedMove::Last, 1, vec!["a", "c", "d", "e", "b"]),
807        ] {
808            let model = ListModel::from_vec(vec!["a", "b", "c", "d", "e"]);
809            let drop = mv.as_row_drop(from, 5).expect("available");
810            // Exactly the call a released drag makes.
811            assert!(
812                ListDataSource::accept_drop(
813                    &model,
814                    DropCommit {
815                        source: DragSource::SameView { key: from },
816                        target: drop.target,
817                        position: drop.position,
818                    }
819                ),
820                "{mv:?} was refused"
821            );
822            let got: Vec<&str> = (0..model.len())
823                .map(|i| model.with_item(i, |v| *v).expect("in range"))
824                .collect();
825            assert_eq!(got, expected, "{mv:?} from {from}");
826            assert_eq!(
827                got.iter()
828                    .position(|s| *s == ["a", "b", "c", "d", "e"][from]),
829                Some(drop.destination),
830                "{mv:?}: the reported destination is where the item actually is"
831            );
832        }
833    }
834}