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}