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
162pub 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
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
191pub 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 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 let visuals = ui.visuals();
375 let normal_color = visuals.text_color();
376 let dim_color = normal_color.gamma_multiply(0.35);
380
381 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 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 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 draw_major_gridlines(
451 &painter,
452 origin,
453 board_size,
454 width,
455 height,
456 cell_size,
457 normal_color,
458 );
459
460 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 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
558fn 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}