Skip to main content

blockworx_kernel/
bar.rs

1//! The selection overlay's own policy: which verbs it carries, in what
2//! order, where the row ends and the overflow begins, and where the bar
3//! stands over the diagram.
4//!
5//! None of it is a drawing. Two front ends that decided any of this
6//! themselves would offer two different bars over one selection, so it is
7//! decided once, here, and each of them only paints the answer.
8
9use blockworx_geom::{Rect, Vec2, pos2};
10use blockworx_tools::commands::Precedence;
11
12/// How many controls the bar's row holds. A selection offering more shows
13/// this many and puts the rest behind the ellipsis; one offering this many or
14/// fewer has no ellipsis at all.
15pub const INLINE_CONTROLS: usize = 5;
16
17/// The air between the selection's bounding box and the bar.
18pub const CLEAR: f32 = 14.0;
19
20/// What a selection with no commands says, rather than raising no bar at all.
21///
22/// A selection that silently draws no bar is indistinguishable from a
23/// selection whose bar is broken, so the empty case says so out loud
24/// whether one thing is selected or several.
25#[must_use]
26pub fn nothing_to_say(count: usize) -> &'static str {
27    if count > 1 {
28        "No actions shared by this selection"
29    } else {
30        "No actions for this selection"
31    }
32}
33
34/// What the bar shows for one selection: the one list, in the order the row,
35/// the overflow and the right-click menu all read.
36///
37/// Generic over what is being cut so the ids a test compares and the controls
38/// a frame draws cannot be divided differently.
39pub enum Bar<T> {
40    /// Every control, own verbs before clerical ones; [`Bar::row`] and
41    /// [`Bar::overflow`] are its two halves.
42    Row(Vec<T>),
43    /// A multi-selection whose members share no command. Saying so is
44    /// the point: an empty bar reads as a bug.
45    NothingShared,
46    /// One thing selected with nothing to do to it. It says so, rather than
47    /// drawing nothing: a selection that raises no bar reads as a bug.
48    Nothing,
49}
50
51impl<T> Bar<T> {
52    /// Order the one list by precedence, keeping the registry's order within
53    /// each rank.
54    pub fn of(mut commands: Vec<T>, count: usize, precedence: impl Fn(&T) -> Precedence) -> Self {
55        if commands.is_empty() {
56            return if count > 1 {
57                Bar::NothingShared
58            } else {
59                Bar::Nothing
60            };
61        }
62        commands.sort_by_key(&precedence);
63        Bar::Row(commands)
64    }
65
66    /// Everything the bar offers, row then overflow — which is exactly what
67    /// the right-click menu carries.
68    #[must_use]
69    pub fn all(&self) -> &[T] {
70        match self {
71            Bar::Row(all) => all,
72            Bar::NothingShared | Bar::Nothing => &[],
73        }
74    }
75
76    /// The controls drawn in the row.
77    #[must_use]
78    pub fn row(&self) -> &[T] {
79        self.all().split_at(self.cut()).0
80    }
81
82    /// The controls behind the ellipsis.
83    #[must_use]
84    pub fn overflow(&self) -> &[T] {
85        self.all().split_at(self.cut()).1
86    }
87
88    #[must_use]
89    pub fn cut(&self) -> usize {
90        self.all().len().min(INLINE_CONTROLS)
91    }
92}
93
94/// Where a bar of `size` stands over a selection of `sel`, inside `region` —
95/// the part of the viewport the front end's chrome leaves clear.
96///
97/// Centred above the selection, flipped below when that would land under the
98/// top chrome, and — for a selection taller than the region, which is an area
99/// whose interior is not hit-testable anyway — inside it. The fit test is
100/// vertical only: both candidates are wholly clear of the selection in `y`
101/// and the clamp moves the bar in `x`, which is why the bar provably never
102/// covers what is selected.
103///
104/// `None` only when there is no region at all to put it in.
105#[must_use]
106pub fn place(sel: Rect, size: Vec2, region: Rect) -> Option<Rect> {
107    let band = |top: f32| Rect::from_min_size(pos2(sel.center().x - size.x / 2.0, top), size);
108    let fits = |bar: &Rect| bar.min.y >= region.min.y && bar.max.y <= region.max.y;
109    let outside = [band(sel.min.y - CLEAR - size.y), band(sel.max.y + CLEAR)]
110        .into_iter()
111        .find(fits);
112    let inside = || {
113        let bar = band(sel.min.y.max(region.min.y) + CLEAR);
114        fits(&bar).then_some(bar)
115    };
116    outside.or_else(inside).map(|bar| clamp(bar, region))
117}
118
119/// `rect` moved — never resized — until it lies inside `region`. A rect too
120/// big for the region aligns to the region's top left, so what overflows is
121/// the far edge rather than the near one.
122#[must_use]
123pub fn clamp(rect: Rect, region: Rect) -> Rect {
124    let shift = |low: f32, high: f32, min: f32, max: f32| {
125        if min < low {
126            low - min
127        } else if max > high {
128            (high - max).max(low - min)
129        } else {
130            0.0
131        }
132    };
133    rect.translate(Vec2::new(
134        shift(region.min.x, region.max.x, rect.min.x, rect.max.x),
135        shift(region.min.y, region.max.y, rect.min.y, rect.max.y),
136    ))
137}
138
139#[cfg(test)]
140mod tests {
141    use blockworx_geom::{pos2, vec2};
142
143    use super::*;
144
145    /// The room the chrome left, as the placement tests read it: the whole
146    /// canvas, or the canvas minus a band an inset took off an edge.
147    fn region() -> Rect {
148        Rect::from_min_size(pos2(0.0, 0.0), vec2(1000.0, 800.0))
149    }
150
151    /// A bar of a size the placement tests can reason about without drawing
152    /// it.
153    const SIZE: Vec2 = Vec2::new(200.0, 40.0);
154
155    #[test]
156    fn places_the_bar_above_the_selection_with_the_specs_air() {
157        let sel = Rect::from_min_size(pos2(400.0, 400.0), vec2(100.0, 100.0));
158        let region = region();
159        assert!(
160            region.contains_rect(sel),
161            "precondition: the selection is inside the room the chrome left",
162        );
163        let bar = place(sel, SIZE, region).expect("a mid-canvas selection has room above it");
164        assert_eq!(bar.max.y, sel.min.y - CLEAR, "the air is not §3.3's 14px");
165        assert_eq!(
166            bar.center().x,
167            sel.center().x,
168            "the bar is not centred on it"
169        );
170        assert_eq!(bar.size(), SIZE, "placing resized the bar");
171    }
172
173    #[test]
174    fn flips_below_when_above_would_land_under_the_top_chrome() {
175        let region = Rect::from_min_max(pos2(0.0, 80.0), pos2(1000.0, 800.0));
176        let sel = Rect::from_min_size(pos2(400.0, 100.0), vec2(100.0, 100.0));
177        assert!(
178            sel.min.y - CLEAR - SIZE.y < region.min.y,
179            "precondition: above is under the chrome",
180        );
181        let bar = place(sel, SIZE, region).expect("there is room below it");
182        assert_eq!(bar.min.y, sel.max.y + CLEAR, "it did not flip below");
183    }
184
185    #[test]
186    fn clamps_sideways_into_the_room_the_chrome_left() {
187        let region = Rect::from_min_max(pos2(0.0, 0.0), pos2(688.0, 800.0));
188        let sel = Rect::from_min_size(pos2(640.0, 400.0), vec2(40.0, 40.0));
189        assert!(
190            sel.center().x + SIZE.x / 2.0 > region.max.x,
191            "precondition: centred, the bar would run under the navigator",
192        );
193        let bar = place(sel, SIZE, region).expect("a clamped bar still has a place");
194        assert!(
195            region.contains_rect(bar),
196            "the bar landed at {bar:?}, outside {region:?}",
197        );
198        assert_eq!(bar.size(), SIZE, "clamping resized the bar");
199    }
200
201    /// Invariant 5, over a spread of selections: wherever the bar lands, it is
202    /// never on top of the thing it describes. This holds because the clamp is
203    /// horizontal and both candidate bands are wholly clear in *y* — so a
204    /// clamp that ever moved *y* would fail here.
205    #[test]
206    fn the_bar_never_covers_what_is_selected() {
207        let region = Rect::from_min_max(pos2(92.0, 80.0), pos2(1000.0, 800.0));
208        let mut placed = 0;
209        for x in [-60.0, 0.0, 120.0, 500.0, 900.0, 980.0] {
210            for y in [-60.0, 0.0, 90.0, 400.0, 700.0, 780.0] {
211                for size in [vec2(24.0, 24.0), vec2(320.0, 180.0)] {
212                    let sel = Rect::from_min_size(pos2(x, y), size);
213                    let Some(bar) = place(sel, SIZE, region) else {
214                        continue;
215                    };
216                    placed += 1;
217                    assert!(
218                        !bar.intersects(sel),
219                        "the bar at {bar:?} covers the selection at {sel:?}",
220                    );
221                    assert!(
222                        region.contains_rect(bar),
223                        "the bar at {bar:?} left {region:?}",
224                    );
225                }
226            }
227        }
228        assert!(
229            placed > 0,
230            "no selection was placed — the sweep proved nothing"
231        );
232    }
233
234    /// A selection bigger than the room around it takes the band just inside
235    /// its own leading edge (item 25). Invariant 5 — *the bar never covers
236    /// what is selected* — is kept where it can be: the bar goes outside the
237    /// selection whenever there is an outside, and only an oversized
238    /// selection sees it inside, which beats an area with no overlay at all.
239    #[test]
240    fn a_selection_with_no_room_either_side_takes_the_band_inside_itself() {
241        let sel = Rect::from_min_size(pos2(-100.0, -100.0), vec2(1200.0, 1000.0));
242        let region = region();
243        assert!(
244            !region.contains_rect(sel),
245            "precondition: the selection overruns the region",
246        );
247        let placed = place(sel, SIZE, region).expect("an oversized selection still gets a bar");
248        assert!(
249            region.contains_rect(placed),
250            "the bar landed outside the region at {placed:?}",
251        );
252        assert!(
253            placed.min.y >= region.min.y + CLEAR,
254            "the bar sits flush against the region's own edge: {placed:?}",
255        );
256    }
257
258    /// A rect too big for the region aligns to its top left, so what
259    /// overflows is the far edge rather than the near one.
260    #[test]
261    fn a_rect_too_big_for_the_region_aligns_to_its_near_edge() {
262        let region = Rect::from_min_max(pos2(100.0, 50.0), pos2(300.0, 150.0));
263        let wide = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 400.0));
264        assert_eq!(clamp(wide, region).min, region.min);
265    }
266
267    /// An empty selection says so rather than raising no bar, and says which
268    /// kind of nothing it is.
269    #[test]
270    fn an_empty_bar_names_which_nothing_it_is() {
271        let none: Bar<u8> = Bar::of(Vec::new(), 1, |_: &u8| Precedence::Own);
272        assert!(matches!(none, Bar::Nothing));
273        assert!(none.all().is_empty());
274        let shared: Bar<u8> = Bar::of(Vec::new(), 3, |_: &u8| Precedence::Own);
275        assert!(matches!(shared, Bar::NothingShared));
276        assert_eq!(nothing_to_say(1), "No actions for this selection");
277        assert_eq!(nothing_to_say(3), "No actions shared by this selection");
278    }
279
280    /// The own verbs lead and the clerical ones follow, and within each rank
281    /// the order the registry pushed them in survives.
282    #[test]
283    fn precedence_orders_the_bar_and_the_registry_orders_each_rank() {
284        let ranked = |at: &u8| {
285            if at.is_multiple_of(2) {
286                Precedence::Clerical
287            } else {
288                Precedence::Own
289            }
290        };
291        let bar = Bar::of(vec![0_u8, 1, 2, 3, 4, 5, 6], 1, ranked);
292        assert_eq!(bar.all(), [1, 3, 5, 0, 2, 4, 6]);
293        assert_eq!(bar.row().len(), INLINE_CONTROLS);
294        assert_eq!(bar.row(), [1, 3, 5, 0, 2]);
295        assert_eq!(bar.overflow(), [4, 6]);
296    }
297}