1use 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#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
15pub enum TapMode {
16 #[default]
18 Fill,
19 Cross,
21}
22
23#[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; const 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
51pub 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 pub fn cell_size(mut self, size: f32) -> Self {
88 self.cell_size = Some(size);
89 self
90 }
91
92 pub fn tap_mode(mut self, mode: TapMode) -> Self {
94 self.tap_mode = mode;
95 self
96 }
97
98 pub fn show_preview(mut self, enabled: bool) -> Self {
103 self.show_preview = enabled;
104 self
105 }
106
107 pub fn win_message(mut self, message: impl Into<String>) -> Self {
110 self.win_message = Some(message.into());
111 self
112 }
113
114 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
130fn 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
145pub 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 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 let visuals = ui.visuals();
250 let normal_color = visuals.text_color();
251 let dim_color = normal_color.gamma_multiply(0.35);
255
256 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 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 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 draw_major_gridlines(&painter, origin, board_size, width, height, cell_size, normal_color);
326
327 if self.show_preview {
330 let preview_cell = PREVIEW_MAX_SIZE / game.width.max(game.height) as f32;
331 let preview_origin =
332 Pos2::new(response.rect.max.x - PREVIEW_MAX_SIZE, response.rect.min.y);
333 let preview_size = Vec2::new(game.width as f32, game.height as f32) * preview_cell;
334 painter.rect_filled(
335 Rect::from_min_size(preview_origin, preview_size),
336 CornerRadius::ZERO,
337 visuals.extreme_bg_color,
338 );
339 for y in 0..game.height {
340 for x in 0..game.width {
341 if game.cells[game.idx(x, y)] == CellState::Filled {
342 let cell_rect = Rect::from_min_size(
343 preview_origin + Vec2::new(x as f32, y as f32) * preview_cell,
344 Vec2::splat(preview_cell),
345 );
346 painter.rect_filled(cell_rect, CornerRadius::ZERO, normal_color);
347 }
348 }
349 }
350 painter.rect_stroke(
351 Rect::from_min_size(preview_origin, preview_size),
352 CornerRadius::ZERO,
353 Stroke::new(1.0, visuals.widgets.noninteractive.bg_stroke.color),
354 StrokeKind::Outside,
355 );
356 }
357
358 if game.status == GameStatus::Won {
362 let board_rect = Rect::from_min_size(origin, board_size);
363 let banner_size = Vec2::new(
364 (board_size.x - 20.0).max(20.0),
365 64.0_f32.min(board_size.y - 4.0).max(20.0),
366 );
367 let banner = Rect::from_center_size(board_rect.center(), banner_size);
368 painter.rect_filled(banner, 8.0, Color32::from_black_alpha(170));
369 painter.text(
370 banner.center(),
371 Align2::CENTER_CENTER,
372 self.win_message.as_deref().unwrap_or("Solved!"),
373 TextStyle::Heading.resolve(ui.style()),
374 Color32::from_rgb(170, 240, 170),
375 );
376 }
377
378 response
379 }
380}
381
382fn draw_cell(painter: &egui::Painter, rect: Rect, state: CellState, visuals: &Visuals) {
383 let ppi = painter.ctx().pixels_per_point();
384 let rect = rect.round_to_pixels(ppi);
385 let inner = rect.shrink(1.0);
386
387 match state {
388 CellState::Empty => {
389 painter.rect_filled(inner, CornerRadius::ZERO, visuals.extreme_bg_color);
390 }
391 CellState::Filled => {
392 let fill = if visuals.dark_mode {
393 Color32::from_rgb(225, 225, 230)
394 } else {
395 Color32::from_rgb(35, 35, 40)
396 };
397 painter.rect_filled(inner, CornerRadius::ZERO, fill);
398 }
399 CellState::Crossed => {
400 painter.rect_filled(inner, CornerRadius::ZERO, visuals.extreme_bg_color);
401 let stroke_color = visuals.widgets.noninteractive.fg_stroke.color;
402 let pad = inner.width() * 0.28;
403 painter.line_segment(
404 [inner.min + Vec2::splat(pad), inner.max - Vec2::splat(pad)],
405 Stroke::new(2.0, stroke_color),
406 );
407 painter.line_segment(
408 [
409 Pos2::new(inner.min.x + pad, inner.max.y - pad),
410 Pos2::new(inner.max.x - pad, inner.min.y + pad),
411 ],
412 Stroke::new(2.0, stroke_color),
413 );
414 }
415 }
416
417 painter.rect_stroke(
418 inner,
419 CornerRadius::ZERO,
420 Stroke::new(0.5, visuals.widgets.noninteractive.bg_stroke.color),
421 StrokeKind::Inside,
422 );
423}
424
425fn draw_major_gridlines(
431 painter: &egui::Painter,
432 origin: Pos2,
433 board_size: Vec2,
434 width: usize,
435 height: usize,
436 cell_size: f32,
437 color: Color32,
438) {
439 let ppi = painter.ctx().pixels_per_point();
440 let stroke = Stroke::new(1.5, color);
441
442 let mut n = 5;
443 while n < width {
444 let x = origin.x + n as f32 * cell_size;
445 painter.line_segment(
446 [
447 Pos2::new(x, origin.y).round_to_pixels(ppi),
448 Pos2::new(x, origin.y + board_size.y).round_to_pixels(ppi),
449 ],
450 stroke,
451 );
452 n += 5;
453 }
454
455 let mut n = 5;
456 while n < height {
457 let y = origin.y + n as f32 * cell_size;
458 painter.line_segment(
459 [
460 Pos2::new(origin.x, y).round_to_pixels(ppi),
461 Pos2::new(origin.x + board_size.x, y).round_to_pixels(ppi),
462 ],
463 stroke,
464 );
465 n += 5;
466 }
467}