use egui::{
emath::GuiRounding, Align2, Color32, CornerRadius, FontId, PointerButton, Pos2, Rect, Response,
Sense, Stroke, StrokeKind, TextStyle, Ui, Vec2, Visuals, Widget,
};
use crate::game::{CellState, GameStatus, NonogramGame};
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum TapMode {
#[default]
Fill,
Cross,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum PaintTarget {
Fill,
Cross,
Clear,
}
impl PaintTarget {
fn apply(self, game: &mut NonogramGame, x: usize, y: usize) {
match self {
Self::Fill => game.fill(x, y),
Self::Cross => game.cross(x, y),
Self::Clear => game.clear_cell(x, y),
}
}
}
const CLUE_FONT_SIZE: f32 = 14.0;
const CLUE_CHAR_WIDTH: f32 = CLUE_FONT_SIZE * 0.6; const CLUE_LINE_HEIGHT: f32 = CLUE_FONT_SIZE * 1.3;
const GUTTER_PADDING: f32 = 8.0;
const PREVIEW_MAX_SIZE: f32 = 96.0;
const PREVIEW_GAP: f32 = 12.0;
pub struct NonogramWidget<'a> {
game: &'a mut NonogramGame,
cell_size: Option<f32>,
tap_mode: TapMode,
show_preview: bool,
win_message: Option<String>,
}
impl<'a> NonogramWidget<'a> {
pub fn new(game: &'a mut NonogramGame) -> Self {
Self {
game,
cell_size: None,
tap_mode: TapMode::Fill,
show_preview: false,
win_message: None,
}
}
pub fn cell_size(mut self, size: f32) -> Self {
self.cell_size = Some(size);
self
}
pub fn tap_mode(mut self, mode: TapMode) -> Self {
self.tap_mode = mode;
self
}
pub fn show_preview(mut self, enabled: bool) -> Self {
self.show_preview = enabled;
self
}
pub fn win_message(mut self, message: impl Into<String>) -> Self {
self.win_message = Some(message.into());
self
}
}
fn clue_line_text(clue: &[u8]) -> String {
clue.iter().map(u8::to_string).collect::<Vec<_>>().join(" ")
}
impl Widget for NonogramWidget<'_> {
fn ui(self, ui: &mut Ui) -> Response {
let game = self.game;
let font_id = FontId::monospace(CLUE_FONT_SIZE);
let row_texts: Vec<String> = game.row_clues.iter().map(|c| clue_line_text(c)).collect();
let max_row_chars = row_texts
.iter()
.map(|s| s.chars().count())
.max()
.unwrap_or(1);
let max_col_lines = game.col_clues.iter().map(Vec::len).max().unwrap_or(1);
let row_gutter_width = max_row_chars as f32 * CLUE_CHAR_WIDTH + GUTTER_PADDING;
let col_gutter_height = max_col_lines as f32 * CLUE_LINE_HEIGHT + GUTTER_PADDING * 0.5;
let preview_width = if self.show_preview {
PREVIEW_MAX_SIZE + PREVIEW_GAP
} else {
0.0
};
let cell_size = self.cell_size.unwrap_or_else(|| {
let available = ui.available_size();
let by_width = (available.x - row_gutter_width - preview_width) / game.width as f32;
let by_height = (available.y - col_gutter_height) / game.height as f32;
by_width.min(by_height).max(4.0)
});
let board_size = Vec2::new(game.width as f32, game.height as f32) * cell_size;
let gutter_size = Vec2::new(row_gutter_width, col_gutter_height);
let total_size = board_size + gutter_size + Vec2::new(preview_width, 0.0);
let (response, painter) = ui.allocate_painter(total_size, Sense::click_and_drag());
let origin = response.rect.min + gutter_size;
let width = game.width;
let height = game.height;
let cell_at = |pos: Pos2| -> Option<(usize, usize)> {
let local = pos - origin;
if local.x >= 0.0 && local.y >= 0.0 && local.x < board_size.x && local.y < board_size.y
{
let cx = (local.x / cell_size).floor() as usize;
let cy = (local.y / cell_size).floor() as usize;
if cx < width && cy < height {
return Some((cx, cy));
}
}
None
};
let paint_id = response.id.with("paint_target");
let gesture_start = response.clicked()
|| response.secondary_clicked()
|| response.drag_started_by(PointerButton::Primary)
|| response.drag_started_by(PointerButton::Secondary);
if gesture_start {
if let Some((cx, cy)) = response.interact_pointer_pos().and_then(cell_at) {
let secondary = response.secondary_clicked()
|| response.drag_started_by(PointerButton::Secondary);
let current = game.cells[game.idx(cx, cy)];
let target = if secondary {
if current == CellState::Crossed {
PaintTarget::Clear
} else {
PaintTarget::Cross
}
} else {
match self.tap_mode {
TapMode::Fill if current == CellState::Filled => PaintTarget::Clear,
TapMode::Fill => PaintTarget::Fill,
TapMode::Cross if current == CellState::Crossed => PaintTarget::Clear,
TapMode::Cross => PaintTarget::Cross,
}
};
ui.ctx().data_mut(|d| d.insert_temp(paint_id, target));
target.apply(game, cx, cy);
}
} else if response.dragged() {
if let Some((cx, cy)) = response.interact_pointer_pos().and_then(cell_at) {
let target = ui.ctx().data(|d| d.get_temp::<PaintTarget>(paint_id));
if let Some(target) = target {
target.apply(game, cx, cy);
}
}
}
let visuals = ui.visuals();
let normal_color = visuals.text_color();
let dim_color = normal_color.gamma_multiply(0.35);
for (y, row_text) in row_texts.iter().enumerate() {
let satisfied = game.row_satisfied(y);
let color = if satisfied { dim_color } else { normal_color };
let pos = Pos2::new(
response.rect.min.x + row_gutter_width - GUTTER_PADDING * 0.5,
origin.y + (y as f32 + 0.5) * cell_size,
);
let text_rect =
painter.text(pos, Align2::RIGHT_CENTER, row_text, font_id.clone(), color);
if satisfied {
let mid_y = text_rect.center().y;
painter.line_segment(
[
Pos2::new(text_rect.min.x, mid_y),
Pos2::new(text_rect.max.x, mid_y),
],
Stroke::new(1.5, color),
);
}
}
for x in 0..game.width {
let satisfied = game.col_satisfied(x);
let color = if satisfied { dim_color } else { normal_color };
let x_center = origin.x + (x as f32 + 0.5) * cell_size;
let mut stack_top = f32::MAX;
let mut stack_bottom = f32::MIN;
for (i, n) in game.col_clues[x].iter().rev().enumerate() {
let y_center = origin.y - (i as f32 + 0.5) * CLUE_LINE_HEIGHT;
let text_rect = painter.text(
Pos2::new(x_center, y_center),
Align2::CENTER_CENTER,
n.to_string(),
font_id.clone(),
color,
);
stack_top = stack_top.min(text_rect.min.y);
stack_bottom = stack_bottom.max(text_rect.max.y);
}
if satisfied && stack_top < stack_bottom {
painter.line_segment(
[
Pos2::new(x_center, stack_top),
Pos2::new(x_center, stack_bottom),
],
Stroke::new(1.5, color),
);
}
}
for y in 0..game.height {
for x in 0..game.width {
let cell_rect = Rect::from_min_size(
origin + Vec2::new(x as f32, y as f32) * cell_size,
Vec2::splat(cell_size),
);
draw_cell(&painter, cell_rect, game.cells[game.idx(x, y)], visuals);
}
}
if self.show_preview {
let preview_cell = PREVIEW_MAX_SIZE / game.width.max(game.height) as f32;
let preview_origin =
Pos2::new(response.rect.max.x - PREVIEW_MAX_SIZE, response.rect.min.y);
let preview_size = Vec2::new(game.width as f32, game.height as f32) * preview_cell;
painter.rect_filled(
Rect::from_min_size(preview_origin, preview_size),
CornerRadius::ZERO,
visuals.extreme_bg_color,
);
for y in 0..game.height {
for x in 0..game.width {
if game.cells[game.idx(x, y)] == CellState::Filled {
let cell_rect = Rect::from_min_size(
preview_origin + Vec2::new(x as f32, y as f32) * preview_cell,
Vec2::splat(preview_cell),
);
painter.rect_filled(cell_rect, CornerRadius::ZERO, normal_color);
}
}
}
painter.rect_stroke(
Rect::from_min_size(preview_origin, preview_size),
CornerRadius::ZERO,
Stroke::new(1.0, visuals.widgets.noninteractive.bg_stroke.color),
StrokeKind::Outside,
);
}
if game.status == GameStatus::Won {
let board_rect = Rect::from_min_size(origin, board_size);
let banner_size = Vec2::new(
(board_size.x - 20.0).max(20.0),
64.0_f32.min(board_size.y - 4.0).max(20.0),
);
let banner = Rect::from_center_size(board_rect.center(), banner_size);
painter.rect_filled(banner, 8.0, Color32::from_black_alpha(170));
painter.text(
banner.center(),
Align2::CENTER_CENTER,
self.win_message.as_deref().unwrap_or("Solved!"),
TextStyle::Heading.resolve(ui.style()),
Color32::from_rgb(170, 240, 170),
);
}
response
}
}
fn draw_cell(painter: &egui::Painter, rect: Rect, state: CellState, visuals: &Visuals) {
let ppi = painter.ctx().pixels_per_point();
let rect = rect.round_to_pixels(ppi);
let inner = rect.shrink(1.0);
match state {
CellState::Empty => {
painter.rect_filled(inner, CornerRadius::ZERO, visuals.extreme_bg_color);
}
CellState::Filled => {
let fill = if visuals.dark_mode {
Color32::from_rgb(225, 225, 230)
} else {
Color32::from_rgb(35, 35, 40)
};
painter.rect_filled(inner, CornerRadius::ZERO, fill);
}
CellState::Crossed => {
painter.rect_filled(inner, CornerRadius::ZERO, visuals.extreme_bg_color);
let stroke_color = visuals.widgets.noninteractive.fg_stroke.color;
let pad = inner.width() * 0.28;
painter.line_segment(
[inner.min + Vec2::splat(pad), inner.max - Vec2::splat(pad)],
Stroke::new(2.0, stroke_color),
);
painter.line_segment(
[
Pos2::new(inner.min.x + pad, inner.max.y - pad),
Pos2::new(inner.max.x - pad, inner.min.y + pad),
],
Stroke::new(2.0, stroke_color),
);
}
}
painter.rect_stroke(
inner,
CornerRadius::ZERO,
Stroke::new(0.5, visuals.widgets.noninteractive.bg_stroke.color),
StrokeKind::Inside,
);
}