teksilo-widgets 0.9.0

Widget library for Teksilo — over a hundred widgets and layout primitives, from Button to TreeTableView.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 FernTech

//! Pointer and scroll dispatch for the rich text editor.
//!
//! Owns three handler entry points that the widget installs in
//! `build()`:
//!
//!  * [`handle_pointer_event`] — PointerDown (caret placement +
//!    drag-select start), PointerMove (drag-select extension and
//!    auto-scroll velocity computation), PointerUp (drag teardown).
//!    Returns `EventResponse::Ignored` for PointerDown so the
//!    gesture arena's `DoubleTapRecognizer` / `TripleTapRecognizer`
//!    also see the event.
//!  * [`handle_scroll`] — mouse wheel / trackpad translation into
//!    `scroll_x` / `scroll_y` signal updates.
//!  * [`handle_double_tap`] / [`handle_triple_tap`] — word and
//!    paragraph selection on successive clicks. The independent
//!    cooperative recognizers in `teksilo-core::gesture` guarantee that
//!    both fire in an escalating click sequence.

use std::cell::Cell;
use std::rc::Rc;

use teksilo_canvas::{Point, Rect};
use teksilo_core::event::{EventResponse, PointerButton, ScrollDelta, WidgetEvent};
use teksilo_core::widget::{CursorIcon, EventContext};
use teksilo_text::text_document::{MoveMode, SelectionType};

use super::hit_test;
use super::state::{DragState, SharedState};
use super::sync_cursor_signals;

/// Convert a **wrapper-node-local** pointer position (as delivered by the
/// framework dispatch) into the **engine/body-local** space that
/// text-typeset's `hit_test` expects. The body is inset within the
/// wrapper, so reconstruct the window point (`position + node_origin`)
/// and subtract the body origin (`viewport_origin`). At wrapper-origin 0
/// this reduces to `position - viewport_origin`.
fn to_engine_local(state: &SharedState, position: &Point) -> Point {
    let st = state.borrow();
    Point::new(
        position.x + st.node_origin.x - st.viewport_origin.x,
        position.y + st.node_origin.y - st.viewport_origin.y,
    )
}

/// Smallest side a resize may leave, in logical pixels.
///
/// A picture dragged to nothing cannot be grabbed again — its handles would
/// have nowhere to sit — so the drag stops here rather than letting the writer
/// lose the image behind an undo.
const RESIZE_MIN_EDGE: f32 = 24.0;

/// How far outside a grip's own square a press still counts.
///
/// The grip is drawn small so it does not cover a thumbnail; this is what makes
/// it hittable without care. Generous on purpose — the cost of overshooting is
/// a resize the writer did not want, which one Escape or Ctrl+Z undoes, while
/// the cost of undershooting is a feature that feels broken.
const RESIZE_HANDLE_SLOP: f32 = 5.0;

/// The corner grip under `local`, if the selected image has one there.
///
/// Returns the image's name, its rect, its offset, and which corner was taken.
fn grabbed_handle(
    state: &SharedState,
    local: Point,
) -> Option<(String, [f32; 4], usize, (f32, f32))> {
    let selected = state.borrow().selected_image.borrow().clone()?;
    let corner = handle_at(selected.rect, local)?;
    Some((selected.name, selected.rect, selected.offset, corner))
}

/// Which corner of `rect` a press at `local` grabs, if any.
///
/// Split out from [`grabbed_handle`] so the aiming rule — the half of this that
/// decides whether the feature is usable — can be tested without a widget tree,
/// a window, or a pointer device.
fn handle_at(rect: [f32; 4], local: Point) -> Option<(f32, f32)> {
    let [x, y, w, h] = rect;
    let reach = super::paint::RESIZE_HANDLE_SIZE / 2.0 + RESIZE_HANDLE_SLOP;
    super::paint::RESIZE_CORNERS.into_iter().find(|&(fx, fy)| {
        let (cx, cy) = (x + w * fx, y + h * fy);
        (local.x - cx).abs() <= reach && (local.y - cy).abs() <= reach
    })
}

