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 and hover are: the component emits a message, your update persists it.
(This page is about dragging components with the mouse — including dropping a dragged element onto a target. Dropping files from the OS onto the app is an unrelated mechanism that arrives like a paste — see File drop.)
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 three pieces
Making something draggable takes three things, none of them large:
- An app-owned offset. A
CellOffset { x, y }lives in your state. The component reads it through anoffsetaccessor and persists changes through anon_changemessage — exactly the read+write pairing used for focus and hover. - The
Dragprimitive. A tiny state machine (ratcn::view::Drag) that remembers where a press began and turns later pointer positions into a new offset. It is meaning-agnostic: what the offset moves (a box, a divider, a pane edge) is up to you. - Three mouse arms. In
handle_event, react toDown(begin),Drag(emit the new offset), andUp(end) — orDragEndwhen the release itself means something, like dropping onto a target (see below).
The Drag events arrive ready-made. The mouse layer turns a button-held move into MouseKind::Drag (see the event model), so you never track button state yourself.
Making a built-in draggable
Dialog exposes the offset pair directly. Wire it and the dialog becomes draggable by its border:
use ratcn::{Dialog, view::CellOffset};
// In your app state:
struct AppState {
dialog_offset: CellOffset,
// ...
}
// In update:
Msg::DialogMoved(offset) => state.dialog_offset = offset,
// When building the dialog:
Dialog::new("confirm")
.offset(|s: &AppState| s.dialog_offset)
.on_offset_change(Msg::DialogMoved)
.title("Confirm")
// ...The offset is never stored inside the dialog — it is yours, in app state, like every other piece of UI state. The dialog clamps it so the box stays on screen and emits a new offset on every drag step, so the move is live.
Making your own component draggable
A component becomes draggable with the same parts. The essentials, from the demo's DragPanel:
use ratcn::view::{Component, Drag, Event, EventResult, MouseKind, clamp_offset, offset_rect};
struct DragPanel {
read_offset: Box<dyn Fn(&AppState) -> CellOffset>,
on_change: Box<dyn Fn(CellOffset) -> Msg>,
drag: Drag, // the primitive — a plain field
last_area: Rect, // captured each render, for hit-testing
}
impl Component<AppState, Msg> for DragPanel {
fn render(&mut self, frame: &mut Frame, area: Rect, state: &AppState, ctx: &RenderCtx) {
self.last_area = area;
let rect = offset_rect(area, base(area), (self.read_offset)(state));
// ...draw the box at `rect`...
}
fn handle_event(&mut self, event: &Event, state: &AppState) -> EventResult<Msg> {
let Event::Mouse(mouse) = event else {
return EventResult::Ignored;
};
let point = Position::new(mouse.column, mouse.row);
match mouse.kind {
// 1. Press on the handle: anchor the drag to the current offset.
MouseKind::Down(_) if rect_for(self, state).contains(point) => {
self.drag.begin(mouse.column, mouse.row, (self.read_offset)(state));
EventResult::Consumed
}
// 2. Move: emit the new offset (clamp it however you like).
MouseKind::Drag(_) if self.drag.is_active() => {
match self.drag.offset_at(mouse.column, mouse.row) {
Some(next) => {
let clamped = clamp_offset(self.last_area, base(self.last_area), next);
EventResult::Emit((self.on_change)(clamped))
}
None => EventResult::Consumed,
}
}
// 3. Release: end the drag.
MouseKind::Up(_) if self.drag.end() => EventResult::Consumed,
_ => EventResult::Ignored,
}
}
}Two details worth noting:
- No interior mutability. Because a registered component's
rendertakes&mut self, the last-rendered area is a plain field — there is noRc<Cell>or hidden cache. This is the recommended shape;Dialogfollows it too. - You choose the handle and the bounds. The
Downarm decides what is draggable (here, anywhere in the box;Dialoguses just its border). TheDragarm 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 primitive imposes neither.
Dropping onto a target
Free movement only needs the offset; drag and drop — releasing a dragged thing onto a target — additionally needs the release. That is MouseKind::DragEnd: synthesized after the Up of any press that dragged, carrying the cell it was released on, so a component can hit-test where the drop landed. (A dragged press never also emits Click — the gesture was a drag, not a click.)
Drag a card to another column below; releasing it commits the move.
The board is, again, an ordinary app-written component. Its arms:
match mouse.kind {
// Press on a card only *arms* the drag (a local field, like the Drag
// primitive) — a plain click resolves without any state to clean up.
MouseKind::Down(_) if state.drag.is_none() => { /* self.pressed = card under point */ }
// The first drag step picks the armed card up; later steps move it.
MouseKind::Drag(_) => { /* Emit(DragStarted { card, .. } or DragMoved { column, row }) */ }
// The release commits: whichever column it landed on is home now.
MouseKind::DragEnd(_) if state.drag.is_some() => {
EventResult::Emit(Msg::CardDropped {
column: self.column_index_at(mouse.column),
})
}
// A release that never dragged just disarms the press.
MouseKind::Up(_) => { /* self.pressed = None */ }
_ => 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.
Where drag events come from
Drag events are synthesized, not raw. The view tracks the press internally, so you feed plain Down/Up/Moved to handle_event and a button-held move arrives as MouseKind::Drag (a Down/Up on the same cell as MouseKind::Click, and the release of a dragged press as MouseKind::DragEnd) before routing — no separate tracker to wire:
if let EventResult::Emit(msg) = view.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.
What stays your responsibility
The primitive is deliberately small, so a few things are left to the component:
- Pointer capture. The demo panel owns the whole area and floats its box inside it, so the pointer never leaves it mid-drag. A component placed in a small sub-region would need the pointer kept captive while dragging; that is not yet provided by the view layer.
- Clamping policy.
clamp_offsetcovers "keep this box on screen." Other rules (snapping, min/max sizes) are the component's own.