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}