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
44const CLUE_FONT_SIZE: f32 = 14.0;
45const CLUE_CHAR_WIDTH: f32 = CLUE_FONT_SIZE * 0.6; // monospace approximation
46const CLUE_LINE_HEIGHT: f32 = CLUE_FONT_SIZE * 1.3;
47const GUTTER_PADDING: f32 = 8.0;
48const PREVIEW_MAX_SIZE: f32 = 96.0;
49const PREVIEW_GAP: f32 = 12.0;
50
51/// An egui widget that renders a nonogram board with its row/column clues.
52///
53/// Left-click fills a cell; right-click crosses it out. Press and drag to
54/// paint (or erase) a whole stroke of cells in one gesture — the action is
55/// decided by the cell the drag started on and then repeated for every cell
56/// the pointer passes over. Once a row or column's filled cells match its
57/// clue, that clue is dimmed (and struck through) as a solving aid.
58///
59/// ```ignore
60/// ui.add(egui_nonogram::NonogramWidget::new(&mut game));
61/// ```
62pub struct NonogramWidget<'a> {
63    game: &'a mut NonogramGame,
64    cell_size: Option<f32>,
65    tap_mode: TapMode,
66    show_preview: bool,
67    win_message: Option<String>,
68    interactive: bool,
69}
70
71impl<'a> NonogramWidget<'a> {
72    pub fn new(game: &'a mut NonogramGame) -> Self {
73        Self {
74            game,
75            cell_size: None,
76            tap_mode: TapMode::Fill,
77            show_preview: false,
78            win_message: None,
79            interactive: true,
80        }
81    }
82
83    /// Override the size (in logical pixels) of each grid cell. When not
84    /// set, the cell size is computed automatically to fill the available
85    /// space of the parent container (after reserving room for the clue
86    /// gutters).
87    pub fn cell_size(mut self, size: f32) -> Self {
88        self.cell_size = Some(size);
89        self
90    }
91
92    /// What a plain tap/primary-click does. Defaults to [`TapMode::Fill`].
93    pub fn tap_mode(mut self, mode: TapMode) -> Self {
94        self.tap_mode = mode;
95        self
96    }
97
98    /// Show a small read-only thumbnail of the current filled cells beside
99    /// the board — a compact "how's it coming along" overview on larger
100    /// grids. It only ever reflects cells the player has already filled, so
101    /// it can't spoil a picture puzzle's solution. Defaults to `false`.
102    pub fn show_preview(mut self, enabled: bool) -> Self {
103        self.show_preview = enabled;
104        self
105    }
106
107    /// Message shown in the win banner drawn over the board once
108    /// [`GameStatus::Won`] is reached. Defaults to `"Solved!"` when not set.
109    pub fn win_message(mut self, message: impl Into<String>) -> Self {
110        self.win_message = Some(message.into());
111        self
112    }
113
114    /// Whether the widget responds to taps/drags at all. Set to `false`
115    /// to render the board read-only — e.g. while a surrounding
116    /// container (like `egui::containers::Scene`) should own pointer
117    /// gestures instead. Unlike wrapping the widget in a disabled `Ui`,
118    /// this does not dim it: painting stays full-opacity, only the
119    /// pointer sense changes. Defaults to `true`.
120    pub fn interactive(mut self, interactive: bool) -> Self {
121        self.interactive = interactive;
122        self
123    }
124}
125
126fn clue_line_text(clue: &[u8]) -> String {
127    clue.iter().map(u8::to_string).collect::<Vec<_>>().join(" ")
128}
129
130/// Row gutter width and column gutter height for a given game's clues.
131fn gutter_size(game: &NonogramGame) -> Vec2 {
132    let max_row_chars = game
133        .row_clues
134        .iter()
135        .map(|c| clue_line_text(c).chars().count())
136        .max()
137        .unwrap_or(1);
138    let max_col_lines = game.col_clues.iter().map(Vec::len).max().unwrap_or(1);
139
140    let row_gutter_width = max_row_chars as f32 * CLUE_CHAR_WIDTH + GUTTER_PADDING;
141    let col_gutter_height = max_col_lines as f32 * CLUE_LINE_HEIGHT + GUTTER_PADDING * 0.5;
142    Vec2::new(row_gutter_width, col_gutter_height)
143}
144
145/// The total footprint [`NonogramWidget`] will occupy for `game` at a given
146/// `cell_size`, including the clue gutters and (if enabled) the progress
147/// preview column. Lets a caller pre-size a container (e.g. `egui::Scene`)
148/// before laying the widget out.
149pub fn content_size(game: &NonogramGame, cell_size: f32, show_preview: bool) -> Vec2 {
150    let board_size = Vec2::new(game.width as f32, game.height as f32) * cell_size;
151    let preview_width = if show_preview {
152        PREVIEW_MAX_SIZE + PREVIEW_GAP
153    } else {
154        0.0
155    };
156    board_size + gutter_size(game) + Vec2::new(preview_width, 0.0)
157}
158
159impl Widget for NonogramWidget<'_> {
160    fn ui(self, ui: &mut Ui) -> Response {
161        let game = self.game;
162        let font_id = FontId::monospace(CLUE_FONT_SIZE);
163
164        let row_texts: Vec<String> = game.row_clues.iter().map(|c| clue_line_text(c)).collect();
165        let gutter_size = gutter_size(game);
166        let row_gutter_width = gutter_size.x;
167        let col_gutter_height = gutter_size.y;
168        let preview_width = if self.show_preview {
169            PREVIEW_MAX_SIZE + PREVIEW_GAP
170        } else {
171            0.0
172        };
173
174        let cell_size = self.cell_size.unwrap_or_else(|| {
175            let available = ui.available_size();
176            let by_width = (available.x - row_gutter_width - preview_width) / game.width as f32;
177            let by_height = (available.y - col_gutter_height) / game.height as f32;
178            by_width.min(by_height).max(4.0)
179        });
180
181        let board_size = Vec2::new(game.width as f32, game.height as f32) * cell_size;
182        let total_size = board_size + gutter_size + Vec2::new(preview_width, 0.0);
183
184        let sense = if self.interactive {
185            Sense::click_and_drag()
186        } else {
187            Sense::hover()
188        };
189        let (response, painter) = ui.allocate_painter(total_size, sense);
190        let origin = response.rect.min + gutter_size;
191        let width = game.width;
192        let height = game.height;
193        let cell_at = |pos: Pos2| -> Option<(usize, usize)> {
194            let local = pos - origin;
195            if local.x >= 0.0 && local.y >= 0.0 && local.x < board_size.x && local.y < board_size.y
196            {
197                let cx = (local.x / cell_size).floor() as usize;
198                let cy = (local.y / cell_size).floor() as usize;
199                if cx < width && cy < height {
200                    return Some((cx, cy));
201                }
202            }
203            None
204        };
205
206        // ── Input handling ──────────────────────────────────────────────
207        // A gesture (plain click or the start of a drag) decides its paint
208        // target from the cell it started on, stores that decision in
209        // egui's per-widget temp memory, and re-applies it (idempotently)
210        // to every cell touched for the rest of the gesture — so dragging
211        // back over an already-painted cell doesn't undo it.
212        let paint_id = response.id.with("paint_target");
213        let gesture_start = response.clicked()
214            || response.secondary_clicked()
215            || response.drag_started_by(PointerButton::Primary)
216            || response.drag_started_by(PointerButton::Secondary);
217        if gesture_start {
218            if let Some((cx, cy)) = response.interact_pointer_pos().and_then(cell_at) {
219                let secondary = response.secondary_clicked()
220                    || response.drag_started_by(PointerButton::Secondary);
221                let current = game.cells[game.idx(cx, cy)];
222                let target = if secondary {
223                    if current == CellState::Crossed {
224                        PaintTarget::Clear
225                    } else {
226                        PaintTarget::Cross
227                    }
228                } else {
229                    match self.tap_mode {
230                        TapMode::Fill if current == CellState::Filled => PaintTarget::Clear,
231                        TapMode::Fill => PaintTarget::Fill,
232                        TapMode::Cross if current == CellState::Crossed => PaintTarget::Clear,
233                        TapMode::Cross => PaintTarget::Cross,
234                    }
235                };
236                ui.ctx().data_mut(|d| d.insert_temp(paint_id, target));
237                target.apply(game, cx, cy);
238            }
239        } else if response.dragged() {
240            if let Some((cx, cy)) = response.interact_pointer_pos().and_then(cell_at) {
241                let target = ui.ctx().data(|d| d.get_temp::<PaintTarget>(paint_id));
242                if let Some(target) = target {
243                    target.apply(game, cx, cy);
244                }
245            }
246        }
247
248        // ── Painting ─────────────────────────────────────────────────────
249        let visuals = ui.visuals();
250        let normal_color = visuals.text_color();
251        // `visuals.weak_text_color()` is too close to `text_color()` to read
252        // as "satisfied" at a glance against this widget's dark cell
253        // background, so dim explicitly and strongly instead.
254        let dim_color = normal_color.gamma_multiply(0.35);
255
256        // Row clues, right-aligned in the left gutter.
257        for (y, row_text) in row_texts.iter().enumerate() {
258            let satisfied = game.row_satisfied(y);
259            let color = if satisfied { dim_color } else { normal_color };
260            let pos = Pos2::new(
261                response.rect.min.x + row_gutter_width - GUTTER_PADDING * 0.5,
262                origin.y + (y as f32 + 0.5) * cell_size,
263            );
264            let text_rect =
265                painter.text(pos, Align2::RIGHT_CENTER, row_text, font_id.clone(), color);
266            if satisfied {
267                let mid_y = text_rect.center().y;
268                painter.line_segment(
269                    [
270                        Pos2::new(text_rect.min.x, mid_y),
271                        Pos2::new(text_rect.max.x, mid_y),
272                    ],
273                    Stroke::new(1.5, color),
274                );
275            }
276        }
277
278        // Column clues, stacked bottom-up in the top gutter. Satisfied
279        // columns get a vertical strikethrough spanning the whole stack —
280        // the rotated equivalent of a row's horizontal strikethrough, since
281        // a single horizontal line can't sensibly cross several stacked
282        // numbers.
283        for x in 0..game.width {
284            let satisfied = game.col_satisfied(x);
285            let color = if satisfied { dim_color } else { normal_color };
286            let x_center = origin.x + (x as f32 + 0.5) * cell_size;
287            let mut stack_top = f32::MAX;
288            let mut stack_bottom = f32::MIN;
289            for (i, n) in game.col_clues[x].iter().rev().enumerate() {
290                let y_center = origin.y - (i as f32 + 0.5) * CLUE_LINE_HEIGHT;
291                let text_rect = painter.text(
292                    Pos2::new(x_center, y_center),
293                    Align2::CENTER_CENTER,
294                    n.to_string(),
295                    font_id.clone(),
296                    color,
297                );
298                stack_top = stack_top.min(text_rect.min.y);
299                stack_bottom = stack_bottom.max(text_rect.max.y);
300            }
301            if satisfied && stack_top < stack_bottom {
302                painter.line_segment(
303                    [
304                        Pos2::new(x_center, stack_top),
305                        Pos2::new(x_center, stack_bottom),
306                    ],
307                    Stroke::new(1.5, color),
308                );
309            }
310        }
311
312        // Grid cells.
313        for y in 0..game.height {
314            for x in 0..game.width {
315                let cell_rect = Rect::from_min_size(
316                    origin + Vec2::new(x as f32, y as f32) * cell_size,
317                    Vec2::splat(cell_size),
318                );
319                draw_cell(&painter, cell_rect, game.cells[game.idx(x, y)], visuals);
320            }
321        }
322
323        // Progress preview: a compact, read-only thumbnail of the filled
324        // cells only, to the right of the board.
325        if self.show_preview {
326            let preview_cell = PREVIEW_MAX_SIZE / game.width.max(game.height) as f32;
327            let preview_origin =
328                Pos2::new(response.rect.max.x - PREVIEW_MAX_SIZE, response.rect.min.y);
329            let preview_size = Vec2::new(game.width as f32, game.height as f32) * preview_cell;
330            painter.rect_filled(
331                Rect::from_min_size(preview_origin, preview_size),
332                CornerRadius::ZERO,
333                visuals.extreme_bg_color,
334            );
335            for y in 0..game.height {
336                for x in 0..game.width {
337                    if game.cells[game.idx(x, y)] == CellState::Filled {
338                        let cell_rect = Rect::from_min_size(
339                            preview_origin + Vec2::new(x as f32, y as f32) * preview_cell,
340                            Vec2::splat(preview_cell),
341                        );
342                        painter.rect_filled(cell_rect, CornerRadius::ZERO, normal_color);
343                    }
344                }
345            }
346            painter.rect_stroke(
347                Rect::from_min_size(preview_origin, preview_size),
348                CornerRadius::ZERO,
349                Stroke::new(1.0, visuals.widgets.noninteractive.bg_stroke.color),
350                StrokeKind::Outside,
351            );
352        }
353
354        // Win banner: drawn last so it sits on top, sized to the board
355        // itself (excludes the clue gutters and the preview column) so it
356        // never covers the picture preview.
357        if game.status == GameStatus::Won {
358            let board_rect = Rect::from_min_size(origin, board_size);
359            let banner_size = Vec2::new(
360                (board_size.x - 20.0).max(20.0),
361                64.0_f32.min(board_size.y - 4.0).max(20.0),
362            );
363            let banner = Rect::from_center_size(board_rect.center(), banner_size);
364            painter.rect_filled(banner, 8.0, Color32::from_black_alpha(170));
365            painter.text(
366                banner.center(),
367                Align2::CENTER_CENTER,
368                self.win_message.as_deref().unwrap_or("Solved!"),
369                TextStyle::Heading.resolve(ui.style()),
370                Color32::from_rgb(170, 240, 170),
371            );
372        }
373
374        response
375    }
376}
377
378fn draw_cell(painter: &egui::Painter, rect: Rect, state: CellState, visuals: &Visuals) {
379    let ppi = painter.ctx().pixels_per_point();
380    let rect = rect.round_to_pixels(ppi);
381    let inner = rect.shrink(1.0);
382
383    match state {
384        CellState::Empty => {
385            painter.rect_filled(inner, CornerRadius::ZERO, visuals.extreme_bg_color);
386        }
387        CellState::Filled => {
388            let fill = if visuals.dark_mode {
389                Color32::from_rgb(225, 225, 230)
390            } else {
391                Color32::from_rgb(35, 35, 40)
392            };
393            painter.rect_filled(inner, CornerRadius::ZERO, fill);
394        }
395        CellState::Crossed => {
396            painter.rect_filled(inner, CornerRadius::ZERO, visuals.extreme_bg_color);
397            let stroke_color = visuals.widgets.noninteractive.fg_stroke.color;
398            let pad = inner.width() * 0.28;
399            painter.line_segment(
400                [inner.min + Vec2::splat(pad), inner.max - Vec2::splat(pad)],
401                Stroke::new(2.0, stroke_color),
402            );
403            painter.line_segment(
404                [
405                    Pos2::new(inner.min.x + pad, inner.max.y - pad),
406                    Pos2::new(inner.max.x - pad, inner.min.y + pad),
407                ],
408                Stroke::new(2.0, stroke_color),
409            );
410        }
411    }
412
413    painter.rect_stroke(
414        inner,
415        CornerRadius::ZERO,
416        Stroke::new(0.5, visuals.widgets.noninteractive.bg_stroke.color),
417        StrokeKind::Inside,
418    );
419}