Skip to content

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:

  1. An app-owned offset. A CellOffset { x, y } lives in your state. The component reads it through an offset accessor and persists changes through an on_change message — exactly the read+write pairing used for focus and hover.
  2. The Drag primitive. 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.
  3. Three mouse arms. In handle_event, react to Down (begin), Drag (emit the new offset), and Up (end) — or DragEnd when 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:

rust
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:

rust
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 render takes &mut self, the last-rendered area is a plain field — there is no Rc<Cell> or hidden cache. This is the recommended shape; Dialog follows it too.
  • You choose the handle and the bounds. The Down arm decides what is draggable (here, anywhere in the box; Dialog uses just its border). The Drag arm decides how far it can goclamp_offset keeps 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:

rust
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:

rust
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_offset covers "keep this box on screen." Other rules (snapping, min/max sizes) are the component's own.