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
44#[derive(Clone, Copy, Debug, PartialEq)]
47enum Axis {
48 Row,
49 Col,
50}
51
52#[derive(Clone, Debug)]
58struct DragState {
59 target: PaintTarget,
60 origin: (usize, usize),
61 origin_original: CellState,
65 axis: Option<Axis>,
67 original: Vec<Option<CellState>>,
71 span: (usize, usize),
74}
75
76const CLUE_FONT_SIZE: f32 = 14.0;
77const CLUE_CHAR_WIDTH: f32 = CLUE_FONT_SIZE * 0.6; const 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
83pub 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 pub fn cell_size(mut self, size: f32) -> Self {
120 self.cell_size = Some(size);
121 self
122 }
123
124 pub fn tap_mode(mut self, mode: TapMode) -> Self {
126 self.tap_mode = mode;
127 self
128 }
129
130 pub fn show_preview(mut self, enabled: bool) -> Self {
135 self.show_preview = enabled;
136 self
137 }
138
139 pub fn win_message(mut self, message: impl Into<String>) -> Self {
142 self.win_message = Some(message.into());
143 self
144 }
145
146 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
162fn 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
177pub 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 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 let visuals = ui.visuals();
362 let normal_color = visuals.text_color();
363 let dim_color = normal_color.gamma_multiply(0.35);
367
368 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 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 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 draw_major_gridlines(&painter, origin, board_size, width, height, cell_size, normal_color);
438
439 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 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
537fn 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}