/// The rect a corner drag proposes, with the picture's proportions kept.
///
/// The corner opposite the one being dragged stays put, so the gesture reads
/// the way it does everywhere else. Proportions are kept by following whichever
/// axis the pointer moved *proportionally* further — taking width alone would
/// ignore a drag that was mostly vertical, and averaging the two makes a
/// diagonal drag lag behind the pointer on both.
///
/// The result is always positioned at the image's original top-left: the
/// picture sits in a text flow, so the layout decides where it lands once the
/// size changes. Anchoring the preview anywhere else would show the writer a
/// position the reflow is about to contradict.
fn proportional_resize(origin: [f32; 4], corner: (f32, f32), local: Point) -> [f32; 4] {
    let [x, y, w, h] = origin;
    if w <= 0.0 || h <= 0.0 {
        return origin;
    }
    // The fixed corner is the diagonal opposite of the grabbed one.
    let anchor_x = x + w * (1.0 - corner.0);
    let anchor_y = y + h * (1.0 - corner.1);
    let dragged_w = (local.x - anchor_x).abs();
    let dragged_h = (local.y - anchor_y).abs();

    let sx = dragged_w / w;
    let sy = dragged_h / h;
    let scale = if (sx - 1.0).abs() >= (sy - 1.0).abs() {
        sx
    } else {
        sy
    };
    let scale = scale.max(RESIZE_MIN_EDGE / w.min(h));
    [x, y, (w * scale).max(1.0), (h * scale).max(1.0)]
}

/// How far the pointer must travel from a press inside the selection before it
/// counts as dragging that text rather than as a click that happened to wobble.
const TEXT_DRAG_THRESHOLD: f32 = 4.0;

/// Whether `offset` falls inside the current selection.
///
/// The end boundary is excluded so a click at the very edge of a selection
/// still collapses it — otherwise the only way out of a selection ending at the
/// caret would be to click somewhere else entirely.
fn selection_contains(state: &SharedState, offset: usize) -> bool {
    let st = state.borrow();
    if !st.cursor.has_selection() {
        return false;
    }
    let (a, b) = (st.cursor.anchor(), st.cursor.position());
    let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
    (lo..hi).contains(&offset)
}

/// Begin dragging the current selection.
///
/// The payload carries the `DocumentFragment` for editors (formatting intact)
/// *and* a `text/plain` MIME alternative, which is what lets the same gesture
/// continue into another application: the framework escalates an in-app drag to
/// a native OS drag at the window boundary when the payload has MIME data, so
/// dragging prose into a text editor costs nothing extra here.
fn start_text_drag(state: &SharedState, ctx: &mut EventContext) {
    let payload = {
        let st = state.borrow();
        let Some(source) = st.self_id else {
            return;
        };
        if !st.cursor.has_selection() {
            return;
        }
        let (a, b) = (st.cursor.anchor(), st.cursor.position());
        let range = if a <= b { (a, b) } else { (b, a) };
        let fragment = st.cursor.selection();
        let text = fragment.to_plain_text().to_string();
        if text.is_empty() {
            return;
        }
        teksilo_core::DragPayload::typed(super::EditorTextDrag {
            source,
            range,
            fragment,
            text: text.clone(),
        })
        .with_mime("text/plain", text.into_bytes())
    };
    let source = state.borrow().self_id;
    if let Some(source) = source {
        ctx.start_drag(source, payload);
    }
    state.borrow_mut().drag_state = DragState::Idle;
    ctx.request_frame();
}

/// Insert dragged editor text at the caret, removing the original when the drop
/// landed in the editor it came from.
///
/// Returns whether anything was accepted.
pub(super) fn apply_text_drop(
    state: &SharedState,
    drag: &super::EditorTextDrag,
    same_editor: bool,
) -> bool {
    let (lo, hi) = drag.range;
    let drop_at = state.borrow().cursor.position();

    if !same_editor {
        let st = state.borrow();
        super::keyboard::collapse_selection_before_insert(&st);
        let _ = st.cursor.insert_fragment(&drag.fragment);
        return true;
    }

    // A same-editor drop is a *move*: the passage is removed from where it was.
    // That is a deletion by mouse, so it answers to the same filter Backspace
    // does — otherwise a forward-only surface could still be emptied out one
    // dragged phrase at a time. Refuse rather than degrade to a copy: silently
    // duplicating the passage would be a stranger outcome than nothing
    // happening. (Dragging *between* editors above is a copy and stays allowed:
    // the source keeps its text.)
    if !state
        .borrow()
        .policy
        .command_filter
        .accepts(super::policy::EditCommandKind::Cut)
    {
        return false;
    }

    // Dropped inside the very text being dragged: there is no move to make, and
    // deleting the range would destroy the selection the writer was carrying.
    // Accept it so the drag ends here rather than bubbling to a parent that
    // would act on it.
    if (lo..=hi).contains(&drop_at) {
        return true;
    }

    // Remove the original first, then insert. Deleting shifts everything after
    // the range left by its length, so a drop *after* the range has to be
    // re-based or the text lands that many characters too far right.
    {
        let st = state.borrow();
        st.cursor.set_position(lo, MoveMode::MoveAnchor);
        st.cursor.set_position(hi, MoveMode::KeepAnchor);
        let _ = st.cursor.remove_selected_text();
        let target = if drop_at > hi {
            drop_at - (hi - lo)
        } else {
            drop_at
        };
        st.cursor.set_position(target, MoveMode::MoveAnchor);
        let _ = st.cursor.insert_fragment(&drag.fragment);
    }
    true
}

