Skip to main content

blockworx/render/
selection.rs

1use blockworx_doc::block_model::Asset;
2use blockworx_geom::{Pos2, Rect, pos2, vec2};
3
4use blockworx_paint::{AnimKey, Animator, Renderer};
5
6use crate::{
7    grid::{GRID_SIZE, LOCK_HINT_SIZE, PORT_RADIUS, round_to_grid},
8    state::ResizeMode,
9    theme::{Role, Style},
10    tools::new_pin::{NEW_PIN_ANIM_TIME, NEW_PIN_GROW_RANGE, NEW_PIN_INACTIVE_SCALE},
11};
12
13/// The padlock icon drawn as the locked-block corner hint. The same asset backs
14/// the overlay lock button, so the hint and the button stay identical.
15const LOCK_HINT_SVG: &str = include_str!("../../icons/icon-lock.svg");
16
17/// Resting size of a resize handle, as a fraction of [`PORT_RADIUS`]. A corner
18/// handle is a coarser target than a route/new-pin anchor, so it rests 30% larger
19/// than [`NEW_PIN_INACTIVE_SCALE`] to stay easy to spot and grab.
20pub(crate) const RESIZE_INACTIVE_SCALE: f32 = NEW_PIN_INACTIVE_SCALE * 1.3;
21
22/// Which corner a resize handle sits on, used both to pick its anchor point and
23/// to key its animation independently of the other three.
24#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
25enum Corner {
26    LeftTop,
27    RightTop,
28    LeftBottom,
29    RightBottom,
30}
31
32impl Corner {
33    const ALL: [Corner; 4] = [
34        Corner::LeftTop,
35        Corner::RightTop,
36        Corner::LeftBottom,
37        Corner::RightBottom,
38    ];
39
40    fn pos(self, bbox: Rect) -> Pos2 {
41        match self {
42            Corner::LeftTop => bbox.left_top(),
43            Corner::RightTop => bbox.right_top(),
44            Corner::LeftBottom => bbox.left_bottom(),
45            Corner::RightBottom => bbox.right_bottom(),
46        }
47    }
48
49    fn from_mode(mode: ResizeMode) -> Corner {
50        match mode {
51            ResizeMode::LeftTop => Corner::LeftTop,
52            ResizeMode::RightTop => Corner::RightTop,
53            ResizeMode::LeftBottom => Corner::LeftBottom,
54            ResizeMode::RightBottom => Corner::RightBottom,
55        }
56    }
57}
58
59/// The animation key for one corner handle. Keyed on the corner's snapped
60/// position so a settled selection's handles animate independently and stably;
61/// the resize-active corner draws armed (not animated), so its position churning
62/// during a drag never feeds a key.
63fn anim_key(bbox: Rect, corner: Corner) -> AnimKey {
64    let pos = corner.pos(bbox);
65    AnimKey::of((
66        "resize_corner",
67        corner,
68        round_to_grid(pos.x) as i32,
69        round_to_grid(pos.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        PORT_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(bbox: Rect, corner: Corner, pointer: Option<Pos2>, anim: &dyn Animator) -> f32 {
88    let goal = if pointer.is_some_and(|p| corner.pos(bbox).distance(p) < NEW_PIN_GROW_RANGE.get()) {
89        1.0
90    } else {
91        0.0
92    };
93    anim.animate(anim_key(bbox, 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/// [`PORT_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                PORT_RADIUS * RESIZE_INACTIVE_SCALE,
105                Role::ResizeCornerFill,
106            );
107        });
108    }
109    if t > 0.0 {
110        let radius = PORT_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 active = mode.map(Corner::from_mode);
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        Corner::ALL
142            .map(|corner| (active != Some(corner)).then(|| grown(bbox, corner, pointer, anim)))
143    });
144    for (corner, growth) in Corner::ALL.into_iter().zip(growths.unwrap_or([None; 4])) {
145        if active == Some(corner) {
146            continue;
147        }
148        match growth {
149            Some(t) => draw_corner_handle(corner.pos(bbox), t, painter),
150            None => painter.circle(
151                corner.pos(bbox),
152                PORT_RADIUS,
153                Role::ResizeCornerFill,
154                (0.5, Role::ResizeCornerStroke),
155            ),
156        }
157    }
158    if let Some(corner) = active {
159        draw_armed_handle(corner.pos(bbox), painter);
160    }
161}
162
163/// Draw the padlock hint in a selected locked block's upper-right corner: a
164/// passive sign that the block is locked, not a button. Renders the lock action
165/// icon's SVG tinted to [`Role::LockedHintIcon`] so it reads as dim and stays
166/// identical to the overlay lock button. Sits clear of the top-right resize handle
167/// and is skipped when the block is too small to hold it without crowding.
168pub fn draw_lock_hint(bbox: Rect, painter: &mut Style<'_, impl Renderer>) {
169    let s = LOCK_HINT_SIZE;
170    let m = PORT_RADIUS.get() + GRID_SIZE * 0.2;
171    if bbox.width() < s + m || bbox.height() < s + m {
172        return;
173    }
174
175    let icon = Rect::from_min_size(
176        pos2(bbox.right_top().x - m - s, bbox.right_top().y + m),
177        vec2(s, s),
178    );
179    let color = painter.theme().resolve(Role::LockedHintIcon);
180    let tinted = LOCK_HINT_SVG.replace(
181        "stroke=\"#ffffff\"",
182        &format!(
183            "stroke=\"#{:02x}{:02x}{:02x}\" stroke-opacity=\"{:.3}\"",
184            color.r(),
185            color.g(),
186            color.b(),
187            color.a() as f32 / 255.0
188        ),
189    );
190    painter.draw_image(icon, &Asset::Svg(tinted.into_bytes().into()));
191}