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 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 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}