pub(super) fn handle_pointer_event(
    state: &SharedState,
    v_scrollbar_bounds: &Rc<Cell<Rect>>,
    h_scrollbar_bounds: &Rc<Cell<Rect>>,
    event: &WidgetEvent,
    ctx: &mut EventContext,
) -> EventResponse {
    match event {
        WidgetEvent::PointerDown {
            position,
            button,
            modifiers,
        } => {
            if *button != PointerButton::Primary {
                // Secondary / middle are for the application's own
                // context menu; let them bubble.
                return EventResponse::Ignored;
            }
            // The wrapper's `on_pointer_event` runs in the preview
            // pass on every event aimed at a descendant, including
            // the overlay scrollbars. A press here would otherwise
            // latch `drag_state = Selecting` against the text under
            // the bar and then `EventResponse::Handled` would steal
            // the subsequent PointerMove from the scrollbar's
            // gesture arena.
            if v_scrollbar_bounds.get().contains(*position)
                || h_scrollbar_bounds.get().contains(*position)
            {
                return EventResponse::Ignored;
            }
            let shift = modifiers.shift();
            let local = to_engine_local(state, position);
            // A resize grip is checked before the hit-test, because a grip sits
            // *outside* the picture: the engine reports `HitRegion::Image` only
            // within the image's own rect, so by the time the hit-test has an
            // answer the corner has already been missed.
            if let Some((name, rect, offset, corner)) = grabbed_handle(state, local) {
                let mut st = state.borrow_mut();
                st.drag_state = DragState::ResizingImage {
                    name,
                    offset,
                    origin: rect,
                    corner,
                };
                st.resize_preview.set(Some(rect));
                drop(st);
                ctx.request_frame();
                // `Ignored`, like every other press path here. Returning
                // `Handled` consumes the event, and the gesture arena that
                // consumed press never delivers the moves that follow — so the
                // grip would latch and the drag would never arrive.
                return EventResponse::Ignored;
            }
            let hit = {
                let st = state.borrow();
                hit_test::hit_test_at(&st.engine, local, 0.0, 0.0)
            };
            let Some(hit) = hit else {
                // Return Ignored so the gesture arena still sees the
                // event — a click that missed text still activates
                // the widget, and the double/triple tap recognizers
                // need every press to progress their state machines.
                return EventResponse::Ignored;
            };
            // A viewer follows a link on a plain click; an editor still asks for
            // Ctrl(⌘). See the arm below for why the two differ.
            let read_only = state.borrow().policy.is_read_only();
            match &hit.region {
                teksilo_text::HitRegion::Link { href } if modifiers.command() || read_only => {
                    // Follow the link: do not move the caret. Dispatch to the
                    // widget's installed `on_link_activated` callback (if any)
                    // so applications can open the link / route to their
                    // router. Clone the `Rc` out of the state borrow before
                    // invoking so the handler can mutate widget state if it
                    // wants.
                    //
                    // Which clicks qualify depends on what kind of surface this
                    // is, which is exactly what `PolicyBundle::is_read_only`
                    // answers:
                    //
                    // * **Editable.** Ctrl(⌘)+click only; a *plain* click falls
                    //   through to ordinary caret placement below. The writer
                    //   who clicks their own link is far more often trying to
                    //   edit its text than to leave the document, and
                    //   intercepting every click left the text inside a link
                    //   unreachable by pointer entirely.
                    // * **Read-only.** A plain click follows it. The rationale
                    //   above is an *authoring* rationale and simply does not
                    //   apply: there is no caret to place and no text to edit,
                    //   so a link the reader cannot follow by clicking it is a
                    //   link that reads as broken. This is the browser default,
                    //   and a viewer can afford it for the same reason a browser
                    //   can — its text is not editable.
                    let callback = state.borrow().on_link_activated.clone();
                    if let Some(cb) = callback {
                        cb(href.as_str(), ctx);
                    }
                    ctx.request_frame();
                    return EventResponse::Ignored;
                }
                teksilo_text::HitRegion::Image { name } => {
                    let callback = state.borrow().on_image_activated.clone();
                    if let Some(cb) = callback {
                        // The caret is deliberately left where it was, as for a
                        // link. The offset is handed over instead, so a host that
                        // wants the image selected can say so itself — and one
                        // that only wants to open a viewer is not left having to
                        // put the caret back.
                        cb(
                            &super::ImageActivation {
                                name: name.clone(),
                                offset: hit.position,
                            },
                            ctx,
                        );
                    }
                    // The same press bookkeeping an ordinary click does, minus
                    // the caret placement — a click on a picture is still a
                    // click, and the state it leaves behind has to say so.
                    //
                    // `drag_state` above all: without it a drag that *starts*
                    // on a picture never becomes a selection (`PointerMove`
                    // gates on `is_dragging`), so the only way to select an
                    // image together with the words after it was to start the
                    // drag somewhere else and come back over it.
                    {
                        let mut st = state.borrow_mut();
                        st.drag_state = DragState::Selecting {
                            auto_scroll_v_per_s: 0.0,
                        };
                        st.preferred_x = None;
                        st.select_all_level = 0;
                        st.select_all_anchor_cell = None;
                        st.mouse_anchored = true;
                    }
                    ctx.request_frame();
                    return EventResponse::Ignored;
                }
                _ => {}
            }
            // A press *inside* the selection may be the writer picking that
            // text up to drag it elsewhere, so the selection has to survive
            // until the pointer says whether it was a drag or a plain click
            // (`PointerMove` and `PointerUp` below each resolve one way).
            // Shift+click is always an extend and never a drag.
            if !shift && selection_contains(state, hit.position) {
                let mut st = state.borrow_mut();
                st.drag_state = DragState::PendingTextDrag {
                    origin: [position.x, position.y],
                };
                st.mouse_anchored = true;
                drop(st);
                return EventResponse::Ignored;
            }
            // Place the cursor. Shift+click extends the selection
            // from the existing anchor; plain click collapses it.
            {
                let mut st = state.borrow_mut();
                let mode = if shift {
                    MoveMode::KeepAnchor
                } else {
                    MoveMode::MoveAnchor
                };
                st.cursor.set_position(hit.position, mode);
                // Affinity: at a soft-wrap boundary the typesetter
                // returned Upstream when the click landed on line
                // K+1's left edge (the visual START of the wrapped
                // line). At every other position the hit-test returns
                // Downstream and there is nothing to change.
                st.cursor_affinity = hit.affinity;
                // A fresh press starts a drag-select session.
                // Stored velocity is 0 until PointerMove detects an
                // auto-scroll zone.
                st.drag_state = DragState::Selecting {
                    auto_scroll_v_per_s: 0.0,
                };
                st.preferred_x = None;
                // Click resets the Ctrl+A ladder so a follow-up
                // Ctrl+A starts fresh at level 1.
                st.select_all_level = 0;
                st.select_all_anchor_cell = None;
                // The pointer now owns the caret: stand the typewriter pin down
                // so this click *becomes* the resting position instead of the
                // page lurching to re-centre on it. Cleared by the next
                // keyboard-driven caret move, which resumes pinning.
                st.mouse_anchored = true;
            }
            sync_cursor_signals(state);
            // Reveal the placed caret in any enclosing scroll area, so
            // click-placement is consistent with keyboard caret motion (no-op
            // when the caret is already visible / follow disabled).
            super::keyboard::chase_caret_into_view(state, ctx);
            ctx.request_frame();
            // Return Ignored so the gesture arena (DoubleTap /
            // TripleTap) also sees this PointerDown. Returning
            // Handled here would consume the event and the arena
            // would never fire `on_double_tap` / `on_triple_tap`.
            EventResponse::Ignored
        }
        WidgetEvent::PointerMove { position } => {
            // Drag-select extension. The `drag_state` field tells us
            // whether a primary button is still held; if it isn't,
            // we ignore the move.
            let (is_dragging, resizing, viewport_height) = {
                let st = state.borrow();
                let dragging = matches!(st.drag_state, DragState::Selecting { .. });
                let resizing = match &st.drag_state {
                    DragState::ResizingImage { origin, corner, .. } => Some((*origin, *corner)),
                    _ => None,
                };
                (dragging, resizing, st.viewport_height)
            };
            // A press inside the selection that has now travelled far enough:
            // hand the selected text to the framework as a drag. From here the
            // drag belongs to the drag system — this widget's own pointer
            // machine goes back to Idle rather than also trying to select.
            let pending = match &state.borrow().drag_state {
                DragState::PendingTextDrag { origin } => Some(*origin),
                _ => None,
            };
            if let Some(origin) = pending {
                let dx = position.x - origin[0];
                let dy = position.y - origin[1];
                if dx * dx + dy * dy < TEXT_DRAG_THRESHOLD * TEXT_DRAG_THRESHOLD {
                    return EventResponse::Handled;
                }
                start_text_drag(state, ctx);
                return EventResponse::Handled;
            }
            if let Some((origin, corner)) = resizing {
                let local = to_engine_local(state, position);
                let proposed = proportional_resize(origin, corner, local);
                state.borrow().resize_preview.set(Some(proposed));
                ctx.request_frame();
                return EventResponse::Handled;
            }
            if !is_dragging {
                // Not a drag, so this is a plain hover. In a viewer a plain
                // click follows a link (see the press arm), and a link that
                // acts like a link has to *look* like one under the pointer —
                // without this the reader gets an I-beam over the one thing on
                // the page that is not text to select. Editable surfaces are
                // left alone: there the click places a caret, so an I-beam is
                // the truthful cursor.
                if state.borrow().policy.is_read_only() {
                    let local = to_engine_local(state, position);
                    let over_link = {
                        let st = state.borrow();
                        hit_test::hit_test_at(&st.engine, local, 0.0, 0.0).is_some_and(|hit| {
                            matches!(hit.region, teksilo_text::HitRegion::Link { .. })
                        })
                    };
                    if over_link {
                        ctx.set_cursor(CursorIcon::Pointer);
                    }
                }
                return EventResponse::Ignored;
            }
            let local = to_engine_local(state, position);
            // Clamp y into [2.0, viewport_height - 2.0] before
            // hit-testing so a drag that leaves the viewport still
            // resolves to a valid position on the edge line
            // (matches the godot reference's `handle_drag_select`).
            let clamped = Point::new(
                local.x,
                local.y.clamp(2.0, (viewport_height - 2.0).max(2.0)),
            );
            let hit = {
                let st = state.borrow();
                hit_test::hit_test_at(&st.engine, clamped, 0.0, 0.0)
            };
            if let Some(hit) = hit {
                {
                    let mut st = state.borrow_mut();
                    st.cursor.set_position(hit.position, MoveMode::KeepAnchor);
                    st.cursor_affinity = hit.affinity;
                }
                sync_cursor_signals(state);
            }
            // Compute auto-scroll velocity for the frame loop. The
            // 20 px margin and 60 px/frame max (normalized to
            // 60 * 60 = 3600 px/s so delta scaling matches the
            // reference without depending on refresh rate) come
            // from the godot reference's `compute_drag_velocity`.
            {
                let mut st = state.borrow_mut();
                let v = if local.y < 20.0 {
                    let intensity = ((20.0 - local.y) / 20.0).clamp(0.0, 1.0);
                    -60.0 * 60.0 * intensity
                } else if local.y > viewport_height - 20.0 {
                    let intensity = ((local.y - (viewport_height - 20.0)) / 20.0).clamp(0.0, 1.0);
                    60.0 * 60.0 * intensity
                } else {
                    0.0
                };
                st.drag_state = DragState::Selecting {
                    auto_scroll_v_per_s: v,
                };
                if v != 0.0 {
                    // Drag near an edge keeps the frame loop pumping
                    // so auto-scroll continues without needing the
                    // user to wiggle the mouse.
                    if let Some(handle) = &st.frame_request {
                        handle.set(true);
                    }
                }
            }
            ctx.request_frame();
            EventResponse::Handled
        }
        WidgetEvent::PointerUp { position, .. } => {
            // A press inside the selection that never travelled: it was an
            // ordinary click after all, so honour it now — collapse the
            // selection onto it, which is exactly what the press deferred.
            if matches!(state.borrow().drag_state, DragState::PendingTextDrag { .. }) {
                let local = to_engine_local(state, position);
                let hit = {
                    let st = state.borrow();
                    hit_test::hit_test_at(&st.engine, local, 0.0, 0.0)
                };
                {
                    let mut st = state.borrow_mut();
                    st.drag_state = DragState::Idle;
                    if let Some(hit) = hit {
                        st.cursor.set_position(hit.position, MoveMode::MoveAnchor);
                        st.cursor_affinity = hit.affinity;
                        st.preferred_x = None;
                        st.select_all_level = 0;
                        st.select_all_anchor_cell = None;
                    }
                }
                sync_cursor_signals(state);
                ctx.request_frame();
                return EventResponse::Ignored;
            }
            // A resize is reported once, here — not on every move. The document
            // is the durable record, and rewriting it per pointer move would put
            // a hundred entries on the undo stack for one gesture.
            let finished = {
                let st = state.borrow();
                match (&st.drag_state, st.resize_preview.get()) {
                    (DragState::ResizingImage { name, offset, .. }, Some(rect)) => {
                        Some((st.on_image_resized.clone(), name.clone(), *offset, rect))
                    }
                    _ => None,
                }
            };
            {
                let mut st = state.borrow_mut();
                st.drag_state = DragState::Idle;
                st.resize_preview.set(None);
            }
            if let Some((callback, name, offset, rect)) = finished {
                if let Some(cb) = callback {
                    cb(
                        &super::ImageResize {
                            name,
                            offset,
                            width: rect[2].round().max(1.0) as u32,
                            height: rect[3].round().max(1.0) as u32,
                        },
                        ctx,
                    );
                }
                ctx.request_frame();
                return EventResponse::Handled;
            }
            EventResponse::Ignored
        }
        _ => EventResponse::Ignored,
    }
}

