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.
163pub fn 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
191/// The cell size (in logical pixels) that fits `game`'s board into
192/// `available` space — the same auto-sizing formula [`NonogramWidget`] uses
193/// internally when no explicit `cell_size` is set. Lets a caller pre-compute
194/// that fit (e.g. to size a `Scene` before laying the widget out inside it)
195/// instead of duplicating the formula.
196pub fn fit_cell_size(game: &NonogramGame, available: Vec2, show_preview: bool) -> f32 {
197    let gutter = gutter_size(game);
198    let preview_width = if show_preview {
199        PREVIEW_MAX_SIZE + PREVIEW_GAP
200    } else {
201        0.0
202    };
203    let by_width = (available.x - gutter.x - preview_width) / game.width as f32;
204    let by_height = (available.y - gutter.y) / game.height as f32;
205    by_width.min(by_height).max(4.0)
206}
207
208impl Widget for NonogramWidget<'_> {
209    fn ui(self, ui: &mut Ui) -> Response {
210        let game = self.game;
211        let font_id = FontId::monospace(CLUE_FONT_SIZE);
212
213        let row_texts: Vec<String> = game.row_clues.iter().map(|c| clue_line_text(c)).collect();
214        let gutter_size = gutter_size(game);
215        let row_gutter_width = gutter_size.x;
216        let preview_width = if self.show_preview {
217            PREVIEW_MAX_SIZE + PREVIEW_GAP
218        } else {
219            0.0
220        };
221
222        let cell_size = self
223            .cell_size
224            .unwrap_or_else(|| fit_cell_size(game, ui.available_size(), self.show_preview));
225
226        let board_size = Vec2::new(game.width as f32, game.height as f32) * cell_size;
227        let total_size = board_size + gutter_size + Vec2::new(preview_width, 0.0);
228
229        let sense = if self.interactive {
230            Sense::click_and_drag()
231        } else {
232            Sense::hover()
233        };
234        let (response, painter) = ui.allocate_painter(total_size, sense);
235        let origin = response.rect.min + gutter_size;
236        let width = game.width;
237        let height = game.height;
238        let cell_at = |pos: Pos2| -> Option<(usize, usize)> {
239            let local = pos - origin;
240            if local.x >= 0.0 && local.y >= 0.0 && local.x < board_size.x && local.y < board_size.y
241            {
242                let cx = (local.x / cell_size).floor() as usize;
243                let cy = (local.y / cell_size).floor() as usize;
244                if cx < width && cy < height {
245                    return Some((cx, cy));
246                }
247            }
248            None
249        };
250
251        // ── Input handling ──────────────────────────────────────────────
252        // A gesture (plain click or the start of a drag) decides its paint
253        // target from the cell it started on. A drag then locks to whichever
254        // single row or column the pointer first moves along (nonogram
255        // players always work one line at a time, never diagonally), and
256        // behaves like drawing a line from the origin cell to the pointer's
257        // current cell: extending the line paints new cells, retracting it
258        // restores cells to whatever they were before this gesture touched
259        // them. The whole gesture — however many cells it paints, retracts,
260        // and repaints — is wrapped in `begin_gesture`/`end_gesture` so it
261        // collapses into a single undo step.
262        let drag_id = response.id.with("drag_state");
263        let gesture_start = response.clicked()
264            || response.secondary_clicked()
265            || response.drag_started_by(PointerButton::Primary)
266            || response.drag_started_by(PointerButton::Secondary);
267        if gesture_start {
268            if let Some((cx, cy)) = response.interact_pointer_pos().and_then(cell_at) {
269                let secondary = response.secondary_clicked()
270                    || response.drag_started_by(PointerButton::Secondary);
271                let is_drag_start = response.drag_started_by(PointerButton::Primary)
272                    || response.drag_started_by(PointerButton::Secondary);
273                let current = game.cells[game.idx(cx, cy)];
274                let target = if secondary {
275                    if current == CellState::Crossed {
276                        PaintTarget::Clear
277                    } else {
278                        PaintTarget::Cross
279                    }
280                } else {
281                    match self.tap_mode {
282                        TapMode::Fill if current == CellState::Filled => PaintTarget::Clear,
283                        TapMode::Fill => PaintTarget::Fill,
284                        TapMode::Cross if current == CellState::Crossed => PaintTarget::Clear,
285                        TapMode::Cross => PaintTarget::Cross,
286                    }
287                };
288                game.begin_gesture();
289                ui.ctx().data_mut(|d| {
290                    d.insert_temp(
291                        drag_id,
292                        DragState {
293                            target,
294                            origin: (cx, cy),
295                            origin_original: current,
296                            axis: None,
297                            original: Vec::new(),
298                            span: (0, 0),
299                        },
300                    )
301                });
302                target.apply(game, cx, cy);
303                if !is_drag_start {
304                    game.end_gesture();
305                }
306            }
307        } else if response.dragged() {
308            if let Some((cx, cy)) = response.interact_pointer_pos().and_then(cell_at) {
309                let state = ui.ctx().data(|d| d.get_temp::<DragState>(drag_id));
310                if let Some(mut state) = state {
311                    let (ox, oy) = state.origin;
312                    if state.axis.is_none() && (cx, cy) != (ox, oy) {
313                        let dx = (cx as isize - ox as isize).abs();
314                        let dy = (cy as isize - oy as isize).abs();
315                        let axis = if dx >= dy { Axis::Row } else { Axis::Col };
316                        let len = match axis {
317                            Axis::Row => width,
318                            Axis::Col => height,
319                        };
320                        let origin_free = match axis {
321                            Axis::Row => ox,
322                            Axis::Col => oy,
323                        };
324                        let mut original = vec![None; len];
325                        original[origin_free] = Some(state.origin_original);
326                        state.axis = Some(axis);
327                        state.original = original;
328                        state.span = (origin_free, origin_free);
329                    }
330                    if let Some(axis) = state.axis {
331                        let origin_free = match axis {
332                            Axis::Row => ox,
333                            Axis::Col => oy,
334                        };
335                        let new_free = match axis {
336                            Axis::Row => cx,
337                            Axis::Col => cy,
338                        };
339                        let fixed = match axis {
340                            Axis::Row => oy,
341                            Axis::Col => ox,
342                        };
343                        let cell_for = |f: usize| match axis {
344                            Axis::Row => (f, fixed),
345                            Axis::Col => (fixed, f),
346                        };
347                        let new_span = (origin_free.min(new_free), origin_free.max(new_free));
348                        for f in new_span.0..=new_span.1 {
349                            let (x, y) = cell_for(f);
350                            if state.original[f].is_none() {
351                                state.original[f] = Some(game.cells[game.idx(x, y)]);
352                            }
353                            state.target.apply(game, x, y);
354                        }
355                        for f in state.span.0..=state.span.1 {
356                            if f < new_span.0 || f > new_span.1 {
357                                let (x, y) = cell_for(f);
358                                if let Some(orig) = state.original[f] {
359                                    game.set_cell_state(x, y, orig);
360                                }
361                            }
362                        }
363                        state.span = new_span;
364                    }
365                    ui.ctx().data_mut(|d| d.insert_temp(drag_id, state));
366                }
367            }
368        }
369        if response.drag_stopped() {
370            game.end_gesture();
371        }
372
373        // ── Painting ─────────────────────────────────────────────────────
374        let visuals = ui.visuals();
375        let normal_color = visuals.text_color();
376        // `visuals.weak_text_color()` is too close to `text_color()` to read
377        // as "satisfied" at a glance against this widget's dark cell
378        // background, so dim explicitly and strongly instead.
379        let dim_color = normal_color.gamma_multiply(0.35);
380
381        // Row clues, right-aligned in the left gutter.
382        for (y, row_text) in row_texts.iter().enumerate() {
383            let satisfied = game.row_satisfied(y);
384            let color = if satisfied { dim_color } else { normal_color };
385            let pos = Pos2::new(
386                response.rect.min.x + row_gutter_width - GUTTER_PADDING * 0.5,
387                origin.y + (y as f32 + 0.5) * cell_size,
388            );
389            let text_rect =
390                painter.text(pos, Align2::RIGHT_CENTER, row_text, font_id.clone(), color);
391            if satisfied {
392                let mid_y = text_rect.center().y;
393                painter.line_segment(
394                    [
395                        Pos2::new(text_rect.min.x, mid_y),
396                        Pos2::new(text_rect.max.x, mid_y),
397                    ],
398                    Stroke::new(1.5, color),
399                );
400            }
401        }
402
403        // Column clues, stacked bottom-up in the top gutter. Satisfied
404        // columns get a vertical strikethrough spanning the whole stack —
405        // the rotated equivalent of a row's horizontal strikethrough, since
406        // a single horizontal line can't sensibly cross several stacked
407        // numbers.
408        for x in 0..game.width {
409            let satisfied = game.col_satisfied(x);
410            let color = if satisfied { dim_color } else { normal_color };
411            let x_center = origin.x + (x as f32 + 0.5) * cell_size;
412            let mut stack_top = f32::MAX;
413            let mut stack_bottom = f32::MIN;
414            for (i, n) in game.col_clues[x].iter().rev().enumerate() {
415                let y_center = origin.y - (i as f32 + 0.5) * CLUE_LINE_HEIGHT;
416                let text_rect = painter.text(
417                    Pos2::new(x_center, y_center),
418                    Align2::CENTER_CENTER,
419                    n.to_string(),
420                    font_id.clone(),
421                    color,
422                );
423                stack_top = stack_top.min(text_rect.min.y);
424                stack_bottom = stack_bottom.max(text_rect.max.y);
425            }
426            if satisfied && stack_top < stack_bottom {
427                painter.line_segment(
428                    [
429                        Pos2::new(x_center, stack_top),
430                        Pos2::new(x_center, stack_bottom),
431                    ],
432                    Stroke::new(1.5, color),
433                );
434            }
435        }
436
437        // Grid cells.
438        for y in 0..game.height {
439            for x in 0..game.width {
440                let cell_rect = Rect::from_min_size(
441                    origin + Vec2::new(x as f32, y as f32) * cell_size,
442                    Vec2::splat(cell_size),
443                );
444                draw_cell(&painter, cell_rect, game.cells[game.idx(x, y)], visuals);
445            }
446        }
447
448        // Major gridlines: painted on top of the cells so they read
449        // clearly regardless of Empty/Filled/Crossed state underneath.
450        draw_major_gridlines(
451            &painter,
452            origin,
453            board_size,
454            width,
455            height,
456            cell_size,
457            normal_color,
458        );
459
460        // Progress preview: a compact, read-only thumbnail of the filled
461        // cells only, to the right of the board.
462        if self.show_preview {
463            let preview_cell = PREVIEW_MAX_SIZE / game.width.max(game.height) as f32;
464            let preview_origin =
465                Pos2::new(response.rect.max.x - PREVIEW_MAX_SIZE, response.rect.min.y);
466            let preview_size = Vec2::new(game.width as f32, game.height as f32) * preview_cell;
467            painter.rect_filled(
468                Rect::from_min_size(preview_origin, preview_size),
469                CornerRadius::ZERO,
470                visuals.extreme_bg_color,
471            );
472            for y in 0..game.height {
473                for x in 0..game.width {
474                    if game.cells[game.idx(x, y)] == CellState::Filled {
475                        let cell_rect = Rect::from_min_size(
476                            preview_origin + Vec2::new(x as f32, y as f32) * preview_cell,
477                            Vec2::splat(preview_cell),
478                        );
479                        painter.rect_filled(cell_rect, CornerRadius::ZERO, normal_color);
480                    }
481                }
482            }
483            painter.rect_stroke(
484                Rect::from_min_size(preview_origin, preview_size),
485                CornerRadius::ZERO,
486                Stroke::new(1.0, visuals.widgets.noninteractive.bg_stroke.color),
487                StrokeKind::Outside,
488            );
489        }
490
491        // Win banner: drawn last so it sits on top, sized to the board
492        // itself (excludes the clue gutters and the preview column) so it
493        // never covers the picture preview.
494        if game.status == GameStatus::Won {
495            let board_rect = Rect::from_min_size(origin, board_size);
496            let banner_size = Vec2::new(
497                (board_size.x - 20.0).max(20.0),
498                64.0_f32.min(board_size.y - 4.0).max(20.0),
499            );
500            let banner = Rect::from_center_size(board_rect.center(), banner_size);
501            painter.rect_filled(banner, 8.0, Color32::from_black_alpha(170));
502            painter.text(
503                banner.center(),
504                Align2::CENTER_CENTER,
505                self.win_message.as_deref().unwrap_or("Solved!"),
506                TextStyle::Heading.resolve(ui.style()),
507                Color32::from_rgb(170, 240, 170),
508            );
509        }
510
511        response
512    }
513}
514
515fn draw_cell(painter: &egui::Painter, rect: Rect, state: CellState, visuals: &Visuals) {
516    let ppi = painter.ctx().pixels_per_point();
517    let rect = rect.round_to_pixels(ppi);
518    let inner = rect.shrink(1.0);
519
520    match state {
521        CellState::Empty => {
522            painter.rect_filled(inner, CornerRadius::ZERO, visuals.extreme_bg_color);
523        }
524        CellState::Filled => {
525            let fill = if visuals.dark_mode {
526                Color32::from_rgb(225, 225, 230)
527            } else {
528                Color32::from_rgb(35, 35, 40)
529            };
530            painter.rect_filled(inner, CornerRadius::ZERO, fill);
531        }
532        CellState::Crossed => {
533            painter.rect_filled(inner, CornerRadius::ZERO, visuals.extreme_bg_color);
534            let stroke_color = visuals.widgets.noninteractive.fg_stroke.color;
535            let pad = inner.width() * 0.28;
536            painter.line_segment(
537                [inner.min + Vec2::splat(pad), inner.max - Vec2::splat(pad)],
538                Stroke::new(2.0, stroke_color),
539            );
540            painter.line_segment(
541                [
542                    Pos2::new(inner.min.x + pad, inner.max.y - pad),
543                    Pos2::new(inner.max.x - pad, inner.min.y + pad),
544                ],
545                Stroke::new(2.0, stroke_color),
546            );
547        }
548    }
549
550    painter.rect_stroke(
551        inner,
552        CornerRadius::ZERO,
553        Stroke::new(0.5, visuals.widgets.noninteractive.bg_stroke.color),
554        StrokeKind::Inside,
555    );
556}
557
558// Bold guide lines every 5th internal cell boundary — the standard
559// nonogram/sudoku convention for helping players count large grids.
560// Only internal boundaries are drawn (the board's outer edge keeps its
561// plain per-cell border from `draw_cell`); a board with 5 or fewer
562// rows/columns in a dimension naturally gets none, with no special-casing.
563fn draw_major_gridlines(
564    painter: &egui::Painter,
565    origin: Pos2,
566    board_size: Vec2,
567    width: usize,
568    height: usize,
569    cell_size: f32,
570    color: Color32,
571) {
572    let ppi = painter.ctx().pixels_per_point();
573    let stroke = Stroke::new(1.5, color);
574
575    let mut n = 5;
576    while n < width {
577        let x = origin.x + n as f32 * cell_size;
578        painter.line_segment(
579            [
580                Pos2::new(x, origin.y).round_to_pixels(ppi),
581                Pos2::new(x, origin.y + board_size.y).round_to_pixels(ppi),
582            ],
583            stroke,
584        );
585        n += 5;
586    }
587
588    let mut n = 5;
589    while n < height {
590        let y = origin.y + n as f32 * cell_size;
591        painter.line_segment(
592            [
593                Pos2::new(origin.x, y).round_to_pixels(ppi),
594                Pos2::new(origin.x + board_size.x, y).round_to_pixels(ppi),
595            ],
596            stroke,
597        );
598        n += 5;
599    }
600}