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}