pub(super) fn handle_scroll(
    state: &SharedState,
    overscroll: crate::common::scroll::OverscrollBehavior,
    event: &WidgetEvent,
    ctx: &mut EventContext,
) -> EventResponse {
    let WidgetEvent::Scroll { delta, .. } = event else {
        return EventResponse::Ignored;
    };
    // Match `ScrollArea`'s sign convention: `delta.y` is the scroll
    // distance in document pixels per unit of wheel / trackpad
    // movement, already oriented so that positive means "scroll
    // content up" (i.e. increase scroll_y). For line-based events
    // the line_height multiplier is 16 px to match ScrollArea's
    // default.
    let (dx, dy) = match delta {
        ScrollDelta::Lines { x, y } => (*x * 16.0, *y * 16.0),
        ScrollDelta::Pixels { x, y } => (*x, *y),
    };
    let st = state.borrow();
    // Clamp each axis and learn whether it could absorb any of the delta.
    // Using the shared helper keeps the editor's boundary behaviour bit-for-bit
    // identical to `ScrollArea` / `ListView` / `TableView`.
    let (new_x, moved_x) =
        crate::common::scroll::scroll_clamp_axis(st.scroll_x.get(), dx, st.max_scroll_x.get());
    let (new_y, moved_y) =
        crate::common::scroll::scroll_clamp_axis(st.scroll_y.get(), dy, st.max_scroll_y.get());
    // Guard each `set` on an actual change: `Signal::set` fans out to every
    // observer unconditionally, so skipping the no-op write matters.
    if moved_x {
        st.scroll_x.set(new_x);
    }
    if moved_y {
        st.scroll_y.set(new_y);
    }
    drop(st);
    if moved_x || moved_y {
        ctx.request_frame();
    }
    // Decline (`Ignored`) when the editor is fully clamped on both axes so the
    // wheel chains to an ancestor scrollable — the editor embedded in a
    // scrolling form/page hands the leftover scroll to the page. Absorbing any
    // movement (`Handled`) keeps the event. `OverscrollBehavior::Contain`
    // always keeps the event at the editor. Shared boundary rule with every
    // other scrollable via `scroll_response`.
    crate::common::scroll::scroll_response(
        moved_x || moved_y,
        overscroll == crate::common::scroll::OverscrollBehavior::Contain,
    )
}

