makepad/widgets/src/reorder_list.rs
Admin 3b4d6a4ff7 platform+widgets: sploded 3D inspect view, the tweaker design-feedback suite, script math-AOT and docs channel, pointer-pin and pixel-probe, modal layout, map warp
Squashed from work; the fine-grained history is under tag archive/work-2026-08-29:
- mp* wave: mpwm window manager + the mp app family, WM API, theme bridge, PDF engine fix
- mpwm polish wave: terminal key focus, focus-history close order, pop-back-to-origin, occupied-workspace cycling, demo
- mpwm: warm-instance pool, flat-luminance opens, flicker-free CEF resize
- work: land the sources the last commits reference
- kenney: catalogue all 50 free 3D kits; Modal dismissed() never fired
- platform: windows check green again — SetWindowTextW binding
- map: exact warp-aware inverse projections — pointer ops work folded
- mpwm: quick-look gap fixes; image cache eviction on preview unload
- tweaker: material thumbnails + vibecode popup + ctrl-space notes, undo/redo over the edit ledger, capture-semantics pi
- tweaker: vibe popup card chrome + dispatch order, ctrl-space notes verified, sploded design v2 chapter
- tweaker: tabbed side panel (Props/Shader/Tree) - shader tab with checkerboard material well + prompt, complete widget-
- sploded v2: nesting-depth z, hairline scope frames, body pass
- sploded: pin the depth convention with a test, kill the draw_depth residue
- sploded: real body-pass split (scene-pass capture, panel flat) + y-convention source of truth with anti-flip gate test
- sploded: hollow outlines, flat-band input, ray-pick unprojection
- tweaker: shader tab defaults to the selection's first draw layer, stale hint trimmed
- sploded: outlines become clipped, antialiased strips; tighter deck
- sploded: merge the lane's v2 (nesting-depth z, clipped AA strip outlines, flat-band input, unproject, SplodedStack bod
- sploded: the exploded view is a LIVE view — pointer events route through the inverse explode transform (ray -> plane -
- tweaker: tabs are real widgets (uid, tree node under the dock, own plane in 3D) and pickable; navigation-class clicks
- sploded: pinned/hover outlines render on the widget's own plane in 3D — per-widget nesting depth lives on the platform
- tweaker: the material well renders the pinned widget's actual shader — the swatch byte-copies the widget's live draw c
- tweaker: the Shader tab shows the shader as written — the layer's pixel/vertex fn source (nearest definition up the co
- sploded: I = true isometric preset (yaw 45°, pitch atan(1/√2))
- tweaker: eyedropper — the colour popover's pick button arms a pixel probe; the next press in the app samples that devi
- tweaker: the shader loop closes — /tweak/apply resolves the pinned widget by uid (anonymous path segments never round-
- vj: responsive DJ mixer + Windows drag-and-drop, cherry-picked from PR #1199 (vjroger)
- tweaker: the material well is a magnifier — the mirrored instance draws at the widget's native size in the well's own
- tweaker: per-layer material thumbnails — the Widget derive emits WidgetNode::layer_areas() (every #[live] Draw… field
- tweaker: the shader source view is the real CodeView (syntax highlighting, selection, editing) when the app registers
- tweaker: Ctrl+Enter sends on every platform (TextInput treated only Cmd as primary on macOS, so Ctrl+Enter inserted a
- Modal claims no layout slot: the DJ page fills its window again
- tweaker: every fn apply recompiles (eval_chunk ran every chunk under ONE synthetic callsite, so the script body — and
- tweaker: an apply whose draw shader fails to compile is rejected — the layer goes back (last live fns / the fn as writ
- tweaker: the Shader tab's source view owns its scrolling (the ScrollYView around the CodeView double-scrolled the care
- widgets: set_visible belongs to every widget, not just View (#1194)
- script: a dead heap's resource handles must not outlive it (#1195)
- Resources: search the executable's directory, not only the working directory (#1196)
- Windows: fit a restored window to the displays that are actually attached (#1197)
- d3d11: a failing GPU call reports the loss instead of killing the process (#1198)

Co-authored-by: Kevin Boos <1139460+kevinaboos@users.noreply.github.com>
2026-08-29 09:26:24 +02:00

545 lines
22 KiB
Rust

//! A PortalList that can reorder its rows by dragging a per-row gripper.
//!
//! `ReorderList` is a thin specialisation of [`PortalList`]: templates,
//! scrolling, virtualisation and item actions all pass straight through
//! (the list is the `#[deref]` base). On top of that it watches one named
//! child of every item — the `drag_handle` — and turns a press-and-drag on
//! it into a reorder gesture:
//!
//! - the press on the gripper is captured HERE, before the inner list sees
//! it, so drag-to-scroll never fights the gesture;
//! - while the drag is live the widget tracks the insertion slot under the
//! pointer (row midpoints decide) and draws `draw_indicator` as a line
//! in the gap the row would land in; the lifted row is reported through
//! [`ReorderList::drag_state`] so the host can tint it;
//! - Escape cancels the gesture; a wheel scroll during it is swallowed so
//! the rows never slide under the pointer mid-drag;
//! - the release emits [`ReorderListAction::Reordered`] with indices into
//! the host's item range. The widget itself moves nothing: item identity
//! and the model belong to the host, which applies the move and redraws.
//!
//! The host names the gripper in its item template and points at it:
//!
//! ```text
//! list := ReorderList {
//! drag_handle: @gripper
//! Row := View { gripper := View { Icon { ... } } ... }
//! }
//! ```
//!
//! CAPTURE LAW (the bug this file once had): a finger capture in
//! `cx.fingers` is keyed on the captured widget's `Area`, and every redraw
//! REMAPS that stored capture to the widget's fresh area
//! (`Cx::update_area_refs`). Any `Area` snapshot a widget keeps for itself
//! goes stale on the first redraw — `event.hits` on it fails `is_valid`
//! and returns `Nothing` forever. So no `Area` is stored here: the
//! gripper's CURRENT area is re-resolved from the live item on every event
//! and always equals the remapped capture.
use crate::{
flat_list::WidgetItem, makepad_derive_widget::*, makepad_draw::*, portal_list::PortalList,
widget::*,
};
script_mod! {
use mod.prelude.widgets_internal.*
use mod.widgets.*
mod.widgets.ReorderListBase = #(ReorderList::register_widget(vm))
mod.widgets.ReorderList = set_type_default() do mod.widgets.ReorderListBase {
width: Fill
height: Fill
capture_overload: true
scroll_bar: mod.widgets.ScrollBar {}
flow: Down
draw_indicator +: {
draw_depth: 10.0
color: #x5a8bd8
}
}
}
#[derive(Clone, Debug, Default)]
pub enum ReorderListAction {
/// A row was dropped somewhere new. `from` and `to` are indices into
/// the host's item range: remove the row at `from`, then insert it at
/// `to` (`to` is already adjusted for the removal).
Reordered { from: usize, to: usize },
#[default]
None,
}
/// One visible row as the drag machine sees it: entry id, top y, height.
pub type RowBand = (usize, f64, f64);
/// The insertion slot for a pointer at `y` over `rows` (ascending by entry
/// id): the first row whose midpoint is still below the pointer, else after
/// the last one (`last id + 1`). `None` when nothing is visible.
pub fn slot_for(rows: &[RowBand], y: f64) -> Option<usize> {
let (last, _, _) = rows.last()?;
let mut slot = last + 1;
for (id, top, height) in rows {
if y < top + height * 0.5 {
slot = *id;
break;
}
}
Some(slot)
}
/// One live drag, from gripper press to release — a pure state machine
/// (no `Cx`, no `Area`), so the whole gesture is unit-testable: pointer y
/// in, slot out, commit or cancel at the end.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ReorderDrag {
/// The item (list entry id) whose gripper was pressed.
pub from: usize,
/// Where the press landed.
start_y: f64,
/// Insertion slot in entry-id space: the row would land BEFORE the
/// current occupant of `slot`; `last visible + 1` means after the end.
pub slot: usize,
/// True once the press has moved past the threshold — only then does
/// the indicator draw and the release commit. A plain click on the
/// gripper stays a click.
pub active: bool,
}
impl ReorderDrag {
/// A fresh press on `from`'s gripper at pointer height `y`.
pub fn press(from: usize, y: f64) -> Self {
Self { from, start_y: y, slot: from, active: false }
}
/// Advance with a new pointer `y` over the currently visible `rows`.
/// Returns true when the visual state changed (activation, or the slot
/// moved) and the host should redraw.
pub fn move_to(&mut self, y: f64, threshold: f64, rows: &[RowBand]) -> bool {
let mut changed = false;
if !self.active && (y - self.start_y).abs() >= threshold {
self.active = true;
changed = true;
}
if self.active {
if let Some(slot) = slot_for(rows, y) {
if slot != self.slot {
self.slot = slot;
changed = true;
}
}
}
changed
}
/// The reorder a release commits: `(from, to)` with `to` already
/// adjusted for the removal. `None` for a plain click (never activated)
/// or a drop back onto the row's own place.
pub fn commit(self) -> Option<(usize, usize)> {
if !self.active {
return None;
}
let to = if self.slot > self.from { self.slot - 1 } else { self.slot };
if to == self.from {
return None;
}
Some((self.from, to))
}
}
#[derive(Script, ScriptHook, Widget)]
pub struct ReorderList {
#[source]
source: ScriptObjectRef,
#[deref]
list: PortalList,
/// Child id of the drag gripper inside each item template
/// (`drag_handle: @gripper`). Zero (the default) disables reordering.
#[live]
drag_handle: LiveId,
/// The drop indicator: a flat quad drawn across the list in the gap the
/// dragged row would land in.
#[live]
draw_indicator: DrawColor,
/// Vertical travel (px) before a gripper press becomes a drag.
#[live(4.0)]
drag_threshold: f64,
#[rust]
drag: Option<ReorderDrag>,
/// Pointer y of the live drag — read by the edge auto-scroll pump so a
/// finger HELD at the viewport's edge keeps scrolling between moves.
#[rust]
drag_pointer_y: Option<f64>,
#[rust]
scroll_pump: NextFrame,
}
impl ReorderList {
/// The live drag as `(from, slot)`, once it has passed the threshold.
/// Hosts use this to tint the lifted row while drawing.
pub fn drag_state(&self) -> Option<(usize, usize)> {
self.drag.as_ref().filter(|d| d.active).map(|d| (d.from, d.slot))
}
/// The inner list's items that have actions in `actions` — the same
/// contract as `PortalListRef::items_with_actions`, offered here on the
/// widget because a downcast to `PortalList` no longer matches.
pub fn items_with_actions(&self, actions: &Actions) -> Vec<(usize, WidgetRef)> {
let uid = self.widget_uid();
let mut set = Vec::new();
for action in actions {
if let Some(action) = action.as_widget_action() {
if let Some(group) = &action.group {
if group.group_uid == uid {
for (item_id, item) in self.list.items().iter() {
if group.item_uid == item.widget.widget_uid() {
set.push((*item_id, item.widget.clone()));
}
}
}
}
}
}
set
}
/// The reorder this event pass delivered, if any: `(from, to)` indices
/// into the host's item range (`to` already adjusted for the removal).
pub fn reordered(&self, actions: &Actions) -> Option<(usize, usize)> {
let uid = self.widget_uid();
for action in actions {
if let Some(action) = action.as_widget_action() {
if action.widget_uid == uid {
if let ReorderListAction::Reordered { from, to } = action.cast() {
return Some((from, to));
}
}
}
}
None
}
/// Currently drawn rows as `(entry id, rect)`, in entry order.
fn visible_rows(&self, cx: &Cx) -> Vec<(usize, Rect)> {
let view = self.list.area().rect(cx);
let mut rows: Vec<(usize, Rect)> = self
.list
.items()
.iter()
.map(|(id, item): (&usize, &WidgetItem)| (*id, item.widget.area().rect(cx)))
.filter(|(_, r)| {
r.size.y > 0.0
&& r.pos.y + r.size.y > view.pos.y
&& r.pos.y < view.pos.y + view.size.y
})
.collect();
rows.sort_by_key(|(id, _)| *id);
rows
}
/// [`visible_rows`] in the drag machine's shape.
fn row_bands(&self, cx: &Cx) -> Vec<RowBand> {
self.visible_rows(cx).iter().map(|(id, r)| (*id, r.pos.y, r.size.y)).collect()
}
/// End the gesture without committing (Escape, or the dragged row left
/// the virtualised viewport).
fn cancel_drag(&mut self, cx: &mut Cx) {
self.drag = None;
self.drag_pointer_y = None;
self.list.redraw(cx);
}
/// Advance the gesture. `true` means the event belonged to the drag and
/// must NOT reach the inner list (that is what keeps a gripper drag from
/// also drag-scrolling the viewport).
fn handle_drag(&mut self, cx: &mut Cx, event: &Event) -> bool {
if self.drag_handle == LiveId(0) {
return false;
}
// A live drag: the finger capture on the gripper routes every move
// and the release here, wherever the pointer wanders.
if let Some(drag) = self.drag {
// Escape cancels outright. The stale capture in cx.fingers dies
// by itself at release; until then the swallowed pointer events
// keep the list from scroll-grabbing mid-gesture.
if let Event::KeyDown(ke) = event {
if ke.key_code == KeyCode::Escape {
self.cancel_drag(cx);
return true;
}
}
// A live drag is modal for the list: a wheel scroll would slide
// the rows away under the pointer.
if drag.active && matches!(event, Event::Scroll(_)) {
return true;
}
let mut drag = drag;
// The auto-scroll pump: rows slide under a HELD pointer, so each
// frame re-derives the drop slot from the fresh bands and keeps
// scrolling while the pointer stays in an edge band. NEVER
// swallowed: NextFrame is one shared event, and the inner list's
// own animation steps on the very same frame.
if self.scroll_pump.is_event(event).is_some() && drag.active {
if let Some(y) = self.drag_pointer_y {
let bands = self.row_bands(cx);
if drag.move_to(y, self.drag_threshold, &bands) {
self.list.redraw(cx);
}
self.drag = Some(drag);
self.drag_edge_autoscroll(cx);
}
}
// RAW window events, not `event.hits` on the gripper's area: the
// edge crawl can scroll the LIFTED row out of the virtualised
// viewport, and with it dies the widget whose area held the
// finger capture — hits-based routing cancelled the gesture the
// moment that happened. The drag is modal anyway; its identity
// is `drag.from`, not a live widget.
let moved_to = match event {
Event::MouseMove(e) => Some(e.abs.y),
Event::TouchUpdate(e) => e
.touches
.iter()
.find(|t| matches!(t.state, makepad_draw::makepad_platform::event::TouchState::Move | makepad_draw::makepad_platform::event::TouchState::Stable))
.map(|t| t.abs.y),
_ => None,
};
if let Some(y) = moved_to {
let bands = self.row_bands(cx);
if drag.move_to(y, self.drag_threshold, &bands) {
self.list.redraw(cx);
}
if drag.active {
cx.set_cursor(MouseCursor::Grabbing);
}
self.drag_pointer_y = Some(y);
self.drag = Some(drag);
if drag.active {
self.drag_edge_autoscroll(cx);
}
return true;
}
let released = matches!(event, Event::MouseUp(_))
|| matches!(event, Event::TouchUpdate(e)
if e.touches.iter().any(|t| matches!(t.state, makepad_draw::makepad_platform::event::TouchState::Stop)));
if released {
self.drag = None;
self.drag_pointer_y = None;
self.list.redraw(cx);
if let Some((from, to)) = drag.commit() {
let uid = self.widget_uid();
cx.widget_action(uid, ReorderListAction::Reordered { from, to });
}
return true;
}
// A second button pressed mid-drag rides the existing modal
// gesture: keep it away from the list, change nothing.
if matches!(event, Event::MouseDown(_)) {
return true;
}
return false;
}
// No drag yet: watch every visible gripper. Runs BEFORE the inner
// list handles the event, so a press on a gripper is captured here
// first and the swallowed event never starts a drag-scroll.
let mut start = None;
for (id, item) in self.list.items().iter() {
let handle = item.widget.widget(cx, &[self.drag_handle]);
if handle.is_empty() {
continue;
}
match event.hits(cx, handle.area()) {
Hit::FingerDown(e) if e.is_primary_hit() => {
start = Some((*id, e.abs.y));
}
Hit::FingerHoverIn(_) | Hit::FingerHoverOver(_) => {
cx.set_cursor(MouseCursor::Grab);
}
_ => {}
}
}
if let Some((from, y)) = start {
self.drag = Some(ReorderDrag::press(from, y));
return true;
}
false
}
/// Drag near (or past) the viewport's top/bottom edge scrolls the list
/// so a long list can be reordered end to end in one gesture (user,
/// 2026-08-27: "when moving maps we need to auto scroll if we're above
/// the top one or bottom one"). A gentle per-frame crawl, re-armed via
/// `scroll_pump` so holding still at the edge keeps it going.
fn drag_edge_autoscroll(&mut self, cx: &mut Cx) {
const BAND: f64 = 28.0;
const CRAWL: f64 = 9.0;
let Some(y) = self.drag_pointer_y else { return };
let view = self.list.area().rect(cx);
let first = self.list.first_id();
let scroll = self.list.first_scroll();
// A direct per-frame nudge: `smooth_scroll_to` refuses a target
// whose top already touches the viewport boundary, which is exactly
// the row an edge crawl starts from. The draw pass renormalizes
// (first_id, first_scroll) and clamps at both ends.
if y < view.pos.y + BAND {
if first > 0 || scroll < 0.0 {
// A POSITIVE offset mid-list is how the draw pass knows to
// pull the previous row in (the setter itself pins real
// overscroll at row 0) — clamping to 0 here froze the
// upward crawl after one row.
self.list.set_first_id_and_scroll(first, scroll + CRAWL);
self.list.redraw(cx);
self.scroll_pump = cx.new_next_frame();
}
} else if y > view.pos.y + view.size.y - BAND {
let last_fully_visible = self
.visible_rows(cx)
.last()
.is_some_and(|(id, rect)| {
*id + 1 >= self.list.range_end()
&& rect.pos.y + rect.size.y <= view.pos.y + view.size.y + 1.0
});
if !last_fully_visible {
self.list.set_first_id_and_scroll(first, scroll - CRAWL);
self.list.redraw(cx);
self.scroll_pump = cx.new_next_frame();
}
}
}
/// The line in the gap the dragged row would land in. Drawn after the
/// list's own pass, so it rides on top of the rows.
fn draw_drop_indicator(&mut self, cx: &mut Cx2d) {
let Some(drag) = self.drag else { return };
if !drag.active {
return;
}
let rows = self.visible_rows(cx);
let Some((last, last_rect)) = rows.last().copied() else { return };
let y = if let Some((_, rect)) = rows.iter().find(|(id, _)| *id == drag.slot) {
rect.pos.y - 4.0
} else if drag.slot == last + 1 {
last_rect.pos.y + last_rect.size.y + 2.0
} else {
return;
};
let view = self.list.area().rect(cx);
// The slot-0 gap sits above the first row, which is the viewport's
// own top edge when the list is scrolled home — unclamped, the line
// lands outside the list and is clipped away. Same for the last gap
// against the bottom edge.
let y = y.clamp(view.pos.y + 2.0, view.pos.y + view.size.y - 3.0);
self.draw_indicator.draw_abs(
cx,
Rect {
pos: dvec2(view.pos.x + 2.0, y - 1.0),
size: dvec2(view.size.x - 12.0, 2.0),
},
);
}
}
impl Widget for ReorderList {
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
if self.handle_drag(cx, event) {
return;
}
self.list.handle_event(cx, event, scope);
}
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
let step = self.list.draw_walk(cx, scope, walk);
if step.is_done() {
self.draw_drop_indicator(cx);
}
step
}
}
#[cfg(test)]
mod tests {
use super::{slot_for, ReorderDrag, RowBand};
/// Four rows of height 60 starting at y=100: ids 0..3 at 100/160/220/280.
fn rows() -> Vec<RowBand> {
(0..4).map(|i| (i, 100.0 + i as f64 * 60.0, 60.0)).collect()
}
#[test]
fn the_slot_is_the_first_row_whose_midpoint_is_below_the_pointer() {
let rows = rows();
assert_eq!(slot_for(&rows, 0.0), Some(0), "above everything: the very top");
assert_eq!(slot_for(&rows, 129.0), Some(0), "above row 0's midpoint (130)");
assert_eq!(slot_for(&rows, 131.0), Some(1), "below it: before row 1");
assert_eq!(slot_for(&rows, 250.0), Some(3), "exactly row 2's midpoint is already past it");
assert_eq!(slot_for(&rows, 311.0), Some(4), "below the last midpoint: after the end");
assert_eq!(slot_for(&[], 100.0), None, "no rows, no slot");
}
#[test]
fn a_press_only_becomes_a_drag_past_the_threshold() {
let rows = rows();
let mut drag = ReorderDrag::press(1, 170.0);
assert!(!drag.active);
assert!(!drag.move_to(172.0, 4.0, &rows), "2px of travel is still a click");
assert!(!drag.active);
assert_eq!(drag.commit(), None, "releasing a click reorders nothing");
assert!(drag.move_to(175.0, 4.0, &rows), "5px activates (a visual change)");
assert!(drag.active);
}
#[test]
fn the_slot_tracks_the_pointer_and_the_drop_commits_adjusted_indices() {
let rows = rows();
let mut drag = ReorderDrag::press(0, 110.0);
drag.move_to(255.0, 4.0, &rows);
assert_eq!(drag.slot, 3, "pointer past row 2's midpoint: before row 3");
// Slot 3 with the dragged row removed from index 0 = final index 2.
assert_eq!(drag.commit(), Some((0, 2)));
// Dragging upward: slot is the final index directly.
let mut drag = ReorderDrag::press(3, 290.0);
drag.move_to(120.0, 4.0, &rows);
assert_eq!(drag.slot, 0);
assert_eq!(drag.commit(), Some((3, 0)));
}
#[test]
fn dropping_a_row_back_onto_its_own_place_is_a_no_op() {
let rows = rows();
// Down past the threshold but still before its own successor's
// midpoint: slot 1 with from=0 adjusts back to index 0.
let mut drag = ReorderDrag::press(0, 110.0);
drag.move_to(170.0, 4.0, &rows);
assert_eq!(drag.slot, 1, "just under row 0: the gap between 0 and 1");
assert_eq!(drag.commit(), None, "that gap IS index 0 — nothing moved");
// Its own slot exactly.
let mut drag = ReorderDrag::press(2, 230.0);
drag.move_to(225.0, 4.0, &rows);
assert_eq!(drag.slot, 2);
assert_eq!(drag.commit(), None);
}
#[test]
fn moves_keep_reporting_only_real_changes() {
let rows = rows();
let mut drag = ReorderDrag::press(1, 170.0);
assert!(drag.move_to(200.0, 4.0, &rows));
assert!(!drag.move_to(201.0, 4.0, &rows), "same slot again: no redraw needed");
assert!(drag.move_to(260.0, 4.0, &rows), "new slot: redraw");
assert_eq!(drag.slot, 3);
}
#[test]
fn cancel_is_dropping_the_machine_nothing_pends() {
// Escape (or a vanished row) simply drops the machine; committing a
// copy afterwards would still be the caller's bug, and an inactive
// one never commits anyway.
let rows = rows();
let mut drag = ReorderDrag::press(0, 110.0);
drag.move_to(300.0, 4.0, &rows);
assert!(drag.commit().is_some(), "the drag WOULD commit");
let cancelled: Option<ReorderDrag> = None;
assert!(cancelled.map_or(true, |d: ReorderDrag| d.commit().is_none()));
}
}