Skip to main content

egui_numberlink/
widget.rs

1//! The egui widget that renders and drives a [`NumberlinkGame`].
2
3use egui::{
4    emath::GuiRounding, Color32, Pos2, Rect, Response, Sense, Stroke, TextStyle, Ui, Vec2, Widget,
5};
6
7use crate::game::{GameStatus, NumberlinkGame};
8
9/// A colorblind-safe default palette (the 8-color Okabe-Ito set, plus a 9th
10/// purple so a 9-pair board — e.g. the web demo's Expert preset — doesn't
11/// wrap back onto an already-used color), cycled if the puzzle has more
12/// pairs than colors. Overridden entirely via [`NumberlinkWidget::colors`].
13pub const DEFAULT_COLORS: &[Color32] = &[
14    Color32::from_rgb(0x00, 0x72, 0xB2), // blue
15    Color32::from_rgb(0xE6, 0x9F, 0x00), // orange
16    Color32::from_rgb(0x00, 0x9E, 0x73), // bluish green
17    Color32::from_rgb(0xD5, 0x5E, 0x00), // vermillion
18    Color32::from_rgb(0xCC, 0x79, 0xA7), // reddish purple
19    Color32::from_rgb(0xF0, 0xE4, 0x42), // yellow
20    Color32::from_rgb(0x56, 0xB4, 0xE9), // sky blue
21    Color32::from_rgb(0x66, 0x66, 0x66), // grey
22    Color32::from_rgb(0x93, 0x70, 0xDB), // purple
23];
24
25/// The total footprint [`NumberlinkWidget`] will occupy for `game` at a
26/// given `cell_size`. Lets a caller pre-size a container before laying the
27/// widget out.
28pub fn content_size(game: &NumberlinkGame, cell_size: f32) -> Vec2 {
29    Vec2::new(game.width as f32, game.height as f32) * cell_size
30}
31
32/// The cell size (in logical pixels) that fits `game`'s board into
33/// `available` space — the same auto-sizing formula [`NumberlinkWidget`]
34/// uses internally when no explicit `cell_size` is set. Lets a caller
35/// pre-compute that fit (e.g. to size a `Scene` before laying the widget
36/// out inside it) instead of duplicating the formula.
37pub fn fit_cell_size(game: &NumberlinkGame, available: Vec2) -> f32 {
38    let by_width = available.x / game.width as f32;
39    let by_height = available.y / game.height as f32;
40    by_width.min(by_height).max(4.0)
41}
42
43/// An egui widget that renders an interactive Numberlink board.
44///
45/// Press and drag from either of a number's two endpoints (both are always
46/// live, symmetrically — grabbing one always starts fresh from there, even
47/// if the path was originally drawn from the other end) to draw/redraw its
48/// path. Grabbing an interior cell of an already-drawn path instead
49/// truncates it to that point. Dragging back over the path's own previous
50/// cell retracts it by one; dragging onto a different number's path is
51/// rejected. A plain click (no drag) on an endpoint or path cell clears/
52/// starts the path immediately, without needing to drag at all.
53///
54/// ```ignore
55/// ui.add(egui_numberlink::NumberlinkWidget::new(&mut game));
56/// ```
57pub struct NumberlinkWidget<'a> {
58    game: &'a mut NumberlinkGame,
59    cell_size: Option<f32>,
60    colors: &'a [Color32],
61    show_numbers: bool,
62    win_message: Option<String>,
63    interactive: bool,
64}
65
66impl<'a> NumberlinkWidget<'a> {
67    pub fn new(game: &'a mut NumberlinkGame) -> Self {
68        Self {
69            game,
70            cell_size: None,
71            colors: DEFAULT_COLORS,
72            show_numbers: true,
73            win_message: None,
74            interactive: true,
75        }
76    }
77
78    /// Override the size (in logical pixels) of each grid cell. When not
79    /// set, the cell size is computed automatically to fill the available
80    /// space of the parent container.
81    pub fn cell_size(mut self, size: f32) -> Self {
82        self.cell_size = Some(size);
83        self
84    }
85
86    /// Per-number color, indexed by number (`colors[number % colors.len()]`)
87    /// — the customization point for embedding apps that want to reskin the
88    /// board. Defaults to [`DEFAULT_COLORS`], a colorblind-safe palette.
89    ///
90    /// # Panics
91    ///
92    /// Panics if `colors` is empty.
93    pub fn colors(mut self, colors: &'a [Color32]) -> Self {
94        assert!(!colors.is_empty(), "colors must not be empty");
95        self.colors = colors;
96        self
97    }
98
99    /// Whether each number's digit is drawn on its two endpoint markers.
100    /// Defaults to `true` — this is what keeps the board reading as
101    /// Numberlink (numbers are the primary identifier) rather than a
102    /// colors-only clone; color is a skin on top, not a replacement.
103    pub fn show_numbers(mut self, show: bool) -> Self {
104        self.show_numbers = show;
105        self
106    }
107
108    /// Message shown in the win banner drawn over the board once
109    /// [`GameStatus::Won`] is reached. Defaults to `"Solved!"`.
110    pub fn win_message(mut self, message: impl Into<String>) -> Self {
111        self.win_message = Some(message.into());
112        self
113    }
114
115    /// Whether the widget responds to drags at all. Set to `false` to
116    /// render the board read-only. Defaults to `true`.
117    pub fn interactive(mut self, interactive: bool) -> Self {
118        self.interactive = interactive;
119        self
120    }
121}
122
123/// White or black, whichever contrasts more with `bg`, for legible text on
124/// an arbitrary caller-supplied color.
125fn contrasting_text_color(bg: Color32) -> Color32 {
126    let luminance =
127        0.2126 * f32::from(bg.r()) + 0.7152 * f32::from(bg.g()) + 0.0722 * f32::from(bg.b());
128    if luminance > 140.0 {
129        Color32::BLACK
130    } else {
131        Color32::WHITE
132    }
133}
134
135impl Widget for NumberlinkWidget<'_> {
136    fn ui(self, ui: &mut Ui) -> Response {
137        let Self {
138            game,
139            cell_size,
140            colors,
141            show_numbers,
142            win_message,
143            interactive,
144        } = self;
145        let width = game.width;
146        let height = game.height;
147        let color_for = |number: usize| colors[number % colors.len()];
148
149        let cell_size = cell_size.unwrap_or_else(|| fit_cell_size(game, ui.available_size()));
150
151        let total_size = Vec2::new(width as f32, height as f32) * cell_size;
152        let sense = if interactive {
153            Sense::click_and_drag()
154        } else {
155            Sense::hover()
156        };
157        let (response, painter) = ui.allocate_painter(total_size, sense);
158        let origin = response.rect.min;
159
160        let cell_rect = |x: usize, y: usize| -> Rect {
161            Rect::from_min_size(
162                origin + Vec2::new(x as f32, y as f32) * cell_size,
163                Vec2::splat(cell_size),
164            )
165        };
166        let cell_at = |pos: Pos2| -> Option<(usize, usize)> {
167            let local = pos - origin;
168            if local.x >= 0.0 && local.y >= 0.0 && local.x < total_size.x && local.y < total_size.y
169            {
170                let cx = (local.x / cell_size).floor() as usize;
171                let cy = (local.y / cell_size).floor() as usize;
172                if cx < width && cy < height {
173                    return Some((cx, cy));
174                }
175            }
176            None
177        };
178
179        // ── Input handling ──────────────────────────────────────────────
180        // The number being drawn is decided once, from the cell the drag
181        // started on, and stashed in egui's per-widget temp memory so every
182        // subsequent frame of the same gesture keeps extending it.
183        let drag_id = response.id.with("numberlink_drag_number");
184        if response.clicked() {
185            // A stationary press+release never fires `drag_started()` (egui
186            // only calls it once the pointer clears a small motion
187            // threshold), so a plain click needs its own branch — otherwise
188            // clicking an endpoint/path cell to clear or restart it would
189            // silently do nothing. There's no later `drag_stopped()` for a
190            // click, so finish the gesture (`end_drag`) immediately instead
191            // of stashing anything in temp memory.
192            if let Some(cell) = response.interact_pointer_pos().and_then(cell_at) {
193                if let Some(number) = game.number_at(cell) {
194                    if game.start_drag(number, cell) {
195                        game.end_drag(number);
196                    }
197                }
198            }
199        } else if response.drag_started() {
200            if let Some(cell) = response.interact_pointer_pos().and_then(cell_at) {
201                if let Some(number) = game.number_at(cell) {
202                    if game.start_drag(number, cell) {
203                        ui.ctx().data_mut(|d| d.insert_temp(drag_id, number));
204                    }
205                }
206            }
207        } else if response.dragged() {
208            if let Some(number) = ui.ctx().data(|d| d.get_temp::<usize>(drag_id)) {
209                if let Some(cell) = response.interact_pointer_pos().and_then(cell_at) {
210                    game.drag_to(number, cell);
211                }
212            }
213        }
214        if response.drag_stopped() {
215            if let Some(number) = ui.ctx().data_mut(|d| d.remove_temp::<usize>(drag_id)) {
216                game.end_drag(number);
217            }
218        }
219
220        // ── Painting ─────────────────────────────────────────────────────
221        let visuals = ui.visuals();
222        let ppi = painter.ctx().pixels_per_point();
223
224        // Board background: plain bordered cells, except blocked cells
225        // (walls no path may ever enter), which get a darker fill plus a
226        // diagonal hatch so they read as an obstacle rather than empty
227        // space still waiting to be drawn on.
228        let wall_color = visuals.widgets.noninteractive.bg_stroke.color;
229        for y in 0..height {
230            for x in 0..width {
231                let rect = cell_rect(x, y).round_to_pixels(ppi);
232                let blocked = game.is_blocked((x, y));
233                painter.rect_filled(
234                    rect,
235                    0.0,
236                    if blocked {
237                        visuals.widgets.noninteractive.bg_fill
238                    } else {
239                        visuals.extreme_bg_color
240                    },
241                );
242                if blocked {
243                    let hatch_stroke = Stroke::new(cell_size * 0.06, wall_color);
244                    let inset = cell_size * 0.18;
245                    painter.line_segment(
246                        [rect.min + Vec2::splat(inset), rect.max - Vec2::splat(inset)],
247                        hatch_stroke,
248                    );
249                    painter.line_segment(
250                        [
251                            Pos2::new(rect.min.x + inset, rect.max.y - inset),
252                            Pos2::new(rect.max.x - inset, rect.min.y + inset),
253                        ],
254                        hatch_stroke,
255                    );
256                }
257                painter.rect_stroke(
258                    rect,
259                    0.0,
260                    Stroke::new(0.5, visuals.widgets.noninteractive.bg_stroke.color),
261                    egui::StrokeKind::Inside,
262                );
263            }
264        }
265
266        // Paths: drawn through cell *centers*, turning at a cell's center —
267        // never along a cell's border/edge — so the pipe visibly runs
268        // through the interior of every cell it occupies.
269        let pipe_width = cell_size * 0.36;
270        let pipe_radius = pipe_width * 0.5;
271        for number in 0..game.pair_count() {
272            let path = game.path_cells(number);
273            if path.len() < 2 {
274                continue;
275            }
276            let color = color_for(number);
277            let stroke = Stroke::new(pipe_width, color);
278            for pair in path.windows(2) {
279                let a = cell_rect(pair[0].0, pair[0].1).center();
280                let b = cell_rect(pair[1].0, pair[1].1).center();
281                painter.line_segment([a, b], stroke);
282            }
283            // Rounded joints: a filled circle at every interior cell of the
284            // path (the two ends get their own bigger endpoint marker below).
285            for &(x, y) in &path[1..path.len() - 1] {
286                painter.circle_filled(cell_rect(x, y).center(), pipe_radius, color);
287            }
288        }
289
290        // Endpoint markers: always drawn (regardless of path state) so the
291        // puzzle's numbers are visible before the player starts, and drawn
292        // last so they sit cleanly on top of a path's end.
293        let marker_radius = cell_size * 0.32;
294        // Monospace at a size scaled to the marker (not a fixed UI text
295        // style) so the digit reads as big and bold at any board size —
296        // matches `egui-minesweeper`'s adjacent-mine-count digits, which
297        // use the same `FontId::monospace(cell_size * factor)` approach.
298        let font = egui::FontId::monospace(cell_size * 0.42);
299        for number in 0..game.pair_count() {
300            let color = color_for(number);
301            let text_color = contrasting_text_color(color);
302            let (a, b) = game.endpoints(number);
303            for &(x, y) in &[a, b] {
304                let center = cell_rect(x, y).center();
305                painter.circle_filled(center, marker_radius, color);
306                if show_numbers {
307                    painter.text(
308                        center,
309                        egui::Align2::CENTER_CENTER,
310                        (number + 1).to_string(),
311                        font.clone(),
312                        text_color,
313                    );
314                }
315            }
316        }
317
318        // Win banner, drawn last so it sits on top of everything else.
319        if game.status == GameStatus::Won {
320            let board_rect = Rect::from_min_size(origin, total_size);
321            let banner_size = Vec2::new(
322                (total_size.x - 20.0).max(20.0),
323                64.0_f32.min(total_size.y - 4.0).max(20.0),
324            );
325            let banner = Rect::from_center_size(board_rect.center(), banner_size);
326            painter.rect_filled(banner, 8.0, Color32::from_black_alpha(170));
327            painter.text(
328                banner.center(),
329                egui::Align2::CENTER_CENTER,
330                win_message.as_deref().unwrap_or("Solved!"),
331                TextStyle::Heading.resolve(ui.style()),
332                Color32::from_rgb(170, 240, 170),
333            );
334        }
335
336        response
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    #[test]
345    fn default_colors_are_all_pairwise_distinct() {
346        // Regression test: `color_for` indexes via `number % colors.len()`,
347        // so any duplicate entry silently collides two pairs' colors once
348        // the board has that many pairs -- as happened when the web demo's
349        // Expert preset moved to 9 pairs against what was then an 8-color
350        // palette, wrapping pair 9 (0-indexed 8) back onto pair 1's blue.
351        for (i, &a) in DEFAULT_COLORS.iter().enumerate() {
352            for &b in &DEFAULT_COLORS[i + 1..] {
353                assert_ne!(a, b, "DEFAULT_COLORS has a duplicate entry");
354            }
355        }
356    }
357}