/// Select word under the caret on double-click.
pub(super) fn handle_double_tap(state: &SharedState, pos: Point, ctx: &mut EventContext) {
    tap_select(state, pos, SelectionType::WordUnderCursor);
    super::keyboard::chase_caret_into_view(state, ctx);
    ctx.request_frame();
}

/// Select block under the caret on triple-click. Matches the godot reference's
/// own triple-click rule.
pub(super) fn handle_triple_tap(state: &SharedState, pos: Point, ctx: &mut EventContext) {
    tap_select(state, pos, SelectionType::BlockUnderCursor);
    super::keyboard::chase_caret_into_view(state, ctx);
    ctx.request_frame();
}

fn tap_select(state: &SharedState, pos: Point, kind: SelectionType) {
    // Double/triple-click is pointer-driven selection: same rule as a plain
    // click — the pin stands down rather than yanking the page under a
    // selection the user is making with the mouse.
    state.borrow_mut().mouse_anchored = true;
    let local = to_engine_local(state, &pos);
    let hit = {
        let st = state.borrow();
        hit_test::hit_test_at(&st.engine, local, 0.0, 0.0)
    };
    if let Some(hit) = hit {
        let st = state.borrow();
        st.cursor.set_position(hit.position, MoveMode::MoveAnchor);
        st.cursor.select(kind);
        drop(st);
        sync_cursor_signals(state);
    }
}

