Skip to main content

blockworx_editor/render/
selection.rs

1use blockworx_doc::block_model::Asset;
2use blockworx_geom::{Pos2, Rect, WorldPx, pos2, vec2};
3
4use blockworx_paint::{AnimKey, Animator, Renderer};
5
6use std::time::Duration;
7
8use crate::{
9    grid::{GRID_SIZE, LOCK_HINT_SIZE, round_to_grid},
10    state::ResizeMode,
11    theme::{Role, Style},
12};
13
14/// The padlock icon drawn as the locked-block corner hint. The same asset backs
15/// the overlay lock button, so the hint and the button stay identical.
16const LOCK_HINT_SVG: &str = include_str!("../../../../icons/icon-lock.svg");
17
18/// Resting dot radius as a fraction of [`PORT_RADIUS`](crate::grid::PORT_RADIUS) (a small, unobtrusive
19/// marker).
20pub const NEW_PIN_INACTIVE_SCALE: f32 = 0.49;
21
22/// How near the cursor must be for a marker to grow into the full control. The
23/// resize tool activates on the resting-dot range; the route tool grows the
24/// control only this close so the dot reads as a target before it arms.
25pub const NEW_PIN_GROW_RANGE: WorldPx = WorldPx::new(GRID_SIZE);
26
27/// Grow time for a handle's activation transition.
28pub const NEW_PIN_ANIM_TIME: Duration = Duration::from_millis(120);
29
30/// The radius a grown resize handle draws at, and the armed one.
31pub const HANDLE_RADIUS: WorldPx = WorldPx::new(GRID_SIZE * 0.45);
32
33/// How near its corner a press grabs a resize handle: twice the radius the
34/// handle draws at, on every resizable shape.
35pub const HANDLE_GRAB: WorldPx = WorldPx::new(2.0 * HANDLE_RADIUS.get());
36
37/// Resting size of a resize handle, as a fraction of [`HANDLE_RADIUS`]. A
38/// corner handle is a coarser target than a route/new-pin anchor, so it rests
39/// 30% larger than [`NEW_PIN_INACTIVE_SCALE`] to stay easy to spot and grab.
40pub const RESIZE_INACTIVE_SCALE: f32 = NEW_PIN_INACTIVE_SCALE * 1.3;
41
42/// The four corner handles of a selection framing `bbox`, each with the
43/// resize it starts.
44pub fn resize_handles(bbox: Rect) -> [(ResizeMode, Pos2); 4] {
45    [
46        (ResizeMode::LeftTop, bbox.left_top()),
47        (ResizeMode::RightTop, bbox.right_top()),
48        (ResizeMode::LeftBottom, bbox.left_bottom()),
49        (ResizeMode::RightBottom, bbox.right_bottom()),
50    ]
51}
52
53/// The handle a press at `pos` grabs on a selection framing `bbox`.
54pub fn resize_handle_at(bbox: Rect, pos: Pos2) -> Option<ResizeMode> {
55    resize_handles(bbox)
56        .into_iter()
57        .find_map(|(mode, corner)| HANDLE_GRAB.within(corner, pos).then_some(mode))
58}
59
60/// The animation key for one corner handle. Keyed on the corner's snapped
61/// position so a settled selection's handles animate independently and stably;
62/// the resize-active corner draws armed (not animated), so its position churning
63/// during a drag never feeds a key.
64fn anim_key(mode: ResizeMode, corner: Pos2) -> AnimKey {
65    AnimKey::of((
66        "resize_corner",
67        mode,
68        round_to_grid(corner.x) as i32,
69        round_to_grid(corner.y) as i32,
70    ))
71}
72
73/// Draw the inverted "armed" handle: a filled disk acknowledging the press, used
74/// for the corner currently being resized.
75fn draw_armed_handle(pos: Pos2, painter: &mut Style<'_, impl Renderer>) {
76    painter.circle(
77        pos,
78        HANDLE_RADIUS,
79        Role::ResizeCornerActiveFill,
80        (1.0, Role::ResizeCornerActiveStroke),
81    );
82}
83
84/// How grown one corner's handle is this frame: eased toward full size as
85/// `pointer` nears it (within [`NEW_PIN_GROW_RANGE`]), mirroring the route and
86/// new-pin anchor targets.
87fn grown(mode: ResizeMode, corner: Pos2, pointer: Option<Pos2>, anim: &dyn Animator) -> f32 {
88    let goal = if pointer.is_some_and(|p| NEW_PIN_GROW_RANGE.within(corner, p)) {
89        1.0
90    } else {
91        0.0
92    };
93    anim.animate(anim_key(mode, corner), goal, NEW_PIN_ANIM_TIME)
94}
95
96/// Draw one corner handle at growth `t`: a resting dot fading out as the full
97/// [`HANDLE_RADIUS`] ring grows in, so it reads as the dot expanding into the
98/// handle.
99fn draw_corner_handle(pos: Pos2, t: f32, painter: &mut Style<'_, impl Renderer>) {
100    if t < 1.0 {
101        painter.with_opacity(1.0 - t, |p| {
102            p.circle_filled(
103                pos,
104                HANDLE_RADIUS * RESIZE_INACTIVE_SCALE,
105                Role::ResizeCornerFill,
106            );
107        });
108    }
109    if t > 0.0 {
110        let radius = HANDLE_RADIUS * (RESIZE_INACTIVE_SCALE + (1.0 - RESIZE_INACTIVE_SCALE) * t);
111        painter.with_opacity(t, |p| {
112            p.circle(
113                pos,
114                radius,
115                Role::ResizeCornerFill,
116                (0.5, Role::ResizeCornerStroke),
117            );
118        });
119    }
120}
121
122/// Draw the four corner resize handles for `bbox`. Each handle rests as a small
123/// dot and grows into the full handle as the pointer approaches, exactly like the
124/// route/new-pin anchor targets. When `mode` is set, that corner is being resized
125/// and draws armed (an inverted filled disk) as click-success feedback.
126///
127/// Growing needs a pointer and a clock, which only a live canvas has
128/// ([`Renderer::animator`]); offline backends (the SVG exporter) draw the
129/// handles statically at full size. The growths are read before anything is
130/// drawn, since the animator is borrowed from the same backend the draws go to.
131pub fn draw_selection_frame(
132    bbox: Rect,
133    mode: Option<ResizeMode>,
134    painter: &mut Style<'_, impl Renderer>,
135) {
136    let handles = resize_handles(bbox);
137    let growths = painter.animator().map(|anim| {
138        let pointer = anim.pointer_world();
139        // The active corner is never animated — its position churns through the
140        // drag, and a churning position is a churning key.
141        handles.map(|(corner, at)| (mode != Some(corner)).then(|| grown(corner, at, pointer, anim)))
142    });
143    let mut armed = None;
144    for ((corner, at), growth) in handles.into_iter().zip(growths.unwrap_or([None; 4])) {
145        if mode == Some(corner) {
146            armed = Some(at);
147            continue;
148        }
149        match growth {
150            Some(t) => draw_corner_handle(at, t, painter),
151            None => painter.circle(
152                at,
153                HANDLE_RADIUS,
154                Role::ResizeCornerFill,
155                (0.5, Role::ResizeCornerStroke),
156            ),
157        }
158    }
159    if let Some(at) = armed {
160        draw_armed_handle(at, painter);
161    }
162}
163
164/// Draw the padlock hint in a selected locked block's upper-right corner: a
165/// passive sign that the block is locked, not a button. Renders the lock action
166/// icon's SVG tinted to [`Role::LockedHintIcon`] so it reads as dim and stays
167/// identical to the overlay lock button. Sits clear of the top-right resize handle
168/// and is skipped when the block is too small to hold it without crowding.
169pub fn draw_lock_hint(bbox: Rect, painter: &mut Style<'_, impl Renderer>) {
170    let s = LOCK_HINT_SIZE;
171    let m = HANDLE_RADIUS.get() + GRID_SIZE * 0.2;
172    if bbox.width() < s + m || bbox.height() < s + m {
173        return;
174    }
175
176    let icon = Rect::from_min_size(
177        pos2(bbox.right_top().x - m - s, bbox.right_top().y + m),
178        vec2(s, s),
179    );
180    let color = painter.theme().resolve(Role::LockedHintIcon);
181    let tinted = LOCK_HINT_SVG.replace(
182        "stroke=\"#ffffff\"",
183        &format!(
184            "stroke=\"#{:02x}{:02x}{:02x}\" stroke-opacity=\"{:.3}\"",
185            color.r(),
186            color.g(),
187            color.b(),
188            color.a() as f32 / 255.0
189        ),
190    );
191    painter.draw_image(icon, &Asset::Svg(tinted.into_bytes().into()));
192}