Dragging
Dragging in ratcn is not a property of any one component. It is a small, shared mechanism that any component — a built-in like Dialog, or one you write yourself — can opt into. The position being dragged is ordinary app-owned state, moved the same way focus is: the component emits a message, your update persists it. See State and messages for the ownership boundary.
Drag the panel below by clicking anywhere on it and moving the mouse.
The panel in that demo is not a library component — it is an ordinary registered component written in the app, which is the point: the same pieces that make Dialog draggable are available to your own components.
The lifecycle helper
Making something draggable takes three pieces:
- An app-owned offset. A
CellOffset { x, y }lives in your state. The declaration passes its current value and persists changes through anon_changemessage. EventCtx::drag. Pass each mouse event andDragOptions. The helper matches the left button by default, anchors the initial offset, captures onDown, and retains capture and movement state by declaration path across rebuilds.- Phase handling.
Downconsumes the press,Moved { offset, position }updates app state, andEnded { position, moved }handles a click-release or commits a drop. Unrelated events produceIgnored.
The Drag events arrive ready-made. The mouse layer turns a button-held move into MouseKind::Drag (see the event model), so the component does not infer held-button state from Moved events.
Making a built-in draggable
Dialog exposes the offset pair directly. Wire it and the dialog becomes draggable by its border:
use ratcn::{Dialog, runtime::CellOffset};
// In your app state:
struct AppState {
dialog_offset: CellOffset,
// ...
}
// In update:
Msg::DialogMoved(offset) => state.dialog_offset = offset,
// When building the dialog:
let dialog = Dialog::new()
.offset(state.dialog_offset)
.on_offset_change(Msg::DialogMoved)
.title("Confirm")
// ...;
let area = ctx.area();
ctx.modal("confirm", dialog, area);The durable offset remains yours, in app state. Events use the dialog geometry and resolved offset from the last successful render. Dialog calls the same lifecycle helper with a start policy that requires both an offset handler and a border hit. Pointer capture continues outside the box until release, and the dialog clamps every emitted offset to the screen.
Making your own component draggable
A component becomes draggable with the same parts. The essentials, from the demo's DragPanel:
use ratcn::runtime::{CellOffset, Component, DeclareCtx, DragOptions, DragPhase,
Event, EventCtx, EventResult, clamp_offset};
struct DragPanel {
read_offset: Box<dyn Fn(&AppState) -> CellOffset>,
on_change: Box<dyn Fn(CellOffset) -> Msg>,
frame_area: Rect,
}
impl Component<AppState, Msg> for DragPanel {
fn declare(
&mut self,
ctx: &mut DeclareCtx<'_, AppState, Msg>,
) {
let area = ctx.area();
// `area` is the panel box already computed by the declaration.
// ...draw the box at `area`...
}
fn handle_event(
&mut self,
event: &Event,
state: &AppState,
ctx: &mut EventCtx<'_>,
) -> EventResult<Msg> {
let Event::Mouse(mouse) = event else {
return EventResult::Ignored;
};
let point = Position::new(mouse.column, mouse.row);
let can_start = self.box_rect(state).contains(point);
match ctx.drag(
mouse,
DragOptions::new((self.read_offset)(state)).start_if(can_start),
) {
DragPhase::Down | DragPhase::Ended { .. } => EventResult::Consumed,
DragPhase::Moved { offset, .. } => {
let clamped = clamp_offset(
self.frame_area,
Self::centered_rect(self.frame_area),
offset,
);
EventResult::Emit((self.on_change)(clamped))
}
DragPhase::Ignored => EventResult::Ignored,
}
}
}Two details worth noting:
- Gesture state follows identity.
EventCtx::dragstores its transient by declaration path, so replacement and reordering do not interrupt a captured gesture while that path remains present. Durable position still belongs in app state. - You choose the handle and the bounds. The
start_ifpolicy decides what is draggable (here, anywhere in the box;Dialoguses just its border). TheMovedphase decides how far it can go --clamp_offsetkeeps a box inside an area, but a resizable pane would clamp to min/max sizes instead. The helper imposes neither.
Dropping onto a target
Free movement only needs the offset; drag and drop — releasing a dragged thing onto a target — additionally uses DragPhase::Ended. Its position is the current release coordinate, not the last movement coordinate, and moved distinguishes a drag from a click-without-movement. A component can therefore hit-test exactly where the drop landed.
Drag a card to another column below; releasing it commits the move.
The board is, again, an ordinary app-written component. Its phase handling:
match ctx.drag(mouse, DragOptions::default().start_if(state.drag.is_none())) {
DragPhase::Down => EventResult::Consumed,
// The first move picks the card up; later moves update its position.
DragPhase::Moved { offset, .. } => {
/* Emit(DragStarted { card, offset }) or DragMoved(offset) */
}
DragPhase::Ended { position, moved: true } => {
EventResult::Emit(Msg::CardDropped {
column: self.board.column_index_at(position.x),
})
}
DragPhase::Ended { moved: false, .. } => EventResult::Consumed,
DragPhase::Ignored => EventResult::Ignored,
}While a card is dragged, its slot renders as an empty bordered placeholder — the stack never reflows mid-drag, and an aborted drag has nowhere to "jump back" from. Which column each card sits in, and the active drag, are both plain app state — the drop is just one more message through update.
The dragged card shifts its original slot by Moved.offset, preserving the cell where the press began instead of centering the card under the pointer.
The demo creates its cards at launch. Each card's creation number becomes both its displayed label and its dynamic id (number.to_string().into() builds the ChildId::Dynamic). The app passes a reference to that stored id in each declaration, so identity follows the card when it moves between columns.
The floating dragged card is passive paint scheduled with DeclareCtx::defer_paint. Deferred paint runs after ordinary declarations in the current layer and has no identity, geometry, focus, hover, or hit target; the card's declared slot remains the interaction source. The dragged card clears its area before painting so border and separator glyphs underneath cannot show through. See Layers and modals for paint ordering.
Where drag events come from
Drag events are synthesized, not raw. Ratcn owns one tracker, so you feed plain Down/Up/Moved to handle_event and a button-held move arrives as MouseKind::Drag (a Down/Up on one component as MouseKind::Click, and the release of a claimed drag as MouseKind::DragEnd) before routing — no separate tracker to wire:
if let EventResult::Emit(msg) = ratcn.handle_event(event, &state) {
update(&mut state, msg);
}Because the offset is app state and moves are emitted live, dragging needs nothing special from the render loop: the message updates state, the next frame draws the new position — the same one-event-one-message flow as every other interaction.
Hover freezes for the length of the gesture. From the press to the release, the runtime keeps hover on whatever the gesture started on instead of following the pointer: the thing being dragged moves under a pointer that is by definition on it, and the panel it passes over is not something the user is pointing at. So a dragged component can style itself with PaintCtx::hovered throughout, and nothing beneath the drag lights up on the way past. The freeze holds the path, so a dragged target redeclared at its new position keeps painting hovered; if it is covered by a modal or stops being declared, it loses hover on that frame even though the gesture continues.
What stays your responsibility
The helper is deliberately small, so these remain component or app policy:
- Clamping policy.
clamp_offsetcovers "keep this box on screen." Other rules (snapping, min/max sizes) are the component's own. - Start hit-testing. Compute handle eligibility and pass it to
start_if. - Durable identity and drop state. Card identity, dragged-card paint, and target policy stay in app state and rendering code.
If the thing being dragged stops being declared mid-gesture, the runtime ends the capture cleanly, so the release cannot land on whatever is now under the pointer. It cannot know what the drag meant, though — clear your own drag state in the same update that removes the thing.
For gestures that do not fit this shape, EventCtx::transient and EventCtx::capture_pointer are public and documented on docs.rs.