/// Convert a **window**-space point (the coordinate a context-menu factory is
/// handed by `show_context_menu_for`) into engine/body-local space. The body's
/// top-left in the window is `viewport_origin`, so `window - viewport_origin`
/// lands in the space `hit_test` expects — no `node_origin` term, because the
/// input is already a window point (unlike [`to_engine_local`], whose input is
/// wrapper-local).
fn engine_local_of_window(state: &SharedState, window_position: Point) -> Point {
    let st = state.borrow();
    Point::new(
        window_position.x - st.viewport_origin.x,
        window_position.y - st.viewport_origin.y,
    )
}

/// Reposition the caret to a right-click point **in window coordinates** when
/// the click lands *outside* the current selection — the platform convention
/// for "right-click, then Cut / Copy / Paste (/ add word) at the new caret". A
/// click *inside* the selection preserves it, so the menu's Cut/Copy still act
/// on the selection.
///
/// This has to run from inside the context-menu factory: the editor never sees
/// the Secondary `PointerDown` through its own pointer handler, because
/// `teksilo-core`'s `show_context_menu_for` consumes it (and returns early)
/// before `dispatch_to_widget` is ever called. Mirrors the single-line
/// [`TextInputField`](crate::primitives::text_input_field)'s behavior.
pub(super) fn reposition_caret_for_context_menu(state: &SharedState, window_position: Point) {
    let local = engine_local_of_window(state, window_position);
    let hit = {
        let st = state.borrow();
        hit_test::hit_test_at(&st.engine, local, 0.0, 0.0)
    };
    let Some(hit) = hit else {
        return;
    };
    {
        let st = state.borrow();
        if st.cursor.has_selection() {
            let (lo, hi) = (st.cursor.selection_start(), st.cursor.selection_end());
            if hit.position >= lo && hit.position <= hi {
                // Click inside the selection — keep it so Cut/Copy act on it.
                return;
            }
        }
    }
    {
        let mut st = state.borrow_mut();
        st.cursor.set_position(hit.position, MoveMode::MoveAnchor);
        st.cursor_affinity = hit.affinity;
        st.preferred_x = None;
        st.select_all_level = 0;
        st.select_all_anchor_cell = None;
    }
    sync_cursor_signals(state);
}

