Skip to main content

egui_hexweb/
widget.rs

1//! The egui widget that renders and drives a [`HexwebGame`]. This is the
2//! only module allowed to touch `egui::Ui`/`Painter`.
3//!
4//! # Interaction model
5//!
6//! Pieces move by drag & drop, with a click-to-select fallback: press a
7//! piece and release it over any empty node, or click a piece and then
8//! click where it should go. While a piece is dragged it goes translucent
9//! at its origin and every empty node grows a landing ring, the nearest
10//! one emphasized; releasing anywhere that is not a legal target snaps the
11//! piece back. A piece never leaves its node until the gesture commits, so
12//! a drag that ends badly is not a move at all.
13
14use std::f32::consts::TAU;
15
16use egui::{
17    Color32, CornerRadius, CursorIcon, FontId, Pos2, Rect, Response, Sense, Shape, Stroke,
18    StrokeKind, Ui, Vec2, Widget,
19};
20
21use crate::game::{Dir, GameStatus, HexwebGame, Node, NodeId, PieceId};
22
23/// `sqrt(3)`: the vertical distance between two hexagon rows, as a
24/// fraction of the circumradius.
25const SQRT_3: f32 = 1.732_050_8;
26
27/// Piece hexagon radius, as a fraction of the cell size. Slightly under 1.0
28/// so a rim of the socket shows around every piece.
29const PIECE_RADIUS: f32 = 0.88;
30/// Chevron geometry, as fractions of the piece radius: how far from the
31/// piece's centre the arrow tip sits, how far back its wings start, and how
32/// far apart they are.
33const ARROW_TIP: f32 = 0.62;
34const ARROW_BACK: f32 = 0.30;
35const ARROW_SPREAD: f32 = 0.17;
36/// Arrow stroke width, as a fraction of the cell size.
37const ARROW_STROKE: f32 = 0.085;
38/// Radius of the landing and selection rings, as a fraction of the cell
39/// size. Under the socket apothem (`sqrt(3)/2`) so rings stay inside the
40/// node they mark.
41const RING_RADIUS: f32 = 0.80;
42
43/// The total footprint [`HexwebWidget`] will occupy for `game` at a given
44/// `cell_size`. Derived from the actual node centers, so irregular board
45/// shapes (a pendant node, a notched hexagon) get a tight box instead of a
46/// bounding square. Lets a caller pre-size a container before laying the
47/// widget out.
48pub fn content_size(game: &HexwebGame, cell_size: f32) -> Vec2 {
49    let (min, max) = center_bounds(game, cell_size);
50    (max - min) + Vec2::new(2.0, SQRT_3) * cell_size
51}
52
53/// The cell size (hex circumradius, in logical pixels) that fits `game`'s
54/// board into `available` space — the same auto-sizing formula
55/// [`HexwebWidget`] uses internally when no explicit `cell_size` is set.
56/// Lets a caller pre-compute that fit (e.g. to size a `Scene` before laying
57/// the widget out inside it) instead of duplicating the formula.
58pub fn fit_cell_size(game: &HexwebGame, available: Vec2) -> f32 {
59    let unit = content_size(game, 1.0);
60    (available.x / unit.x).min(available.y / unit.y).max(4.0)
61}
62
63/// Centre of node `(q, r)` relative to the `(0, 0)` centre, for a flat-top
64/// hexagon of circumradius `size`: columns step `1.5·size` sideways, rows
65/// `sqrt(3)·size` vertically, with every odd column shifted half a row.
66fn node_center(q: i32, r: i32, size: f32) -> Vec2 {
67    Vec2::new(
68        1.5 * size * q as f32,
69        SQRT_3 * size * (r as f32 + 0.5 * q as f32),
70    )
71}
72
73/// Screen angle of a direction. `Dir::ALL` runs clockwise from the top, and
74/// egui angles run clockwise from +x with y pointing down, so `Dir::Up` is
75/// at -90°.
76fn dir_angle(dir: Dir) -> f32 {
77    -TAU / 4.0 + TAU / 6.0 * dir as u8 as f32
78}
79
80/// The (min, max) of the node centers laid out at circumradius `size`.
81fn center_bounds(game: &HexwebGame, size: f32) -> (Vec2, Vec2) {
82    let mut min = Vec2::splat(f32::INFINITY);
83    let mut max = Vec2::splat(f32::NEG_INFINITY);
84    for node in game.nodes() {
85        let c = node_center(node.q, node.r, size);
86        min = min.min(c);
87        max = max.max(c);
88    }
89    (min, max)
90}
91
92/// The corners of a flat-top hexagon, clockwise.
93fn hex_corners(center: Pos2, radius: f32) -> Vec<Pos2> {
94    (0..6)
95        .map(|i| center + Vec2::angled(TAU / 6.0 * i as f32) * radius)
96        .collect()
97}
98
99/// One arrow, as a chevron pointing from the piece's centre toward `dir`.
100fn draw_arrow(painter: &egui::Painter, center: Pos2, dir: Dir, radius: f32, stroke: Stroke) {
101    let v = Vec2::angled(dir_angle(dir));
102    let tip = center + v * (radius * ARROW_TIP);
103    let back = center + v * (radius * ARROW_BACK);
104    let spread = v.rot90() * (radius * ARROW_SPREAD);
105    painter.add(Shape::line(vec![back + spread, tip, back - spread], stroke));
106}
107
108/// The mapping between board nodes and screen positions for one widget
109/// pass. Shared by painting and hit-testing so the two can never drift
110/// apart.
111#[derive(Clone, Copy)]
112struct Geometry {
113    /// Top-left of the widget's allocated rect.
114    origin: Pos2,
115    /// Hex circumradius.
116    cell: f32,
117    /// Node-center bounding-box min at this `cell`, used to shift the board
118    /// into the allocated rect with a margin of one hexagon.
119    min: Vec2,
120}
121
122impl Geometry {
123    fn new(origin: Pos2, cell: f32, game: &HexwebGame) -> Self {
124        Self {
125            origin,
126            cell,
127            min: center_bounds(game, cell).0,
128        }
129    }
130
131    /// Screen position of `node`'s center.
132    fn center(&self, node: &Node) -> Pos2 {
133        self.origin + node_center(node.q, node.r, self.cell) - self.min
134            + Vec2::new(self.cell, SQRT_3 * 0.5 * self.cell)
135    }
136
137    /// The node nearest `pos`, if it is close enough to claim the pointer.
138    /// Neighboring centers sit `sqrt(3)·cell` apart, so gating the nearest
139    /// at one circumradius covers every node's own hexagonal cell without
140    /// any two cells overlapping.
141    fn node_at(&self, game: &HexwebGame, pos: Pos2) -> Option<NodeId> {
142        let mut best = None;
143        let mut best_d = f32::INFINITY;
144        for (id, node) in game.nodes().iter().enumerate() {
145            let d = pos.distance(self.center(node));
146            if d < best_d {
147                best = Some(id);
148                best_d = d;
149            }
150        }
151        match best {
152            Some(id) if best_d <= self.cell => Some(id),
153            _ => None,
154        }
155    }
156}
157
158/// An egui widget that renders an interactive hexweb board.
159///
160/// Drag a piece onto any empty node, or click a piece and then click its
161/// destination. Arrows pointing at another piece are drawn in
162/// `satisfied_color`; the ones still pointing at an empty node or off the
163/// board are drawn in `unsatisfied_color`, which is the board's live
164/// progress feedback. The puzzle is solved when no arrow is unsatisfied.
165///
166/// ```ignore
167/// ui.add(egui_hexweb::HexwebWidget::new(&mut game));
168/// ```
169pub struct HexwebWidget<'a> {
170    game: &'a mut HexwebGame,
171    cell_size: Option<f32>,
172    win_message: Option<String>,
173    interactive: bool,
174    satisfied_color: Option<Color32>,
175    unsatisfied_color: Option<Color32>,
176}
177
178impl<'a> HexwebWidget<'a> {
179    pub fn new(game: &'a mut HexwebGame) -> Self {
180        Self {
181            game,
182            cell_size: None,
183            win_message: None,
184            interactive: true,
185            satisfied_color: None,
186            unsatisfied_color: None,
187        }
188    }
189
190    /// Override the size (in logical pixels) of each hexagon's
191    /// circumradius. When not set, the cell size is computed automatically
192    /// to fill the available space of the parent container.
193    pub fn cell_size(mut self, size: f32) -> Self {
194        self.cell_size = Some(size);
195        self
196    }
197
198    /// Message shown in the win banner drawn over the board once
199    /// [`GameStatus::Won`] is reached. Defaults to `"Solved!"`. The crate
200    /// ships no translations; embedding apps pass their own.
201    pub fn win_message(mut self, message: impl Into<String>) -> Self {
202        self.win_message = Some(message.into());
203        self
204    }
205
206    /// Color for arrows that point at an occupied node. Defaults to the
207    /// theme's strong text color.
208    pub fn satisfied_color(mut self, color: Color32) -> Self {
209        self.satisfied_color = Some(color);
210        self
211    }
212
213    /// Color for arrows that point at an empty node or off the board.
214    /// Defaults to the theme's error color — the accent the player reads
215    /// as "this piece still needs to move".
216    pub fn unsatisfied_color(mut self, color: Color32) -> Self {
217        self.unsatisfied_color = Some(color);
218        self
219    }
220
221    /// Whether the widget responds to input at all. Set to `false` to
222    /// render the board read-only. Defaults to `true`.
223    pub fn interactive(mut self, interactive: bool) -> Self {
224        self.interactive = interactive;
225        self
226    }
227}
228
229impl Widget for HexwebWidget<'_> {
230    fn ui(self, ui: &mut Ui) -> Response {
231        let Self {
232            game,
233            cell_size,
234            win_message,
235            interactive,
236            satisfied_color,
237            unsatisfied_color,
238        } = self;
239
240        let cell = cell_size.unwrap_or_else(|| fit_cell_size(game, ui.available_size()));
241        let total_size = content_size(game, cell);
242
243        let sense = if interactive {
244            Sense::click_and_drag()
245        } else {
246            Sense::hover()
247        };
248        let (response, painter) = ui.allocate_painter(total_size, sense);
249        let geometry = Geometry::new(response.rect.min, cell, game);
250
251        // ── Input ────────────────────────────────────────────────────────
252        // The dragged (or selected) piece is stashed in egui's per-widget
253        // temp memory so a gesture spans frames without the game holding
254        // any presentation state.
255        let drag_id = response.id.with("hexweb_drag_piece");
256        let select_id = response.id.with("hexweb_selected_piece");
257        let can_play = interactive && game.status() == GameStatus::InProgress;
258        let pointer = response.interact_pointer_pos();
259        let mut dragging: Option<PieceId> = ui.ctx().data(|d| d.get_temp(drag_id));
260
261        if can_play {
262            if response.clicked() {
263                // A stationary press+release never fires `drag_started()`,
264                // so the click path owns both selection and click-to-move.
265                let hit = pointer.and_then(|pos| geometry.node_at(game, pos));
266                let hit_piece = hit.and_then(|node| game.piece_at(node));
267                if let Some(piece) = hit_piece {
268                    let already =
269                        ui.ctx().data(|d| d.get_temp::<PieceId>(select_id)) == Some(piece);
270                    ui.ctx().data_mut(|d| {
271                        if already {
272                            d.remove_temp::<PieceId>(select_id);
273                        } else {
274                            d.insert_temp(select_id, piece);
275                        }
276                    });
277                } else if let Some(node) = hit {
278                    if let Some(selected) = ui.ctx().data(|d| d.get_temp::<PieceId>(select_id)) {
279                        if game.move_piece(selected, node) {
280                            ui.ctx().data_mut(|d| d.remove_temp::<PieceId>(select_id));
281                        }
282                    }
283                } else {
284                    ui.ctx().data_mut(|d| d.remove_temp::<PieceId>(select_id));
285                }
286            } else if response.drag_started() {
287                // Hit-test where the press started, not where the pointer
288                // is now: the first move event can already have crossed
289                // into a neighboring node's cell, and grabbing whatever
290                // piece happens to be under the *current* pointer would
291                // steal the wrong piece on a fast flick.
292                let grabbed = ui
293                    .input(|i| i.pointer.press_origin())
294                    .and_then(|pos| geometry.node_at(game, pos))
295                    .and_then(|node| game.piece_at(node));
296                if let Some(piece) = grabbed {
297                    ui.ctx().data_mut(|d| {
298                        d.insert_temp(drag_id, piece);
299                        d.remove_temp::<PieceId>(select_id);
300                    });
301                    dragging = Some(piece);
302                }
303            }
304        }
305
306        // Where the dragged piece would land right now, if anywhere.
307        let drag_target = if can_play {
308            dragging.and_then(|piece| {
309                pointer
310                    .and_then(|pos| geometry.node_at(game, pos))
311                    .filter(|&node| game.can_move(piece, node))
312            })
313        } else {
314            None
315        };
316
317        if response.drag_stopped() {
318            if let Some(piece) = ui.ctx().data_mut(|d| d.remove_temp::<PieceId>(drag_id)) {
319                if let Some(node) = pointer
320                    .and_then(|pos| geometry.node_at(game, pos))
321                    .filter(|&node| game.can_move(piece, node))
322                {
323                    game.move_piece(piece, node);
324                }
325            }
326            dragging = None;
327        }
328
329        if can_play {
330            if dragging.is_some() {
331                ui.ctx().set_cursor_icon(CursorIcon::Grabbing);
332            } else if let Some(node) = response
333                .hover_pos()
334                .and_then(|pos| geometry.node_at(game, pos))
335            {
336                if game.piece_at(node).is_some() {
337                    ui.ctx().set_cursor_icon(CursorIcon::Grab);
338                } else if ui
339                    .ctx()
340                    .data(|d| d.get_temp::<PieceId>(select_id).is_some())
341                {
342                    ui.ctx().set_cursor_icon(CursorIcon::PointingHand);
343                }
344            }
345        }
346
347        // ── Painting ─────────────────────────────────────────────────────
348        let visuals = ui.visuals();
349        let satisfied_color = satisfied_color.unwrap_or(visuals.strong_text_color());
350        let unsatisfied_color = unsatisfied_color.unwrap_or(visuals.error_fg_color);
351
352        let (piece_fill, piece_edge) = if visuals.dark_mode {
353            (Color32::from_gray(0x5C), Color32::from_gray(0x82))
354        } else {
355            (Color32::from_gray(0xC9), Color32::from_gray(0x8E))
356        };
357        let socket_fill = visuals.extreme_bg_color;
358        let socket_edge = visuals.widgets.noninteractive.bg_stroke.color;
359
360        // Sockets first: every node shows one, pieces draw over them.
361        for node in game.nodes() {
362            painter.add(Shape::convex_polygon(
363                hex_corners(geometry.center(node), cell),
364                socket_fill,
365                Stroke::new(1.0, socket_edge),
366            ));
367        }
368
369        // Landing rings while a piece is in the air: a faint ring on every
370        // empty node, a strong one on the node the piece would drop on.
371        if can_play && dragging.is_some() {
372            for (id, node) in game.nodes().iter().enumerate() {
373                if game.piece_at(id).is_some() {
374                    continue;
375                }
376                let stroke = if drag_target == Some(id) {
377                    Stroke::new(cell * 0.10, satisfied_color)
378                } else {
379                    Stroke::new(cell * 0.05, piece_fill)
380                };
381                painter.circle_stroke(geometry.center(node), cell * RING_RADIUS, stroke);
382            }
383        }
384
385        // Selection ring for the click-to-select fallback.
386        if can_play && dragging.is_none() {
387            if let Some(selected) = ui.ctx().data(|d| d.get_temp::<PieceId>(select_id)) {
388                if let Some(node) = game.node_of(selected) {
389                    painter.circle_stroke(
390                        geometry.center(&game.nodes()[node]),
391                        cell * RING_RADIUS,
392                        Stroke::new(cell * 0.075, satisfied_color),
393                    );
394                }
395            }
396        }
397
398        // Pieces and their arrows, on top of everything.
399        for piece in 0..game.piece_count() {
400            let Some(node) = game.node_of(piece) else {
401                continue;
402            };
403            let center = geometry.center(&game.nodes()[node]);
404            let in_hand = dragging == Some(piece);
405            let alpha = if in_hand { 0.35 } else { 1.0 };
406            let arrows = game.pieces()[piece].arrows;
407            let unsatisfied = game.unsatisfied_arrows(piece);
408
409            painter.add(Shape::convex_polygon(
410                hex_corners(center, cell * PIECE_RADIUS),
411                piece_fill.gamma_multiply(alpha),
412                Stroke::new(cell * 0.05, piece_edge.gamma_multiply(alpha)),
413            ));
414            for dir in arrows.iter() {
415                let color = if unsatisfied.contains(dir) {
416                    unsatisfied_color
417                } else {
418                    satisfied_color
419                };
420                draw_arrow(
421                    &painter,
422                    center,
423                    dir,
424                    cell * PIECE_RADIUS,
425                    Stroke::new(cell * ARROW_STROKE, color.gamma_multiply(alpha)),
426                );
427            }
428        }
429
430        // Win banner, drawn last so it sits on top of everything else.
431        if game.status() == GameStatus::Won {
432            let message = win_message.unwrap_or_else(|| "Solved!".to_owned());
433            let font = FontId::proportional((cell * 0.6).clamp(14.0, 40.0));
434            let galley = painter.layout_no_wrap(message, font, satisfied_color);
435            // Hugging the top edge rather than centred: the fully satisfied
436            // ring of arrows is the reward for solving the board, and a
437            // banner in the middle of it covers exactly the part worth
438            // looking at.
439            let size = galley.size() + Vec2::new(cell, cell * 0.6);
440            let banner = Rect::from_center_size(
441                Pos2::new(
442                    response.rect.center().x,
443                    response.rect.min.y + size.y * 0.5 + cell * 0.15,
444                ),
445                size,
446            );
447            painter.rect_filled(banner, CornerRadius::same(6), visuals.window_fill);
448            painter.rect_stroke(
449                banner,
450                CornerRadius::same(6),
451                Stroke::new(1.0, satisfied_color),
452                StrokeKind::Inside,
453            );
454            painter.galley(
455                banner.center() - galley.size() * 0.5,
456                galley,
457                Color32::PLACEHOLDER,
458            );
459        }
460
461        response
462    }
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468    use crate::game::{random_symmetric_board, Params};
469
470    /// Beginner-shaped board: 8 nodes, 6 pieces, deterministic per seed.
471    fn preset_game(nodes: usize, pieces: usize, seed: u64) -> HexwebGame {
472        HexwebGame::random(
473            Params {
474                nodes: random_symmetric_board(nodes, seed),
475                pieces,
476                min_arrows: 2,
477                max_arrows: 4,
478            },
479            seed,
480        )
481    }
482
483    #[test]
484    fn dir_angles_match_the_pixel_deltas() {
485        for dir in Dir::ALL {
486            let (dq, dr) = dir.delta();
487            let pixel = Vec2::new(1.5 * dq as f32, SQRT_3 * (dr as f32 + 0.5 * dq as f32));
488            let angled = Vec2::angled(dir_angle(dir));
489            assert!(
490                (pixel.normalized() - angled).length() < 1e-4,
491                "{dir:?} paints toward {angled:?}, lattice delta is {pixel:?}"
492            );
493        }
494    }
495
496    #[test]
497    fn content_size_and_fit_round_trip() {
498        let game = preset_game(12, 9, 7);
499        let size = content_size(&game, 10.0);
500        assert!((fit_cell_size(&game, size) - 10.0).abs() < 1e-4);
501        // The board is not a square: the fit is bounded by the tighter side.
502        assert!(size.x != size.y);
503        assert!(fit_cell_size(&game, Vec2::splat(20.0)) >= 4.0);
504    }
505
506    #[test]
507    fn node_at_round_trips_through_every_center() {
508        let game = preset_game(16, 12, 3);
509        let geometry = Geometry::new(Pos2::ZERO, 20.0, &game);
510        for (id, node) in game.nodes().iter().enumerate() {
511            assert_eq!(geometry.node_at(&game, geometry.center(node)), Some(id));
512            // A small offset stays within the node's cell.
513            let near = geometry.center(node) + Vec2::new(6.0, -4.0);
514            assert_eq!(geometry.node_at(&game, near), Some(id));
515        }
516        assert_eq!(
517            geometry.node_at(&game, Pos2::new(-999.0, -999.0)),
518            None,
519            "far away from the board there is nothing to hit"
520        );
521    }
522
523    const TEST_CELL: f32 = 24.0;
524
525    /// Runs the widget in a real `egui` pass so clicks and drags are
526    /// exercised end to end: the click/drag split, the temp-memory gesture
527    /// state, and the drop logic only behave correctly through egui's own
528    /// pointer machinery, which a plain method call can't check.
529    struct Harness {
530        ctx: egui::Context,
531        game: HexwebGame,
532        rect: Rect,
533        time: f64,
534    }
535
536    impl Harness {
537        fn new(game: HexwebGame) -> Self {
538            let mut harness = Self {
539                ctx: egui::Context::default(),
540                game,
541                rect: Rect::ZERO,
542                time: 0.0,
543            };
544            // The first pass places the board.
545            harness.pass(Vec::new());
546            harness
547        }
548
549        fn pass(&mut self, events: Vec<egui::Event>) -> Rect {
550            self.time += 1.0 / 60.0;
551            let input = egui::RawInput {
552                screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0))),
553                events,
554                time: Some(self.time),
555                ..Default::default()
556            };
557            let game = &mut self.game;
558            let rect = std::cell::Cell::new(Rect::ZERO);
559            let _ = self.ctx.run_ui(input, |ui| {
560                let response = ui.add(HexwebWidget::new(game).cell_size(TEST_CELL));
561                rect.set(response.rect);
562            });
563            self.rect = rect.get();
564            self.rect
565        }
566
567        fn geometry(&self) -> Geometry {
568            Geometry::new(self.rect.min, TEST_CELL, &self.game)
569        }
570
571        fn center(&self, node: NodeId) -> Pos2 {
572            self.geometry().center(&self.game.nodes()[node])
573        }
574
575        fn press(&mut self, pos: Pos2) {
576            self.pass(vec![
577                egui::Event::PointerMoved(pos),
578                egui::Event::PointerButton {
579                    pos,
580                    button: egui::PointerButton::Primary,
581                    pressed: true,
582                    modifiers: Default::default(),
583                },
584            ]);
585        }
586
587        fn drag(&mut self, pos: Pos2) {
588            self.pass(vec![egui::Event::PointerMoved(pos)]);
589        }
590
591        fn release(&mut self, pos: Pos2) {
592            self.pass(vec![
593                egui::Event::PointerMoved(pos),
594                egui::Event::PointerButton {
595                    pos,
596                    button: egui::PointerButton::Primary,
597                    pressed: false,
598                    modifiers: Default::default(),
599                },
600            ]);
601        }
602
603        fn click(&mut self, pos: Pos2) {
604            self.press(pos);
605            self.release(pos);
606        }
607    }
608
609    fn empty_node(game: &HexwebGame) -> NodeId {
610        (0..game.node_count())
611            .find(|&node| game.piece_at(node).is_none())
612            .expect("presets always leave empty nodes")
613    }
614
615    fn occupied_node(game: &HexwebGame) -> NodeId {
616        (0..game.node_count())
617            .find(|&node| game.piece_at(node).is_some())
618            .expect("presets always have pieces")
619    }
620
621    #[test]
622    fn click_piece_then_empty_node_moves_it() {
623        let mut harness = Harness::new(preset_game(8, 6, 11));
624        let from = occupied_node(&harness.game);
625        let piece = harness.game.piece_at(from).unwrap();
626        let target = empty_node(&harness.game);
627
628        harness.click(harness.center(from));
629        assert_eq!(harness.game.node_of(piece), Some(from), "select only");
630        harness.click(harness.center(target));
631
632        assert_eq!(harness.game.node_of(piece), Some(target));
633        assert_eq!(harness.game.moves(), 1);
634    }
635
636    #[test]
637    fn clicking_an_empty_node_without_a_selection_does_nothing() {
638        let mut harness = Harness::new(preset_game(8, 6, 11));
639        harness.click(harness.center(empty_node(&harness.game)));
640        assert_eq!(harness.game.moves(), 0);
641    }
642
643    #[test]
644    fn clicking_the_selected_piece_again_deselects_it() {
645        let mut harness = Harness::new(preset_game(8, 6, 11));
646        let from = occupied_node(&harness.game);
647        harness.click(harness.center(from));
648        harness.click(harness.center(from));
649        harness.click(harness.center(empty_node(&harness.game)));
650        assert_eq!(harness.game.moves(), 0);
651    }
652
653    #[test]
654    fn dragging_a_piece_to_an_empty_node_moves_it() {
655        let mut harness = Harness::new(preset_game(12, 9, 5));
656        let from = occupied_node(&harness.game);
657        let piece = harness.game.piece_at(from).unwrap();
658        let target = empty_node(&harness.game);
659
660        harness.press(harness.center(from));
661        // Cross egui's drag radius so this becomes a drag, not a click.
662        harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
663        harness.drag(harness.center(target));
664        harness.release(harness.center(target));
665
666        assert_eq!(harness.game.node_of(piece), Some(target));
667        assert_eq!(harness.game.moves(), 1);
668    }
669
670    #[test]
671    fn dropping_off_the_board_snaps_back() {
672        let mut harness = Harness::new(preset_game(12, 9, 5));
673        let from = occupied_node(&harness.game);
674        let piece = harness.game.piece_at(from).unwrap();
675
676        harness.press(harness.center(from));
677        harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
678        harness.release(Pos2::new(-100.0, -100.0));
679
680        assert_eq!(harness.game.node_of(piece), Some(from));
681        assert_eq!(harness.game.moves(), 0);
682    }
683
684    #[test]
685    fn dropping_on_an_occupied_node_snaps_back() {
686        let mut harness = Harness::new(preset_game(12, 9, 5));
687        let from = occupied_node(&harness.game);
688        let piece = harness.game.piece_at(from).unwrap();
689        let taken = (0..harness.game.node_count())
690            .find(|&node| node != from && harness.game.piece_at(node).is_some())
691            .expect("a second occupied node exists");
692
693        harness.press(harness.center(from));
694        harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
695        harness.release(harness.center(taken));
696
697        assert_eq!(harness.game.node_of(piece), Some(from));
698        assert_eq!(harness.game.moves(), 0);
699    }
700
701    /// Plays the board's unique solution through legal moves. Pieces can
702    /// sit on each other's targets, so cycles are broken by parking a
703    /// piece on any node no solution needs; there is always at least one
704    /// empty node to park on.
705    fn play_solution(game: &mut HexwebGame) {
706        let Some(solution) = game.solution() else {
707            panic!("test boards always have a solution");
708        };
709        let mut placed: Vec<bool> = (0..game.piece_count())
710            .map(|piece| game.node_of(piece) == Some(solution[piece]))
711            .collect();
712        while placed.iter().any(|&done| !done) {
713            // An unplaced piece whose target is empty can move right away.
714            let step = (0..game.piece_count())
715                .filter(|&piece| !placed[piece])
716                .find_map(|piece| {
717                    game.piece_at(solution[piece])
718                        .is_none()
719                        .then_some((piece, solution[piece]))
720                })
721                .or_else(|| {
722                    // Otherwise every unplaced target is occupied: park any
723                    // unplaced piece on a node no piece targets.
724                    let piece = (0..game.piece_count()).find(|&piece| !placed[piece])?;
725                    let node =
726                        (0..game.node_count()).find(|&node| game.piece_at(node).is_none())?;
727                    Some((piece, node))
728                })
729                .expect("a solvable board is always finishable");
730            let (piece, node) = step;
731            assert!(game.move_piece(piece, node));
732            placed[piece] = game.node_of(piece) == Some(solution[piece]);
733        }
734    }
735
736    #[test]
737    fn a_won_board_ignores_input() {
738        let mut harness = Harness::new(preset_game(8, 6, 2));
739        play_solution(&mut harness.game);
740        assert_eq!(harness.game.status(), GameStatus::Won);
741        let moves = harness.game.moves();
742
743        let from = occupied_node(&harness.game);
744        let target = empty_node(&harness.game);
745        // Clicks no longer select or move...
746        harness.click(harness.center(from));
747        harness.click(harness.center(target));
748        // ...and drags no longer pick anything up.
749        harness.press(harness.center(from));
750        harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
751        harness.release(harness.center(target));
752
753        assert_eq!(harness.game.moves(), moves);
754    }
755}