Skip to main content

teksilo_scene/
scene_card.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! [`SceneCard`] — the chrome and the gesture regime of a heavyweight scene
5//! item, with **no opinion about what is inside it**.
6//!
7//! # Why it is a card and not a note
8//!
9//! A note container, a pinned image, an embedded web page, a chart, a group of
10//! dried ink and a sub-page thumbnail want the same five things — a surface, a
11//! grab handle, a selection state, an edit state, and an accessibility shape —
12//! and differ only in their body. Naming the type after one of those bodies
13//! would put text-specific policy (a document, a commit, a word count) into a
14//! crate whose whole claim is that the heavyweight tier is *any widget*. So the
15//! card owns the container and the app owns the content:
16//!
17//! ```ignore
18//! SceneCard::new(model.clone(), id)
19//!     .height_for_width()
20//!     .label(title.clone())
21//!     .header(TextWidget::new(title))
22//!     .body(RichTextEditor::editor(doc))
23//! ```
24//!
25//! # The gesture regime, which is the part that could not be written outside
26//!
27//! Three presses, three different owners, decided **structurally** rather than
28//! by a recognizer race:
29//!
30//! | press lands on | who owns the gesture | why |
31//! | --- | --- | --- |
32//! | the header | the header's own `on_drag` | the innermost node with a drag owns the sequence outright, and no ancestor is enrolled |
33//! | the body | whatever the body installed (a text field's selection drag) | no drag on the captured node, so the walk climbs — and stops at the card root's dead zone |
34//! | the card background | nothing but the card's own tap | the card root **is** the dead-zone boundary, so nothing above it arms |
35//!
36//! The load-bearing line is
37//! [`gesture_dead_zone(true)`](teksilo_core::widget_builder::WidgetBuilder::gesture_dead_zone)
38//! on the card root. Without it the `SceneView`'s marquee is enrolled as an
39//! ancestor of every press inside the card, and dragging from the middle of a
40//! note rubber-bands the page behind it. With it, a drag inside a card is the
41//! card's business and the canvas never sees it.
42//!
43//! The trailing header slot is wrapped in a
44//! [`DeadZone`](teksilo_widgets::primitives::DeadZone) of its own — the
45//! `Accordion` header precedent exactly — so the `⋮` button can be clicked
46//! *with the jitter a real click carries* without starting a move.
47//!
48//! # What drags a card, and what resizes it
49//!
50//! The **header** moves it, always. The selection frame the
51//! [transform controller](crate::transform_session) draws moves and resizes it
52//! too, once the card is selected — and that frame is drawn `padding` outside
53//! the selection precisely so it lands on pixels the card does not own. There
54//! is deliberately no third route: a body drag belongs to the body (that is how
55//! you select text in an embedded editor), and a card with no header is a card
56//! you move by its frame.
57//!
58//! Both routes end in the same model write. The header drag accumulates a
59//! scene-space translation, previews it as a node transform, runs it past the
60//! document's standing rule with
61//! [`SceneModel::constrain_move`](crate::SceneModel::constrain_move) — the same
62//! door the built-in drag uses, so a snap-to-grid rule cannot mean two things —
63//! and commits **once**, on release, through
64//! [`SceneModel::apply_transform_delta`](crate::SceneModel::apply_transform_delta).
65//! One gesture is one reversible step, and a cancelled gesture has nothing to
66//! roll back because nothing was written.
67//!
68//! # Modes, and why there is no `on_commit`
69//!
70//! [`CardMode`] is a `Signal` the **app owns**. The card writes into it
71//! (`Editing` on activation, `Selected` when focus leaves its subtree or `Esc`
72//! is pressed) and reads it for its chrome and its accessibility state. An app An app
73//! that wants to persist on commit observes that signal; there is no second
74//! `on_commit` callback, because the trigger for the most important case —
75//! focus leaving the subtree — is the framework's `focus_within` signal, which
76//! is written outside event dispatch. A callback there could not be handed an
77//! `EventContext`, so it would be a worse `Signal` with a misleading shape.
78//! [`on_activate`](SceneCard::on_activate) *does* take one, because an
79//! activation is a gesture or a key and has a dispatch to belong to.
80//!
81//! # Accessibility
82//!
83//! **One** `Role::Group` per card, named by [`label`](SceneCard::label),
84//! carrying `selected` when the mode says so and a custom **Edit** action that
85//! is the non-pointer twin of the double-click. It is a tab stop, so a keyboard
86//! user reaches every card with Tab, enters one with Enter and leaves with Esc.
87//!
88//! One, not two: the default surface is announced as a
89//! `Role::GenericContainer` with no properties, which is the role the
90//! accessibility walker prunes, promoting its children in order. Left alone,
91//! `Card` publishes a second, nameless `Role::Group` *inside* the card's own,
92//! and a screen reader reads that as a container within a container. The net
93//! result is node-for-node identical to the hand-rolled `Panel` a note page
94//! used before — with the container **named**, where the panel's was not.
95//! Pinned by `a_card_publishes_no_more_nodes_than_the_hand_rolled_panel_it_replaces`.
96//!
97//! Everything inside the card is walked by the framework's ordinary walker —
98//! the header's text, the trailing button, and a body that publishes
99//! `Role::TextRun` children all hang off the group in the emitted tree.
100//!
101//! They reach a screen reader, too. The default surface is
102//! `teksilo_widgets::Card`, whose `RecipeCardStyle` frame is a bare
103//! `Role::GenericContainer`: the walker prunes it, and
104//! `accesskit_consumer::common_filter`, which every platform adapter and this
105//! repo's own `accessibility::audit` read through, drops such a node while
106//! keeping its children. The frame once said "presentational" with
107//! `set_hidden()` instead, which that filter reads as `ExcludeSubtree`, and a
108//! card with a title and a body read `Window > Pane > Group "Note"` with
109//! nothing inside it. The parity test counts nodes in the update, where a
110//! hidden subtree still sits node for node, so it could not see that;
111//! `a_cards_title_and_body_reach_a_screen_reader` walks the filtered tree
112//! the way an adapter does and pins it.
113//!
114//! ## Tab stops, and the one thing the card does not decide
115//!
116//! The card contributes exactly **one** tab stop — itself, the object handle.
117//! It does not take its body's away while idle, and the reason is not
118//! reluctance: `set_tab_stop` reaches one node, and a composite body's tab
119//! stops are its own inner nodes. Nothing short of parking the body dormant
120//! takes a subtree out of the Tab ring.
121//!
122//! So a card with a focusable body is two stops, always. An app that wants one
123//! stop per idle note puts a [`Switcher`](teksilo_widgets::Switcher) in the
124//! body — a read-only viewer and an editor, driven by the same
125//! [`CardMode`] — because a `Switcher` parks its hidden branch dormant, which
126//! takes it out of focus, hit-testing *and* the accessibility tree while
127//! keeping it mounted, so a mode round-trip does not destroy the caret. That
128//! shape is the framework's answer to this question and the card defers to it
129//! rather than building a worse one.
130//!
131//! ## …and the one frame that shape costs
132//!
133//! Deferring to the `Switcher` has a consequence the card has to answer for:
134//! **the editor does not exist yet at the moment the user asks for it.**
135//! A double-click flips [`CardMode`] inside one dispatch, and
136//! `EventContext::request_focus_into` is drained at the end of that same
137//! dispatch — but the `Switcher`'s branch is parked until its `visible_when`
138//! gate is evaluated, which happens in the *next* layout pass. So the obvious
139//! code focuses a subtree that has nothing focusable in it, the caret never
140//! appears, and the two recommended shapes — one tab stop, and focus on
141//! activation — are mutually exclusive.
142//!
143//! The card closes that by asking twice. `CardHandle::activate` asks straight
144//! away, which is what a plain focusable body wants (instant, same dispatch),
145//! and a private zero-size `EditFocusGate` child asks again from
146//! [`BuildContext::run_after_mount`](teksilo_core::build_context::BuildContext::run_after_mount)
147//! — the one hook that hands a widget a real `EventContext` *after* the pass
148//! that mounts its siblings. The second ask is skipped when the card's
149//! `focus_within` already says the keyboard is inside, so a body that took the
150//! focus itself (or an `on_activate` that placed it deliberately) is never
151//! yanked back to the first field. The caret lands one frame after the
152//! double-click instead of zero, and it lands.
153//!
154//! The same gate is what makes the two-way [`mode`](SceneCard::mode) contract
155//! true from the outside: a toolbar button that writes `Editing` into the
156//! signal gets the body focused too, with no dispatch of its own to do it in.
157
158use std::cell::Cell;
159use std::rc::Rc;
160
161use teksilo_canvas::{Point, Rect, Size, SizeProposal, Transform2D, Vec2};
162use teksilo_core::accessibility::AccessNodeBuilder;
163use teksilo_core::binding::BindingLevel;
164use teksilo_core::build_context::BuildContext;
165use teksilo_core::event::{EventResponse, Key, Modifiers, WidgetEvent};
166use teksilo_core::gesture::DragPhase;
167use teksilo_core::signal::{Prop, Signal};
168use teksilo_core::styles::CardVariant;
169use teksilo_core::widget::{EventContext, LayoutContext, LayoutResponse, PendingChild, Widget};
170use teksilo_core::widget_builder::{HandlerSet, WidgetBuilder};
171use teksilo_core::widget_id::WidgetId;
172use teksilo_i18n::lit;
173
174use crate::item::ItemId;
175use crate::scene::SizePolicy;
176use crate::scene_model::SceneModel;
177use crate::selection::SceneSelection;
178use crate::transform_session::{TransformDelta, TransformFrame, TransformSource};
179
180/// AccessKit custom-action id of the card's Edit action.
181///
182/// Away from the low numbers: an application's own `.access_custom_action(..)`
183/// entries are numbered from 0 by position and published beside the card's, and
184/// the dispatcher hands one `CustomAction(id)` to the card and to those entries
185/// alike, so at 0 an application's first action would both share the card's id
186/// and put the card into editing.
187const EDIT_ACTION_ID: i32 = 1000;
188
189/// What a [`SceneCard`] is doing.
190///
191/// Three states rather than a `bool`, because `Selected` and `Editing` differ
192/// in *both* the accessibility shape and the gesture regime: a selected card is
193/// an object the canvas can move, and an editing card has handed the keyboard —
194/// arrows, Backspace, type-ahead — to its content.
195///
196/// `#[non_exhaustive]`: this crate has out-of-tree consumers.
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
198#[non_exhaustive]
199pub enum CardMode {
200    /// Not selected, not editing. The resting state.
201    #[default]
202    Idle,
203    /// Part of the scene selection: move, resize and delete apply.
204    Selected,
205    /// The body has the keyboard.
206    Editing,
207}
208
209impl CardMode {
210    /// Whether this mode reads as "selected" to assistive technology. `Editing`
211    /// does: a card being edited is the selected one.
212    pub fn is_selected(self) -> bool {
213        matches!(self, CardMode::Selected | CardMode::Editing)
214    }
215}
216
217/// In-flight state of a header drag. Lives behind an `Rc<Cell<..>>` because the
218/// `on_drag` closure outlives the `&self` that installed it.
219#[derive(Debug, Clone, Copy)]
220struct DragState {
221    /// The card's scene rect when the gesture started — the frame a geometry
222    /// constraint measures its answer against.
223    start: Rect,
224    /// Where the press landed, in the header's own coordinates, measured with
225    /// no preview in force. See [`SceneCard::build_header`] for why the drag is
226    /// anchored rather than accumulated.
227    anchor: Point,
228    /// Scene-space translation currently previewed — what the constraint
229    /// returned for the latest sample.
230    applied: Vec2,
231    /// The translation the pointer asked for, before the constraint. Recomputed
232    /// from scratch each sample rather than accumulated, so a rule that snaps to
233    /// a grid is always asked about the pointer's real displacement and never
234    /// about the remainder of its own previous answer.
235    raw: Vec2,
236}
237
238type Callback = Rc<dyn Fn(&mut EventContext)>;
239type Surface = Rc<dyn Fn(WidgetId) -> Box<dyn Widget>>;
240
241/// The second half of "enter edit mode and hand the keyboard to the body" —
242/// the half that cannot be done from the dispatch that starts the edit.
243///
244/// A zero-size child of the card whose only job is to notice `Editing`
245/// arriving and ask for the focus again **from the far side of a layout
246/// pass**, where a `Switcher`'s editor branch has finally been woken by its
247/// `visible_when` gate. See the `scene_card` module docs' "the one frame that
248/// shape costs" for why the obvious code cannot work.
249///
250/// It publishes no accessibility node and is not marked hidden to achieve
251/// that: emitting nothing leaves a content-free `Role::Unknown`, which is
252/// exactly what the walker collapses out of the tree. Marking it hidden would
253/// *add* a node, because the hidden flag is a property and a node carrying one
254/// is no longer content-free.
255///
256/// It is a widget rather than a method on the card because the trigger has to
257/// be a `BindingLevel::Rebuild` binding, and putting one of those on the card
258/// would rebuild the card's whole chrome on every `Idle` ↔ `Selected` flip —
259/// which is every click and every marquee. Here it rebuilds one leaf with no
260/// children, and only when the *editing* half of the mode changes.
261struct EditFocusGate {
262    /// The card's mode, narrowed to the only transition that matters.
263    editing: Signal<bool>,
264    /// The card's `focus_within`. `true` means the keyboard is already
265    /// somewhere inside the card, so the gate stays out of the way rather than
266    /// yanking it back to the body's first field.
267    focus_within: Signal<bool>,
268    /// What to focus into.
269    body: WidgetId,
270    /// What the previous build saw, so the gate fires on the **transition**
271    /// into `Editing` and not on every rebuild that happens to find it true.
272    was_editing: bool,
273}
274
275impl std::fmt::Debug for EditFocusGate {
276    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
277        f.debug_struct("EditFocusGate")
278            .field("editing", &self.editing.get())
279            .finish()
280    }
281}
282
283impl Widget for EditFocusGate {
284    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
285        let self_id = ctx.self_id();
286        self.editing
287            .bind_to(self_id, ctx.binding_registry(), BindingLevel::Rebuild);
288        let editing = self.editing.get();
289        let entering = editing && !self.was_editing;
290        self.was_editing = editing;
291        if entering && !self.focus_within.get() {
292            let body = self.body;
293            // Drained by the app loop after this layout pass (and by
294            // `WidgetTree::run_mount_actions` in a headless test), which is the
295            // first moment the editor branch is awake. `request_focus_into`
296            // leaves focus alone when the subtree still has nothing focusable,
297            // so a card whose body is an image or a chart is unaffected.
298            ctx.run_after_mount(move |ctx| ctx.request_focus_into(body));
299        }
300        vec![]
301    }
302
303    fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
304        Size::ZERO.into()
305    }
306}
307
308/// A heavyweight scene item's container: surface, header, body, modes,
309/// gestures and accessibility. See the `scene_card` module docs.
310pub struct SceneCard {
311    model: SceneModel,
312    item: ItemId,
313    header: Option<WidgetId>,
314    header_pending: Option<Box<dyn Widget>>,
315    trailing: Option<WidgetId>,
316    trailing_pending: Option<Box<dyn Widget>>,
317    body: Option<WidgetId>,
318    body_pending: Option<Box<dyn Widget>>,
319    surface: Option<Surface>,
320    mode: Signal<CardMode>,
321    selection: Option<SceneSelection>,
322    label: Prop<String>,
323    activate_label: Prop<String>,
324    movable: bool,
325    on_activate: Option<Callback>,
326    /// The drag preview, as a node-level self transform. Bound at
327    /// `RepaintOnly`: a card being dragged does not relayout, which is what
328    /// keeps a ten-card selection from re-laying-out per pointer sample.
329    preview: Signal<Transform2D>,
330    drag: Rc<Cell<Option<DragState>>>,
331    focus_within: Signal<bool>,
332    root: Option<WidgetId>,
333    /// What `build` returned, which is what `children` owes the arena.
334    child_ids: Vec<WidgetId>,
335}
336
337impl std::fmt::Debug for SceneCard {
338    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
339        f.debug_struct("SceneCard")
340            .field("item", &self.item)
341            .field("mode", &self.mode.get())
342            .field("movable", &self.movable)
343            .finish()
344    }
345}
346
347/// The part of a [`SceneCard`] its handlers need, as something a closure can
348/// own.
349///
350/// A `HandlerSet` closure is built once and lives in the arena long after the
351/// `&mut SceneCard` that installed it is gone, so the behaviour cannot live on
352/// `&self` — the same shape `TransformDriver` takes inside the view, and for
353/// the same reason.
354#[derive(Clone)]
355struct CardHandle {
356    model: SceneModel,
357    item: ItemId,
358    mode: Signal<CardMode>,
359    selection: Option<SceneSelection>,
360    body: Option<WidgetId>,
361    root: Option<WidgetId>,
362    on_activate: Option<Callback>,
363}
364
365impl CardHandle {
366    /// Enter edit mode: flip the signal and hand the keyboard to the body.
367    ///
368    /// The hand-over is asked for **twice**, and has to be: this dispatch can
369    /// only reach a body that is already mounted and awake, which the
370    /// recommended [`Switcher`](teksilo_widgets::Switcher) shape's editor branch
371    /// is not until the next layout pass. `EditFocusGate` asks again from the
372    /// far side of that pass. See the module docs' "the one frame that shape
373    /// costs".
374    fn activate(&self, ctx: &mut EventContext) {
375        let before = self.mode.get();
376        if let Some(f) = &self.on_activate {
377            f(ctx);
378        }
379        // **One rule: if the hook moved the mode, the hook decided.** It runs
380        // before the flip precisely so it can, and every way it might want to
381        // is the same write: `Editing` because it drove its own flow, anything
382        // else because it is refusing this one. Comparing against what the hook
383        // *found* rather than against a fixed value is what makes the refusal
384        // expressible at all — a double-click arrives here with the card
385        // already `Selected` (the press selected it), so "wrote `Selected`"
386        // cannot mean anything, while "wrote something other than what was
387        // there" can.
388        if self.mode.get() != before {
389            return;
390        }
391        self.select(ctx);
392        self.mode.set(CardMode::Editing);
393        if let Some(body) = self.body {
394            // A no-op when the body has nothing focusable, which is the right
395            // answer for an image or a chart: the card keeps the focus and the
396            // mode still reads `Editing` for whatever the app does with it.
397            ctx.request_focus_into(body);
398        }
399    }
400
401    /// Leave edit mode and take the keyboard back onto the card itself, so
402    /// `Tab` continues from the object rather than from inside it.
403    fn deactivate(&self, ctx: &mut EventContext) {
404        if self.mode.get() != CardMode::Editing {
405            return;
406        }
407        self.mode.set(CardMode::Selected);
408        if let Some(root) = self.root {
409            ctx.request_focus(root);
410        }
411    }
412
413    /// Select the card and bring it forward. The selection half is a no-op when
414    /// the app wired no [`SceneSelection`]; the z half is a model mutation, so
415    /// every view of this model restacks.
416    fn select(&self, _ctx: &mut EventContext) {
417        if let Some(sel) = &self.selection {
418            sel.select_one(self.item);
419        }
420        if self.mode.get() == CardMode::Idle {
421            self.mode.set(CardMode::Selected);
422        }
423        self.model.bring_to_front(self.item);
424    }
425}
426
427impl SceneCard {
428    /// A card bound to one heavyweight entry of `model`.
429    ///
430    /// The card **is** the widget the view's delegate returns for `item`:
431    ///
432    /// ```ignore
433    /// SceneView::with_model(model.clone())
434    ///     .delegate_typed::<Note>(move |note, id| {
435    ///         Box::new(SceneCard::new(model.clone(), id).body(view_of(note)))
436    ///     })
437    /// ```
438    pub fn new(model: SceneModel, item: ItemId) -> Self {
439        Self {
440            model,
441            item,
442            header: None,
443            header_pending: None,
444            trailing: None,
445            trailing_pending: None,
446            body: None,
447            body_pending: None,
448            surface: None,
449            mode: Signal::new(CardMode::Idle),
450            selection: None,
451            label: Prop::Static(String::new()),
452            activate_label: lit!("Edit").into(),
453            movable: true,
454            on_activate: None,
455            preview: Signal::new(Transform2D::identity()),
456            drag: Rc::new(Cell::new(None)),
457            focus_within: Signal::new(false),
458            root: None,
459            child_ids: Vec::new(),
460        }
461    }
462
463    /// The grab handle, and the card's title strip.
464    ///
465    /// A press here moves the card and nothing else — see the
466    /// `scene_card` module docs for the three-row table that makes that true.
467    ///
468    /// Takes a widget, a `Box<dyn Widget>` (the shape a card factory hands
469    /// back), or a [`WidgetId`] already in the tree — one method per slot, so
470    /// the caller does not pick a spelling to match what they happen to hold.
471    pub fn header(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
472        Self::put(
473            teksilo_core::IntoTeksiChild::into_pending(widget),
474            &mut self.header,
475            &mut self.header_pending,
476        );
477        self
478    }
479
480    /// A control at the trailing end of the header — a `⋮` menu, a colour
481    /// swatch, a pin.
482    ///
483    /// Wrapped in a [`DeadZone`](teksilo_widgets::primitives::DeadZone), so the
484    /// header drags everywhere **except** here and a click on the control with
485    /// a few pixels of jitter still reads as a click.
486    ///
487    /// Takes a widget, a boxed widget or a [`WidgetId`], like
488    /// [`header`](Self::header).
489    pub fn header_trailing(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
490        Self::put(
491            teksilo_core::IntoTeksiChild::into_pending(widget),
492            &mut self.trailing,
493            &mut self.trailing_pending,
494        );
495        self
496    }
497
498    /// The content. Anything at all: a text editor, an image, a chart, a
499    /// `SceneView` of its own.
500    ///
501    /// Not wrapped in anything — the card root's dead zone already covers it,
502    /// and wrapping would put a node between the body and the card that the
503    /// body's own gesture arena would have to argue with.
504    ///
505    /// Takes a widget, a boxed widget or a [`WidgetId`], like
506    /// [`header`](Self::header).
507    pub fn body(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
508        Self::put(
509            teksilo_core::IntoTeksiChild::into_pending(widget),
510            &mut self.body,
511            &mut self.body_pending,
512        );
513        self
514    }
515
516    /// File a slot's child into whichever of the two stores fits it, clearing
517    /// the other.
518    ///
519    /// The pair is what `build` reads (`id.or_else(|| pending.take())`), so a
520    /// method that only ever *set* its own store would make an id shadow a
521    /// widget handed over later: last call has to win, and that means the
522    /// losing store is cleared here rather than left to the resolution order.
523    fn put(child: PendingChild, id: &mut Option<WidgetId>, pending: &mut Option<Box<dyn Widget>>) {
524        match child {
525            PendingChild::Id(w) => {
526                *id = Some(w);
527                *pending = None;
528            }
529            PendingChild::Deferred(w) => {
530                *pending = Some(w);
531                *id = None;
532            }
533        }
534    }
535
536    /// Replace the chrome. The closure is handed the id of the card's content
537    /// (header plus body, already stacked) and returns the widget that wraps
538    /// it.
539    ///
540    /// The default is
541    /// `Card::new().variant(CardVariant::Elevated).content(content)`, so the
542    /// card is Tier-3 themed through the existing `style_slots.card` with no
543    /// new style protocol. Override it for a different variant, a per-card
544    /// colour, or a surface of your own:
545    ///
546    /// ```ignore
547    /// card.surface(|content| {
548    ///     Box::new(Card::new().variant(CardVariant::Outlined).content(content))
549    /// })
550    /// ```
551    pub fn surface(mut self, f: impl Fn(WidgetId) -> Box<dyn Widget> + 'static) -> Self {
552        self.surface = Some(Rc::new(f));
553        self
554    }
555
556    /// Bind the card's mode to an app-owned signal.
557    ///
558    /// Two-way: the card writes into it and reads from it, so a toolbar button
559    /// or a shortcut can put a card into `Editing` and the card will focus its
560    /// body. Observe it to persist on commit — see the `scene_card` module docs for
561    /// why there is no separate `on_commit`.
562    pub fn mode(mut self, mode: Signal<CardMode>) -> Self {
563        self.mode = mode;
564        self
565    }
566
567    /// The card's mode signal, for an app that did not supply one.
568    pub fn mode_signal(&self) -> Signal<CardMode> {
569        self.mode.clone()
570    }
571
572    /// The scene selection this card takes part in.
573    ///
574    /// Pass the same handle the view was given
575    /// ([`SceneView::selection_model`](crate::SceneView::selection_model)), so
576    /// a tap on the card and a marquee across it mean the same thing. Without
577    /// one the card still tracks its own [`CardMode`], but a tap selects
578    /// nothing.
579    pub fn selection(mut self, selection: SceneSelection) -> Self {
580        self.selection = Some(selection);
581        self
582    }
583
584    /// The card's accessible name. Locale-reactive when given a `tr!` string.
585    pub fn label(mut self, label: impl Into<Prop<String>>) -> Self {
586        self.label = label.into();
587        self
588    }
589
590    /// The name of the custom accessibility action that enters edit mode.
591    /// Defaults to an untranslated `"Edit"`.
592    pub fn activate_label(mut self, label: impl Into<Prop<String>>) -> Self {
593        self.activate_label = label.into();
594        self
595    }
596
597    /// Whether the header moves the card. `true` by default; `false` leaves the
598    /// header as an ordinary strip (and the selection frame as the only way to
599    /// move the card).
600    pub fn movable(mut self, movable: bool) -> Self {
601        self.movable = movable;
602        self
603    }
604
605    /// Give the card's **height** to its content:
606    /// [`SizePolicy::HeightForWidth`] on the entry.
607    ///
608    /// The width stays the model's — you resize a note by dragging its edge —
609    /// and the height follows the words on every pass that lays the card out.
610    ///
611    /// One consequence worth knowing before you reach for it: **the height axis
612    /// of the selection frame does nothing on such a card.** Not "writes a value
613    /// that is then corrected" — nothing at all. The scale's vertical component
614    /// is neutralised at the model door
615    /// ([`Scene::apply_transform_delta`](crate::Scene::apply_transform_delta)),
616    /// because a height written there is one the next pass measures straight
617    /// back over: the gesture's whole contribution would be a reversible step
618    /// that undoes nothing, and a top-edge drag would *move* the card rather
619    /// than resize it. Dragging a side or corner still changes the width, and
620    /// the words decide the rest. That is the intended reading of "the content
621    /// decides" — and it is why this is opt-in rather than the default.
622    ///
623    /// The **horizontal** half is an ordinary resize and converges: the preview
624    /// reflows the card live as the handle moves, nothing reaches the model
625    /// until the release, and the release writes the width once.
626    pub fn height_for_width(self) -> Self {
627        self.size_policy(SizePolicy::HeightForWidth)
628    }
629
630    /// [`height_for_width`](Self::height_for_width), stated in full. See
631    /// [`SizePolicy`].
632    pub fn size_policy(self, policy: SizePolicy) -> Self {
633        self.model.set_size_policy(self.item, policy);
634        self
635    }
636
637    /// Double-click, `Enter` on the focused card, or the AT **Edit** action.
638    ///
639    /// Runs **before** the mode flips, and **any write it makes to the mode is
640    /// the decision**: the card leaves the mode alone and does nothing further.
641    /// Write [`CardMode::Editing`] to drive an edit flow of your own, or
642    /// anything else to refuse this activation. A hook that writes nothing lets
643    /// the card flip to `Editing` as usual.
644    ///
645    /// The comparison is against the mode the hook *found*, not against a fixed
646    /// value, because a double-click arrives with the card already
647    /// [`Selected`](CardMode::Selected) — the press selected it — and a rule
648    /// phrased against a fixed value could not tell a refusal from the state
649    /// the gesture had already produced.
650    pub fn on_activate(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
651        self.on_activate = Some(Rc::new(f));
652        self
653    }
654
655    // ------------------------------------------------------------------
656    // Internals
657    // ------------------------------------------------------------------
658
659    /// The handlers' view of this card. Cheap: five handles and two `Option`s.
660    fn handle(&self) -> CardHandle {
661        CardHandle {
662            model: self.model.clone(),
663            item: self.item,
664            mode: self.mode.clone(),
665            selection: self.selection.clone(),
666            body: self.body,
667            root: self.root,
668            on_activate: self.on_activate.clone(),
669        }
670    }
671
672    /// The header strip: the app's header, a `Spacer`, and the trailing slot in
673    /// its own dead zone — with the drag that moves the card installed on the
674    /// row itself.
675    ///
676    /// `None` when the card has neither a header nor a trailing control, which
677    /// is a card you move by its selection frame.
678    fn build_header(&mut self, ctx: &mut BuildContext) -> Option<WidgetId> {
679        let header = self
680            .header
681            .or_else(|| self.header_pending.take().map(|w| ctx.add_boxed(w)));
682        let trailing = self
683            .trailing
684            .or_else(|| self.trailing_pending.take().map(|w| ctx.add_boxed(w)));
685        self.header = header;
686        self.trailing = trailing;
687        if header.is_none() && trailing.is_none() {
688            return None;
689        }
690
691        let mut row = teksilo_widgets::primitives::HStack::new().spacing(4.0);
692        if let Some(h) = header {
693            row = row.child(h);
694        }
695        row = row.child(teksilo_widgets::primitives::Spacer::new());
696        if let Some(t) = trailing {
697            // The `Accordion` precedent: the header drags everywhere except
698            // over its own controls. A `DeadZone` is the *innermost* enrolment
699            // boundary on a press that lands inside it, so the row's `on_drag`
700            // above is never enrolled — even for a click that jitters by a few
701            // pixels, which a recognizer-timing race would lose.
702            row = row.child(teksilo_widgets::primitives::DeadZone::new().child(t));
703        }
704
705        if !self.movable {
706            return Some(ctx.add(row.cursor(teksilo_core::widget::CursorIcon::Default)));
707        }
708
709        let model = self.model.clone();
710        let item = self.item;
711        let drag = self.drag.clone();
712        let preview = self.preview.clone();
713        Some(
714            ctx.add(row.cursor(teksilo_core::widget::CursorIcon::Move).on_drag(
715                move |phase, ctx| match phase {
716                    DragPhase::Started { position, .. } => {
717                        // Selection already happened, on the press, in the card
718                        // root's preview handler — one mechanism, not two, and
719                        // nothing here needs the card handle.
720                        let _ = &ctx;
721                        let start = model.scene_rect(item).unwrap_or(Rect::ZERO);
722                        drag.set(Some(DragState {
723                            start,
724                            anchor: position,
725                            applied: Vec2::ZERO,
726                            raw: Vec2::ZERO,
727                        }));
728                    }
729                    DragPhase::Moved { position, .. } => {
730                        let Some(mut state) = drag.get() else {
731                            return;
732                        };
733                        // **Anchored, not accumulated** — and the preview is
734                        // added back, which is the whole subtlety of moving a
735                        // widget from inside itself.
736                        //
737                        // `position` is localised against this node's *current*
738                        // bounds and transform, read live at dispatch. The
739                        // preview below moves the card, so the next sample is
740                        // measured against a node that has already travelled:
741                        // a pointer keeping pace with the card it is dragging
742                        // reports a **zero** local delta, which is why the
743                        // recogniser's own `delta` cannot be summed here (it
744                        // reads 40, 0, 40 for three even 40-unit moves). Adding
745                        // the preview back recovers the true displacement —
746                        // `D = (position − anchor) + previewed` — and it
747                        // recovers the part of the motion that crossed the drag
748                        // slop as well, which an accumulated delta loses
749                        // permanently and visibly: the card would trail the
750                        // cursor by the slop for the rest of the gesture.
751                        //
752                        // The units are **scene** units on both terms, at every
753                        // zoom, because a card's node-local space is
754                        // axis-aligned scene space translated to its origin —
755                        // the view's camera is a *content* transform one level
756                        // up, which the localisation has already undone. So no
757                        // view transform is needed here, and a card dragged at
758                        // 4× follows the pointer exactly as one at 1× does.
759                        state.raw = Vec2::new(
760                            position.x - state.anchor.x + state.applied.x,
761                            position.y - state.anchor.y + state.applied.y,
762                        );
763                        // The document's standing rule, through the public door
764                        // the built-in drag uses — so an app's snap-to-grid
765                        // cannot mean one thing for a lightweight item and
766                        // another for a card. Asked with the **raw** total each
767                        // sample, never with the remainder of its own previous
768                        // answer, so a grid rule does not accumulate rounding.
769                        state.applied = model.constrain_move(
770                            &[item],
771                            TransformFrame::new(state.start, 0.0, 1),
772                            state.raw,
773                            TransformSource::Pointer,
774                        );
775                        drag.set(Some(state));
776                        // Preview only. Nothing is written until the release,
777                        // which is what makes a cancelled drag have nothing to
778                        // roll back — the same discipline the selection
779                        // transform controller states in its module header.
780                        preview.set(Transform2D::translate(state.applied.x, state.applied.y));
781                    }
782                    DragPhase::Ended { position, .. } => {
783                        let Some(mut state) = drag.replace(None) else {
784                            return;
785                        };
786                        // The release resolves like any other sample, and it
787                        // has to: the recogniser reports the move that *crossed
788                        // the slop* as `Started`, at the press position and with
789                        // no delta, so a flick that produces one move and a
790                        // release yields `Started → Ended` and never a `Moved`
791                        // at all. Committing `state.applied` as it stood would
792                        // move such a card by nothing. Resolving here also makes
793                        // the committed position exact rather than one sample
794                        // behind the pointer.
795                        state.raw = Vec2::new(
796                            position.x - state.anchor.x + state.applied.x,
797                            position.y - state.anchor.y + state.applied.y,
798                        );
799                        state.applied = model.constrain_move(
800                            &[item],
801                            TransformFrame::new(state.start, 0.0, 1),
802                            state.raw,
803                            TransformSource::Pointer,
804                        );
805                        preview.set(Transform2D::identity());
806                        let delta = TransformDelta::new(
807                            Point::new(state.start.x, state.start.y),
808                            0.0,
809                            Vec2::new(1.0, 1.0),
810                            0.0,
811                            state.applied,
812                        );
813                        // One write, one transaction, one reversible step.
814                        model.apply_transform_delta(&[item], &delta);
815                    }
816                    _ => {
817                        // Cancelled. Drop the preview and the state; the model
818                        // was never touched.
819                        drag.set(None);
820                        preview.set(Transform2D::identity());
821                    }
822                },
823            )),
824        )
825    }
826}
827
828impl Widget for SceneCard {
829    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
830        let self_id = ctx.self_id();
831        self.root = Some(self_id);
832
833        // ---- content ------------------------------------------------
834        let header_row = self.build_header(ctx);
835        let body = self
836            .body
837            .or_else(|| self.body_pending.take().map(|w| ctx.add_boxed(w)));
838        self.body = body;
839
840        let mut stack = teksilo_widgets::primitives::VStack::new().spacing(0.0);
841        if let Some(h) = header_row {
842            stack = stack.child(h);
843        }
844        if let Some(b) = body {
845            // `respect_intrinsic` is the whole of the size story. Without it an
846            // `Expand` reports a basis of zero, which is right inside a stack
847            // with slack to share and wrong when the question is "how tall are
848            // you at this width" — the question `SizePolicy::HeightForWidth`
849            // asks, with an unbounded height. With it the body reports its
850            // content height when nothing is imposed and fills the box when
851            // something is, which is the same widget serving both policies.
852            stack = stack.child(
853                teksilo_widgets::primitives::Expand::vertical()
854                    .respect_intrinsic()
855                    .child(b),
856            );
857        }
858        let content = ctx.add(stack);
859
860        let surface = match &self.surface {
861            Some(f) => ctx.add_boxed(f(content)),
862            None => ctx.add(
863                teksilo_widgets::Card::new()
864                    .variant(CardVariant::Elevated)
865                    .content(content)
866                    // The card itself is the named `Role::Group`; the surface
867                    // is chrome. Left alone, `Card` publishes a second,
868                    // nameless `Role::Group` **inside** the named one, which a
869                    // screen reader reads as a container within a container.
870                    // `GenericContainer` with no properties is the role the
871                    // walker prunes, promoting its children in order.
872                    .access_role(accesskit::Role::GenericContainer),
873            ),
874        };
875
876        // ---- selection ring -----------------------------------------
877        // A sibling of the surface rather than part of it: the ring must sit
878        // *over* the content (a body that paints to its own edge would cover a
879        // border underneath it) and must not contribute to the card's measured
880        // height, which a `ZStack` child of zero intrinsic size does not.
881        let ring = ctx.add(
882            teksilo_widgets::primitives::RectWidget::new()
883                // One role, three widths. The colour is a role so it follows
884                // the theme (and desaturates with the window); the *mode* is
885                // expressed as thickness, which is what lets both be a single
886                // `RepaintOnly` binding instead of a rebuild.
887                .border_color(teksilo_tokens::BorderRole::Focused)
888                .border_width(self.mode.map(|m| match m {
889                    CardMode::Idle => 0.0,
890                    CardMode::Selected => 1.5,
891                    CardMode::Editing => 2.5,
892                }))
893                .corner_radius(teksilo_tokens::CornerRadius::uniform(8.0))
894                // Paint-only: the ring is chrome, and a pointer that lands on
895                // it has landed on the card.
896                .event_pass_through(true),
897        );
898        // **And deliberately not `access_hidden`.** A bare `RectWidget` emits a
899        // `Role::Unknown` node carrying nothing, which is exactly the shape
900        // `is_presentational_container` collapses out of the tree — so the ring
901        // already costs no accessibility node at all. Marking it hidden would
902        // *add* one: the hidden flag is a property, a node that carries a
903        // property is no longer content-free, and the walker would start
904        // publishing ten hidden boxes on a page of ten cards where it published
905        // none. Measured, ten cards: 10 `Unknown` nodes with the ring, 10
906        // without it, 30 with the ring marked hidden. Pinned by
907        // `a_card_publishes_no_more_nodes_than_the_hand_rolled_panel_it_replaces`.
908
909        // ---- the card's own node ------------------------------------
910        // The drag preview. `RepaintOnly` on purpose: a move is a visual
911        // translation until it commits, so a ten-card page costs a repaint per
912        // sample rather than a relayout per sample.
913        ctx.set_transform(self_id, self.preview.clone());
914        self.preview
915            .bind_to(self_id, ctx.binding_registry(), BindingLevel::RepaintOnly);
916        // The chrome follows the mode without a rebuild: both ring props are
917        // derived from this one signal.
918        self.mode
919            .bind_to(self_id, ctx.binding_registry(), BindingLevel::RepaintOnly);
920        // …and the accessibility state does too, which is a different level.
921        self.mode.bind_to(
922            self_id,
923            ctx.binding_registry(),
924            BindingLevel::AccessibilityOnly,
925        );
926
927        // Focus leaving the subtree is the commit. Strict-descendants-only, so
928        // the card's own focus does not flip it — which is what makes `Esc`
929        // (which focuses the card root) not read as a commit twice.
930        let mode_for_focus = self.mode.clone();
931        ctx.effect(&self.focus_within, move |within| {
932            if !*within && mode_for_focus.get() == CardMode::Editing {
933                mode_for_focus.set(CardMode::Selected);
934            }
935        });
936
937        let mut handlers = HandlerSet::new()
938            // The line that keeps the canvas out of the card. See the module
939            // docs' three-row table.
940            .gesture_dead_zone(true)
941            .focusable(true)
942            .focus_within(self.focus_within.clone());
943
944        {
945            // **Selection notices a press; it does not claim one.**
946            //
947            // `on_tap` on the card root would not fire at all for the case that
948            // matters — a click into an embedded editor. The body claims that
949            // press (anything focusable does), so the tap belongs to the body
950            // and no ancestor recogniser sees it, and "click a note's text and
951            // the note becomes the selected object" would not work.
952            //
953            // `on_pointer_event` runs on strict ancestors during the **preview**
954            // pass, before the target's own handlers, and on the target itself
955            // during the bubble — so this one closure covers a press on the
956            // card's background, its header and anything inside it. Returning
957            // `Ignored` is the whole discipline: the preview pass is where a
958            // `Handled` steals a descendant's press, which is the defect the
959            // scene's own pickers were built to stop doing. This notices and
960            // gets out of the way.
961            //
962            // On the **press**, not the release, which is the desktop
963            // convention for selecting an object (and what makes grabbing a
964            // card's header select it before the drag starts). The framework's
965            // "a press is not an activation" rule is about *activation* —
966            // committing a button, following a link — and a card's activation
967            // is the double-click below.
968            let this = self.handle();
969            handlers = handlers.on_pointer_event(move |event, ctx| {
970                if matches!(event, WidgetEvent::PointerDown { .. }) {
971                    this.select(ctx);
972                }
973                EventResponse::Ignored
974            });
975        }
976        {
977            let this = self.handle();
978            handlers = handlers.on_double_tap(move |_ev, ctx| {
979                this.activate(ctx);
980            });
981        }
982        {
983            let this = self.handle();
984            handlers = handlers.on_key(move |event, ctx| {
985                let WidgetEvent::KeyDown { key, modifiers, .. } = event else {
986                    return EventResponse::Ignored;
987                };
988                if *modifiers != Modifiers::NONE {
989                    return EventResponse::Ignored;
990                }
991                match key {
992                    Key::Enter => {
993                        this.activate(ctx);
994                        EventResponse::Handled
995                    }
996                    Key::Escape => {
997                        this.deactivate(ctx);
998                        EventResponse::Handled
999                    }
1000                    _ => EventResponse::Ignored,
1001                }
1002            });
1003        }
1004        {
1005            // `Esc` while the body has the focus. The preview pass reaches
1006            // strict ancestors only, so this never double-fires with the
1007            // `on_key` above — that one runs when the card itself is focused,
1008            // this one when a descendant is.
1009            let this = self.handle();
1010            handlers = handlers.on_key_preview(move |event, ctx| {
1011                let WidgetEvent::KeyDown { key, modifiers, .. } = event else {
1012                    return EventResponse::Ignored;
1013                };
1014                if *key == Key::Escape
1015                    && *modifiers == Modifiers::NONE
1016                    && this.mode.get() == CardMode::Editing
1017                {
1018                    this.deactivate(ctx);
1019                    return EventResponse::Handled;
1020                }
1021                EventResponse::Ignored
1022            });
1023        }
1024        {
1025            let this = self.handle();
1026            handlers = handlers.on_access_action_request(move |action, _node, data, ctx| {
1027                use accesskit::{Action, ActionData};
1028                match (action, data) {
1029                    (Action::Focus, _) => {
1030                        this.select(ctx);
1031                        EventResponse::Handled
1032                    }
1033                    (Action::CustomAction, Some(ActionData::CustomAction(EDIT_ACTION_ID))) => {
1034                        this.activate(ctx);
1035                        EventResponse::Handled
1036                    }
1037                    _ => EventResponse::Ignored,
1038                }
1039            });
1040        }
1041        ctx.apply_self_handlers(handlers);
1042
1043        // One child, not two: a `ZStack` already knows how to lay a ring over a
1044        // surface, and a hand-rolled `place_children` here would have to
1045        // reproduce the framework's absolute-origin convention — which is
1046        // exactly the kind of duplicate that drifts.
1047        let mut layers = teksilo_widgets::primitives::ZStack::new()
1048            // A `ZStack` sizes each child to its own measurement and
1049            // centres it; the surface has to *fill* the box the model gave
1050            // the card, or a short note paints a floating panel in the
1051            // middle of its own rectangle.
1052            .child(
1053                teksilo_widgets::primitives::Expand::new()
1054                    .respect_intrinsic()
1055                    .child(surface),
1056            )
1057            .child(ring);
1058        // The focus gate, when there is a body to focus into. Zero size, so a
1059        // `ZStack` layer costs nothing; content-free, so the accessibility
1060        // walker collapses it out of the tree the same way it collapses the
1061        // ring; no children, so its rebuild is one node.
1062        if let Some(b) = body {
1063            layers = layers.child(ctx.add(EditFocusGate {
1064                editing: self.mode.map(|m| *m == CardMode::Editing),
1065                focus_within: self.focus_within.clone(),
1066                body: b,
1067                was_editing: self.mode.get() == CardMode::Editing,
1068            }));
1069        }
1070        let stack = ctx.add(layers);
1071        self.child_ids = vec![stack];
1072        self.child_ids.clone()
1073    }
1074
1075    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
1076        // The surface decides; the ring is chrome and contributes nothing.
1077        // Asking the ring too would make an unbounded proposal answer `0`, and
1078        // `HeightForWidth` asks exactly that.
1079        let size = self
1080            .child_ids
1081            .first()
1082            .and_then(|id| ctx.child_size(*id, proposal))
1083            .unwrap_or_else(|| proposal.resolve(0.0, 0.0));
1084        size.into()
1085    }
1086
1087    fn children(&self) -> Vec<WidgetId> {
1088        self.child_ids.clone()
1089    }
1090
1091    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1092        builder.set_role(accesskit::Role::Group);
1093        let name = self.label.get();
1094        if !name.is_empty() {
1095            builder.set_name(name);
1096        }
1097        builder.set_selected(self.mode.get().is_selected());
1098        builder.add_action(accesskit::Action::Focus);
1099        // The non-pointer twin of the double-click. One custom action, at
1100        // `EDIT_ACTION_ID` — and the action itself is advertised by
1101        // [`AccessNodeBuilder::set_custom_actions`](teksilo_core::accessibility::AccessNodeBuilder::set_custom_actions),
1102        // which owns that half so no caller can publish a list an adapter will
1103        // never report. See its docs for why the list alone is decoration.
1104        builder.set_custom_actions(vec![accesskit::CustomAction {
1105            id: EDIT_ACTION_ID,
1106            description: self.activate_label.get(),
1107        }]);
1108    }
1109
1110    fn as_any(&self) -> Option<&dyn std::any::Any> {
1111        Some(self)
1112    }
1113
1114    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
1115        Some(self)
1116    }
1117}