/// Hit-test a **window**-space point to a document char offset — the primitive
/// behind [`EditorHandle::offset_at_point`](super::EditorHandle::offset_at_point).
/// `None` when the point resolves to no text.
/// Move the caret to the drop point under a hovering drag, in the coordinate
/// space the widget's own drag handlers receive (widget-local).
///
/// A drag with no caret under it asks the writer to aim at nothing: they can see
/// the pointer but not where the text will land, and the two are never the same
/// place because a caret snaps to a character boundary. So the caret follows the
/// drag, and dropping puts the payload exactly where the caret already is.
///
/// Returns `false` when the pointer is not over any text, so the caller can
/// decline the drop rather than insert somewhere arbitrary.
pub(super) fn move_caret_for_drag(state: &SharedState, local: Point) -> bool {
    let hit = {
        let st = state.borrow();
        hit_test::hit_test_at(&st.engine, to_engine_local(state, &local), 0.0, 0.0)
    };
    let Some(hit) = hit else { return false };
    {
        let mut st = state.borrow_mut();
        // Collapsed, not extended: a drop replaces nothing, and leaving an
        // anchor behind would make the insertion look like it was about to
        // overwrite the selection it came from.
        st.cursor.set_position(hit.position, MoveMode::MoveAnchor);
        st.cursor_affinity = hit.affinity;
        // Show it even though the drag holds no focus here — see `drop_caret`.
        st.drop_caret = true;
    }
    sync_cursor_signals(state);
    true
}

/// Stop showing the drop caret — the drag left, was cancelled, or has landed.
pub(super) fn clear_drop_caret(state: &SharedState) {
    state.borrow_mut().drop_caret = false;
}

pub(super) fn offset_at_window_point(state: &SharedState, window_position: Point) -> Option<usize> {
    let local = engine_local_of_window(state, window_position);
    let st = state.borrow();
    hit_test::hit_test_at(&st.engine, local, 0.0, 0.0).map(|hit| hit.position)
}

#[cfg(test)]
mod resize_tests {
    use super::*;

    /// A 200×100 picture at (50, 20) — deliberately not square, so a fix that
    /// silently squares it up would show.
    const IMG: [f32; 4] = [50.0, 20.0, 200.0, 100.0];
    const BOTTOM_RIGHT: (f32, f32) = (1.0, 1.0);
    const TOP_LEFT: (f32, f32) = (0.0, 0.0);

    fn ratio(rect: [f32; 4]) -> f32 {
        rect[2] / rect[3]
    }

    #[test]
    fn dragging_a_corner_keeps_the_proportions() {
        // Straight out along the diagonal, then a drag that is mostly
        // horizontal, then one that is mostly vertical. All three must keep the
        // 2:1 shape — that is the whole promise of the gesture.
        for target in [
            Point::new(450.0, 220.0),
            Point::new(450.0, 130.0),
            Point::new(260.0, 320.0),
        ] {
            let out = proportional_resize(IMG, BOTTOM_RIGHT, target);
            assert!(
                (ratio(out) - ratio(IMG)).abs() < 0.001,
                "proportions drifted: {out:?} is {:.3}, wanted {:.3}",
                ratio(out),
                ratio(IMG)
            );
        }
    }

