Skip to main content

egui_nonogram/
widget.rs

1//! The egui widget that renders and drives a [`NonogramGame`](crate::NonogramGame).
2
3use egui::{
4    emath::GuiRounding, Align2, Color32, CornerRadius, FontId, PointerButton, Pos2, Rect, Response,
5    Sense, Stroke, StrokeKind, TextStyle, Ui, Vec2, Visuals, Widget,
6};
7
8use crate::game::{CellState, GameStatus, NonogramGame};
9
10/// Which action a plain (primary) tap performs. Desktop users always have
11/// both actions available (left-click fills, right-click crosses); this
12/// only matters for touch input, which has no secondary-tap gesture — a
13/// toolbar toggle can flip it so mobile players can reach both actions.
14#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
15pub enum TapMode {
16    /// A tap fills the cell.
17    #[default]
18    Fill,
19    /// A tap crosses out the cell.
20    Cross,
21}
22
23/// The mark a click-and-drag gesture is painting, decided once from the
24/// cell the gesture started on and then applied to every cell the pointer
25/// passes over for the rest of that gesture (stored in egui's per-widget
26/// temporary memory so it survives across the frames of one drag).
27#[derive(Clone, Copy, Debug, PartialEq)]
28enum PaintTarget {
29    Fill,
30    Cross,
31    Clear,
32}
33
34impl PaintTarget {
35    fn apply(self, game: &mut NonogramGame, x: usize, y: usize) {
36        match self {
37            Self::Fill => game.fill(x, y),
38            Self::Cross => game.cross(x, y),
39            Self::Clear => game.clear_cell(x, y),
40        }
41    }
42}
43
44/// The single row or column a click-and-drag gesture is locked to, once the
45/// pointer has moved off the origin cell.
46#[derive(Clone, Copy, Debug, PartialEq)]
47enum Axis {
48    Row,
49    Col,
50}
51
52/// Tracks one click-and-drag gesture across frames: the paint target it
53/// decided on, and enough to paint (and un-paint, on retract) a single
54/// row/column line from the origin cell to the pointer's current cell.
55/// Stored in egui's per-widget temporary memory so it survives across the
56/// frames of one drag.
57#[derive(Clone, Debug)]
58struct DragState {
59    target: PaintTarget,
60    origin: (usize, usize),
61    /// The origin cell's state right before this gesture painted it —
62    /// captured because `axis` isn't known yet at gesture-start, so it
63    /// can't go straight into `original` below.
64    origin_original: CellState,
65    /// `None` until the pointer moves off the origin cell.
66    axis: Option<Axis>,
67    /// Indexed by the free coordinate along the locked axis (x for `Row`,
68    /// y for `Col`). Lazily filled the first time each cell is touched,
69    /// holding what that cell looked like before this gesture painted it.
70    original: Vec<Option<CellState>>,
71    /// Current painted range `[min, max]` of the free coordinate — the line
72    /// currently drawn from `origin` to the last-seen pointer cell.
73    span: (usize, usize),
74}
75
76const CLUE_FONT_SIZE: f32 = 14.0;
77const CLUE_CHAR_WIDTH: f32 = CLUE_FONT_SIZE * 0.6; // monospace approximation
78const CLUE_LINE_HEIGHT: f32 = CLUE_FONT_SIZE * 1.3;
79const GUTTER_PADDING: f32 = 8.0;
80const PREVIEW_MAX_SIZE: f32 = 96.0;
81const PREVIEW_GAP: f32 = 12.0;
82
83/// An egui widget that renders a nonogram board with its row/column clues.
84///
85/// Left-click fills a cell; right-click crosses it out. Press and drag to
86/// paint (or erase) a whole stroke of cells in one gesture — the action is
87/// decided by the cell the drag started on and then repeated for every cell
88/// the pointer passes over. Once a row or column's filled cells match its
89/// clue, that clue is dimmed (and struck through) as a solving aid.
90///
91/// ```ignore
92/// ui.add(egui_nonogram::NonogramWidget::new(&mut game));
93/// ```
94pub struct NonogramWidget<'a> {
95    game: &'a mut NonogramGame,
96    cell_size: Option<f32>,
97    tap_mode: TapMode,
98    show_preview: bool,
99    win_message: Option<String>,
100    interactive: bool,
101}
102
103impl<'a> NonogramWidget<'a> {
104    pub fn new(game: &'a mut NonogramGame) -> Self {
105        Self {
106            game,
107            cell_size: None,
108            tap_mode: TapMode::Fill,
109            show_preview: false,
110            win_message: None,
111            interactive: true,
112        }
113    }
114
115    /// Override the size (in logical pixels) of each grid cell. When not
116    /// set, the cell size is computed automatically to fill the available
117    /// space of the parent container (after reserving room for the clue
118    /// gutters).
119    pub fn cell_size(mut self, size: f32) -> Self {
120        self.cell_size = Some(size);
121        self
122    }
123
124    /// What a plain tap/primary-click does. Defaults to [`TapMode::Fill`].
125    pub fn tap_mode(mut self, mode: TapMode) -> Self {
126        self.tap_mode = mode;
127        self
128    }
129
130    /// Show a small read-only thumbnail of the current filled cells beside
131    /// the board — a compact "how's it coming along" overview on larger
132    /// grids. It only ever reflects cells the player has already filled, so
133    /// it can't spoil a picture puzzle's solution. Defaults to `false`.
134    pub fn show_preview(mut self, enabled: bool) -> Self {
135        self.show_preview = enabled;
136        self
137    }
138
139    /// Message shown in the win banner drawn over the board once
140    /// [`GameStatus::Won`] is reached. Defaults to `"Solved!"` when not set.
141    pub fn win_message(mut self, message: impl Into<String>) -> Self {
142        self.win_message = Some(message.into());
143        self
144    }
145
146    /// Whether the widget responds to taps/drags at all. Set to `false`
147    /// to render the board read-only — e.g. while a surrounding
148    /// container (like `egui::containers::Scene`) should own pointer
149    /// gestures instead. Unlike wrapping the widget in a disabled `Ui`,
150    /// this does not dim it: painting stays full-opacity, only the
151    /// pointer sense changes. Defaults to `true`.
152    pub fn interactive(mut self, interactive: bool) -> Self {
153        self.interactive = interactive;
154        self
155    }
156}
157
158fn clue_line_text(clue: &[u8]) -> String {
159    clue.iter().map(u8::to_string).collect::<Vec<_>>().join(" ")
160}
161
162/// Row gutter width and column gutter height for a given game's clues.
163fn gutter_size(game: &NonogramGame) -> Vec2 {
164    let max_row_chars = game
165        .row_clues
166        .iter()
167        .map(|c| clue_line_text(c).chars().count())
168        .max()
169        .unwrap_or(1);
170    let max_col_lines = game.col_clues.iter().map(Vec::len).max().unwrap_or(1);
171
172    let row_gutter_width = max_row_chars as f32 * CLUE_CHAR_WIDTH + GUTTER_PADDING;
173    let col_gutter_height = max_col_lines as f32 * CLUE_LINE_HEIGHT + GUTTER_PADDING * 0.5;
174    Vec2::new(row_gutter_width, col_gutter_height)
175}
176
177/// The total footprint [`NonogramWidget`] will occupy for `game` at a given
178/// `cell_size`, including the clue gutters and (if enabled) the progress
179/// preview column. Lets a caller pre-size a container (e.g. `egui::Scene`)
180/// before laying the widget out.
181pub fn content_size(game: &NonogramGame, cell_size: f32, show_preview: bool) -> Vec2 {
182    let board_size = Vec2::new(game.width as f32, game.height as f32) * cell_size;
183    let preview_width = if show_preview {
184        PREVIEW_MAX_SIZE + PREVIEW_GAP
185    } else {
186        0.0
187    };
188    board_size + gutter_size(game) + Vec2::new(preview_width, 0.0)
189}
190
191impl Widget for NonogramWidget<'_> {
192    fn ui(self, ui: &mut Ui) -> Response {
193        let game = self.game;
194        let font_id = FontId::monospace(CLUE_FONT_SIZE);
195
196        let row_texts: Vec<String> = game.row_clues.iter().map(|c| clue_line_text(c)).collect();
197        let gutter_size = gutter_size(game);
198        let row_gutter_width = gutter_size.x;
199        let col_gutter_height = gutter_size.y;
200        let preview_width = if self.show_preview {
201            PREVIEW_MAX_SIZE + PREVIEW_GAP
202        } else {
203            0.0
204        };
205
206        let cell_size = self.cell_size.unwrap_or_else(|| {
207            let available = ui.available_size();
208            let by_width = (available.x - row_gutter_width - preview_width) / game.width as f32;
209            let by_height = (available.y - col_gutter_height) / game.height as f32;
210            by_width.min(by_height).max(4.0)
211        });
212
213        let board_size = Vec2::new(game.width as f32, game.height as f32) * cell_size;
214        let total_size = board_size + gutter_size + Vec2::new(preview_width, 0.0);
215
216        let sense = if self.interactive {
217            Sense::click_and_drag()
218        } else {
219            Sense::hover()
220        };
221        let (response, painter) = ui.allocate_painter(total_size, sense);
222        let origin = response.rect.min + gutter_size;
223        let width = game.width;
224        let height = game.height;
225        let cell_at = |pos: Pos2| -> Option<(usize, usize)> {
226            let local = pos - origin;
227            if local.x >= 0.0 && local.y >= 0.0 && local.x < board_size.x && local.y < board_size.y
228            {
229                let cx = (local.x / cell_size).floor() as usize;
230                let cy = (local.y / cell_size).floor() as usize;
231                if cx < width && cy < height {
232                    return Some((cx, cy));
233                }
234            }
235            None
236        };
237
238        // ── Input handling ──────────────────────────────────────────────
239        // A gesture (plain click or the start of a drag) decides its paint
240        // target from the cell it started on. A drag then locks to whichever
241        // single row or column the pointer first moves along (nonogram
242        // players always work one line at a time, never diagonally), and
243        // behaves like drawing a line from the origin cell to the pointer's
244        // current cell: extending the line paints new cells, retracting it
245        // restores cells to whatever they were before this gesture touched
246        // them. The whole gesture — however many cells it paints, retracts,
247        // and repaints — is wrapped in `begin_gesture`/`end_gesture` so it
248        // collapses into a single undo step.
249        let drag_id = response.id.with("drag_state");
250        let gesture_start = response.clicked()
251            || response.secondary_clicked()
252            || response.drag_started_by(PointerButton::Primary)
253            || response.drag_started_by(PointerButton::Secondary);
254        if gesture_start {
255            if let Some((cx, cy)) = response.interact_pointer_pos().and_then(cell_at) {
256                let secondary = response.secondary_clicked()
257                    || response.drag_started_by(PointerButton::Secondary);
258                let is_drag_start = response.drag_started_by(PointerButton::Primary)
259                    || response.drag_started_by(PointerButton::Secondary);
260                let current = game.cells[game.idx(cx, cy)];
261                let target = if secondary {
262                    if current == CellState::Crossed {
263                        PaintTarget::Clear
264                    } else {
265                        PaintTarget::Cross
266                    }
267                } else {
268                    match self.tap_mode {
269                        TapMode::Fill if current == CellState::Filled => PaintTarget::Clear,
270                        TapMode::Fill => PaintTarget::Fill,
271                        TapMode::Cross if current == CellState::Crossed => PaintTarget::Clear,
272                        TapMode::Cross => PaintTarget::Cross,
273                    }
274                };
275                game.begin_gesture();
276                ui.ctx().data_mut(|d| {
277                    d.insert_temp(
278                        drag_id,
279                        DragState {
280                            target,
281                            origin: (cx, cy),
282                            origin_original: current,
283                            axis: None,
284                            original: Vec::new(),
285                            span: (0, 0),
286                        },
287                    )
288                });
289                target.apply(game, cx, cy);
290                if !is_drag_start {
291                    game.end_gesture();
292                }
293            }
294        } else if response.dragged() {
295            if let Some((cx, cy)) = response.interact_pointer_pos().and_then(cell_at) {
296                let state = ui.ctx().data(|d| d.get_temp::<DragState>(drag_id));
297                if let Some(mut state) = state {
298                    let (ox, oy) = state.origin;
299                    if state.axis.is_none() && (cx, cy) != (ox, oy) {
300                        let dx = (cx as isize - ox as isize).abs();
301                        let dy = (cy as isize - oy as isize).abs();
302                        let axis = if dx >= dy { Axis::Row } else { Axis::Col };
303                        let len = match axis {
304                            Axis::Row => width,
305                            Axis::Col => height,
306                        };
307                        let origin_free = match axis {
308                            Axis::Row => ox,
309                            Axis::Col => oy,
310                        };
311                        let mut original = vec![None; len];
312                        original[origin_free] = Some(state.origin_original);
313                        state.axis = Some(axis);
314                        state.original = original;
315                        state.span = (origin_free, origin_free);
316                    }
317                    if let Some(axis) = state.axis {
318                        let origin_free = match axis {
319                            Axis::Row => ox,
320                            Axis::Col => oy,
321                        };
322                        let new_free = match axis {
323                            Axis::Row => cx,
324                            Axis::Col => cy,
325                        };
326                        let fixed = match axis {
327                            Axis::Row => oy,
328                            Axis::Col => ox,
329                        };
330                        let cell_for = |f: usize| match axis {
331                            Axis::Row => (f, fixed),
332                            Axis::Col => (fixed, f),
333                        };
334                        let new_span = (origin_free.min(new_free), origin_free.max(new_free));
335                        for f in new_span.0..=new_span.1 {
336                            let (x, y) = cell_for(f);
337                            if state.original[f].is_none() {
338                                state.original[f] = Some(game.cells[game.idx(x, y)]);
339                            }
340                            state.target.apply(game, x, y);
341                        }
342                        for f in state.span.0..=state.span.1 {
343                            if f < new_span.0 || f > new_span.1 {
344                                let (x, y) = cell_for(f);
345                                if let Some(orig) = state.original[f] {
346                                    game.set_cell_state(x, y, orig);
347                                }
348                            }
349                        }
350                        state.span = new_span;
351                    }
352                    ui.ctx().data_mut(|d| d.insert_temp(drag_id, state));
353                }
354            }
355        }
356        if response.drag_stopped() {
357            game.end_gesture();
358        }
359
360        // ── Painting ─────────────────────────────────────────────────────
361        let visuals = ui.visuals();
362        let normal_color = visuals.text_color();
363        // `visuals.weak_text_color()` is too close to `text_color()` to read
364        // as "satisfied" at a glance against this widget's dark cell
365        // background, so dim explicitly and strongly instead.
366        let dim_color = normal_color.gamma_multiply(0.35);
367
368        // Row clues, right-aligned in the left gutter.
369        for (y, row_text) in row_texts.iter().enumerate() {
370            let satisfied = game.row_satisfied(y);
371            let color = if satisfied { dim_color } else { normal_color };
372            let pos = Pos2::new(
373                response.rect.min.x + row_gutter_width - GUTTER_PADDING * 0.5,
374                origin.y + (y as f32 + 0.5) * cell_size,
375            );
376            let text_rect =
377                painter.text(pos, Align2::RIGHT_CENTER, row_text, font_id.clone(), color);
378            if satisfied {
379                let mid_y = text_rect.center().y;
380                painter.line_segment(
381                    [
382                        Pos2::new(text_rect.min.x, mid_y),
383                        Pos2::new(text_rect.max.x, mid_y),
384                    ],
385                    Stroke::new(1.5, color),
386                );
387            }
388        }
389
390        // Column clues, stacked bottom-up in the top gutter. Satisfied
391        // columns get a vertical strikethrough spanning the whole stack —
392        // the rotated equivalent of a row's horizontal strikethrough, since
393        // a single horizontal line can't sensibly cross several stacked
394        // numbers.
395        for x in 0..game.width {
396            let satisfied = game.col_satisfied(x);
397            let color = if satisfied { dim_color } else { normal_color };
398            let x_center = origin.x + (x as f32 + 0.5) * cell_size;
399            let mut stack_top = f32::MAX;
400            let mut stack_bottom = f32::MIN;
401            for (i, n) in game.col_clues[x].iter().rev().enumerate() {
402                let y_center = origin.y - (i as f32 + 0.5) * CLUE_LINE_HEIGHT;
403                let text_rect = painter.text(
404                    Pos2::new(x_center, y_center),
405                    Align2::CENTER_CENTER,
406                    n.to_string(),
407                    font_id.clone(),
408                    color,
409                );
410                stack_top = stack_top.min(text_rect.min.y);
411                stack_bottom = stack_bottom.max(text_rect.max.y);
412            }
413            if satisfied && stack_top < stack_bottom {
414                painter.line_segment(
415                    [
416                        Pos2::new(x_center, stack_top),
417                        Pos2::new(x_center, stack_bottom),
418                    ],
419                    Stroke::new(1.5, color),
420                );
421            }
422        }
423
424        // Grid cells.
425        for y in 0..game.height {
426            for x in 0..game.width {
427                let cell_rect = Rect::from_min_size(
428                    origin + Vec2::new(x as f32, y as f32) * cell_size,
429                    Vec2::splat(cell_size),
430                );
431                draw_cell(&painter, cell_rect, game.cells[game.idx(x, y)], visuals);
432            }
433        }
434
435        // Major gridlines: painted on top of the cells so they read
436        // clearly regardless of Empty/Filled/Crossed state underneath.
437        draw_major_gridlines(&painter, origin, board_size, width, height, cell_size, normal_color);
438
439        // Progress preview: a compact, read-only thumbnail of the filled
440        // cells only, to the right of the board.
441        if self.show_preview {
442            let preview_cell = PREVIEW_MAX_SIZE / game.width.max(game.height) as f32;
443            let preview_origin =
444                Pos2::new(response.rect.max.x - PREVIEW_MAX_SIZE, response.rect.min.y);
445            let preview_size = Vec2::new(game.width as f32, game.height as f32) * preview_cell;
446            painter.rect_filled(
447                Rect::from_min_size(preview_origin, preview_size),
448                CornerRadius::ZERO,
449                visuals.extreme_bg_color,
450            );
451            for y in 0..game.height {
452                for x in 0..game.width {
453                    if game.cells[game.idx(x, y)] == CellState::Filled {
454                        let cell_rect = Rect::from_min_size(
455                            preview_origin + Vec2::new(x as f32, y as f32) * preview_cell,
456                            Vec2::splat(preview_cell),
457                        );
458                        painter.rect_filled(cell_rect, CornerRadius::ZERO, normal_color);
459                    }
460                }
461            }
462            painter.rect_stroke(
463                Rect::from_min_size(preview_origin, preview_size),
464                CornerRadius::ZERO,
465                Stroke::new(1.0, visuals.widgets.noninteractive.bg_stroke.color),
466                StrokeKind::Outside,
467            );
468        }
469
470        // Win banner: drawn last so it sits on top, sized to the board
471        // itself (excludes the clue gutters and the preview column) so it
472        // never covers the picture preview.
473        if game.status == GameStatus::Won {
474            let board_rect = Rect::from_min_size(origin, board_size);
475            let banner_size = Vec2::new(
476                (board_size.x - 20.0).max(20.0),
477                64.0_f32.min(board_size.y - 4.0).max(20.0),
478            );
479            let banner = Rect::from_center_size(board_rect.center(), banner_size);
480            painter.rect_filled(banner, 8.0, Color32::from_black_alpha(170));
481            painter.text(
482                banner.center(),
483                Align2::CENTER_CENTER,
484                self.win_message.as_deref().unwrap_or("Solved!"),
485                TextStyle::Heading.resolve(ui.style()),
486                Color32::from_rgb(170, 240, 170),
487            );
488        }
489
490        response
491    }
492}
493
494fn draw_cell(painter: &egui::Painter, rect: Rect, state: CellState, visuals: &Visuals) {
495    let ppi = painter.ctx().pixels_per_point();
496    let rect = rect.round_to_pixels(ppi);
497    let inner = rect.shrink(1.0);
498
499    match state {
500        CellState::Empty => {
501            painter.rect_filled(inner, CornerRadius::ZERO, visuals.extreme_bg_color);
502        }
503        CellState::Filled => {
504            let fill = if visuals.dark_mode {
505                Color32::from_rgb(225, 225, 230)
506            } else {
507                Color32::from_rgb(35, 35, 40)
508            };
509            painter.rect_filled(inner, CornerRadius::ZERO, fill);
510        }
511        CellState::Crossed => {
512            painter.rect_filled(inner, CornerRadius::ZERO, visuals.extreme_bg_color);
513            let stroke_color = visuals.widgets.noninteractive.fg_stroke.color;
514            let pad = inner.width() * 0.28;
515            painter.line_segment(
516                [inner.min + Vec2::splat(pad), inner.max - Vec2::splat(pad)],
517                Stroke::new(2.0, stroke_color),
518            );
519            painter.line_segment(
520                [
521                    Pos2::new(inner.min.x + pad, inner.max.y - pad),
522                    Pos2::new(inner.max.x - pad, inner.min.y + pad),
523                ],
524                Stroke::new(2.0, stroke_color),
525            );
526        }
527    }
528
529    painter.rect_stroke(
530        inner,
531        CornerRadius::ZERO,
532        Stroke::new(0.5, visuals.widgets.noninteractive.bg_stroke.color),
533        StrokeKind::Inside,
534    );
535}
536
537// Bold guide lines every 5th internal cell boundary — the standard
538// nonogram/sudoku convention for helping players count large grids.
539// Only internal boundaries are drawn (the board's outer edge keeps its
540// plain per-cell border from `draw_cell`); a board with 5 or fewer
541// rows/columns in a dimension naturally gets none, with no special-casing.
542fn draw_major_gridlines(
543    painter: &egui::Painter,
544    origin: Pos2,
545    board_size: Vec2,
546    width: usize,
547    height: usize,
548    cell_size: f32,
549    color: Color32,
550) {
551    let ppi = painter.ctx().pixels_per_point();
552    let stroke = Stroke::new(1.5, color);
553
554    let mut n = 5;
555    while n < width {
556        let x = origin.x + n as f32 * cell_size;
557        painter.line_segment(
558            [
559                Pos2::new(x, origin.y).round_to_pixels(ppi),
560                Pos2::new(x, origin.y + board_size.y).round_to_pixels(ppi),
561            ],
562            stroke,
563        );
564        n += 5;
565    }
566
567    let mut n = 5;
568    while n < height {
569        let y = origin.y + n as f32 * cell_size;
570        painter.line_segment(
571            [
572                Pos2::new(origin.x, y).round_to_pixels(ppi),
573                Pos2::new(origin.x + board_size.x, y).round_to_pixels(ppi),
574            ],
575            stroke,
576        );
577        n += 5;
578    }
579}