    #[test]
    fn dragging_outward_grows_and_inward_shrinks() {
        let bigger = proportional_resize(IMG, BOTTOM_RIGHT, Point::new(450.0, 220.0));
        assert!(bigger[2] > IMG[2], "{bigger:?}");
        let smaller = proportional_resize(IMG, BOTTOM_RIGHT, Point::new(150.0, 70.0));
        assert!(smaller[2] < IMG[2], "{smaller:?}");
    }

    #[test]
    fn the_opposite_corner_is_what_stays_put() {
        // Grabbing the TOP-LEFT measures against the bottom-right, so dragging
        // up and left must GROW the picture. A version that always measured
        // from the top-left would shrink it here — the sign error that makes
        // two of the four grips feel inverted.
        let out = proportional_resize(IMG, TOP_LEFT, Point::new(0.0, 0.0));
        assert!(
            out[2] > IMG[2],
            "dragging the top-left up and out must grow: {out:?}"
        );
    }

    #[test]
    fn a_resize_cannot_shrink_the_image_out_of_reach() {
        // Dragged far past the anchor. A picture with no area has no grips, so
        // it could never be resized back — the drag has to stop short.
        let out = proportional_resize(IMG, BOTTOM_RIGHT, Point::new(51.0, 21.0));
        assert!(out[2] >= RESIZE_MIN_EDGE, "{out:?}");
        assert!(out[3] >= RESIZE_MIN_EDGE, "{out:?}");
        assert!(
            (ratio(out) - ratio(IMG)).abs() < 0.001,
            "the floor must not distort it: {out:?}"
        );
    }

    #[test]
    fn the_preview_stays_at_the_images_own_place_in_the_text() {
        // Whatever corner is dragged, the result is anchored at the original
        // top-left: the picture sits in a text flow, so the reflow decides
        // where it lands. Showing it anywhere else previews a position the
        // relayout is about to contradict.
        for corner in [TOP_LEFT, BOTTOM_RIGHT, (1.0, 0.0), (0.0, 1.0)] {
            let out = proportional_resize(IMG, corner, Point::new(400.0, 300.0));
            assert_eq!((out[0], out[1]), (IMG[0], IMG[1]), "{corner:?} moved it");
        }
    }

    #[test]
    fn a_degenerate_image_is_left_alone() {
        let zero = [10.0, 10.0, 0.0, 0.0];
        assert_eq!(
            proportional_resize(zero, BOTTOM_RIGHT, Point::new(99.0, 99.0)),
            zero
        );
    }

    // ── grabbing a grip ─────────────────────────────────────────────────

    #[test]
    fn each_corner_is_grabbable_from_either_side_of_its_edge() {
        // A grip straddles the corner, so it must answer both from inside the
        // picture and from just outside it — the outside half is the reason the
        // engine's own hit-test cannot do this job.
        for (fx, fy) in super::super::paint::RESIZE_CORNERS {
            let (cx, cy) = (IMG[0] + IMG[2] * fx, IMG[1] + IMG[3] * fy);
            for (dx, dy) in [(0.0, 0.0), (-4.0, -4.0), (4.0, 4.0)] {
                assert_eq!(
                    handle_at(IMG, Point::new(cx + dx, cy + dy)),
                    Some((fx, fy)),
                    "corner {fx},{fy} missed at offset {dx},{dy}"
                );
            }
        }
    }

    #[test]
    fn the_middle_of_the_picture_grabs_nothing() {
        // Otherwise clicking a picture to select it would start a resize.
        let centre = Point::new(IMG[0] + IMG[2] / 2.0, IMG[1] + IMG[3] / 2.0);
        assert_eq!(handle_at(IMG, centre), None);
    }

    #[test]
    fn a_press_well_clear_of_a_corner_grabs_nothing() {
        // Twenty pixels out on both axes: prose beside the picture must stay
        // ordinary prose.
        assert_eq!(
            handle_at(IMG, Point::new(IMG[0] - 20.0, IMG[1] - 20.0)),
            None
        );
        assert_eq!(
            handle_at(
                IMG,
                Point::new(IMG[0] + IMG[2] + 20.0, IMG[1] + IMG[3] + 20.0)
            ),
            None
        );
    }

    #[test]
    fn the_edges_between_corners_grab_nothing() {
        // Corners only — this resize keeps proportions, so an edge grip would
        // promise a stretch it will not perform.
        let mid_top = Point::new(IMG[0] + IMG[2] / 2.0, IMG[1]);
        let mid_left = Point::new(IMG[0], IMG[1] + IMG[3] / 2.0);
        assert_eq!(handle_at(IMG, mid_top), None);
        assert_eq!(handle_at(IMG, mid_left), None);
    }
}