#!/usr/bin/env python3 """ Shikaku - Native NixOS puzzle game Puzzle generator uses a guaranteed-solvable approach: 1. Partition the grid into rectangles 2. Place one clue number per rectangle 3. Player draws rectangles to match clues """ import gi gi.require_version("Gtk", "4.0") from gi.repository import Gtk, Gdk, GLib, Pango import random import sys import time from dataclasses import dataclass, field from typing import Optional # ─── Data structures ────────────────────────────────────────────────────────── @dataclass class Rect: row: int col: int height: int width: int @property def area(self): return self.height * self.width def cells(self): for r in range(self.row, self.row + self.height): for c in range(self.col, self.col + self.width): yield (r, c) @dataclass class PlayerRect: start_row: int start_col: int end_row: int end_col: int def normalized(self): return PlayerRect( min(self.start_row, self.end_row), min(self.start_col, self.end_col), max(self.start_row, self.end_row), max(self.start_col, self.end_col), ) def cells(self): n = self.normalized() for r in range(n.start_row, n.end_row + 1): for c in range(n.start_col, n.end_col + 1): yield (r, c) def area(self): n = self.normalized() return (n.end_row - n.start_row + 1) * (n.end_col - n.start_col + 1) def contains(self, row, col): n = self.normalized() return n.start_row <= row <= n.end_row and n.start_col <= col <= n.end_col # ─── Puzzle generator ───────────────────────────────────────────────────────── def generate_puzzle(size: int, seed: int = None) -> tuple[dict, list[Rect]]: """ Returns (clues, solution_rects). clues: {(row, col): number} — the numbers shown on the grid solution_rects: list of Rect — the correct partition Algorithm: - Repeatedly pick an uncovered cell and try to place a random rectangle that covers it and only uncovered cells. - Retry with a fresh random state if we get stuck. """ rng = random.Random(seed) for _attempt in range(200): grid = [[-1] * size for _ in range(size)] # -1 = uncovered rects = [] failed = False uncovered = [(r, c) for r in range(size) for c in range(size)] rng.shuffle(uncovered) while uncovered: # pick first uncovered cell start_r, start_c = uncovered[0] if grid[start_r][start_c] != -1: uncovered.pop(0) continue # gather candidate rectangles that: # - include (start_r, start_c) # - fit inside the grid # - cover only uncovered cells # - have area >= 2 (single-cell rectangles are not allowed) candidates = [] for h in range(1, size - start_r + 1): for w in range(1, size - start_c + 1): if h == 1 and w == 1: continue # minimum area is 2 # also try rectangles that start before the anchor for ro in range(0, h): for co in range(0, w): r0 = start_r - ro c0 = start_c - co r1 = r0 + h - 1 c1 = c0 + w - 1 if r0 < 0 or c0 < 0 or r1 >= size or c1 >= size: continue if all(grid[r][c] == -1 for r in range(r0, r1 + 1) for c in range(c0, c1 + 1)): candidates.append(Rect(r0, c0, h, w)) if not candidates: # This cell is isolated — no area-2+ rectangle fits. # Absorb it into an orthogonally adjacent already-placed rectangle # by expanding that rect to include this cell, if possible. absorbed = False neighbours = [ (start_r - 1, start_c), (start_r + 1, start_c), (start_r, start_c - 1), (start_r, start_c + 1), ] rng.shuffle(neighbours) for nr, nc in neighbours: if 0 <= nr < size and 0 <= nc < size and grid[nr][nc] != -1: rid = grid[nr][nc] old = rects[rid] # Compute the bounding box of the existing rect + this cell new_r0 = min(old.row, start_r) new_c0 = min(old.col, start_c) new_r1 = max(old.row + old.height - 1, start_r) new_c1 = max(old.col + old.width - 1, start_c) new_h = new_r1 - new_r0 + 1 new_w = new_c1 - new_c0 + 1 # Only absorb if the expanded bounding box covers only # cells already owned by this rect or the isolated cell expanded_cells = [ (r, c) for r in range(new_r0, new_r1 + 1) for c in range(new_c0, new_c1 + 1) ] if all(grid[r][c] in (-1, rid) for r, c in expanded_cells): # Re-assign all expanded cells to this rect for r, c in expanded_cells: grid[r][c] = rid rects[rid] = Rect(new_r0, new_c0, new_h, new_w) uncovered = [(r, c) for r, c in uncovered if grid[r][c] == -1] absorbed = True break if not absorbed: failed = True break continue # cell was absorbed into a neighbour, skip candidate placement # Prefer smaller rectangles (keeps the puzzle interesting) candidates.sort(key=lambda rect: (rect.area, rng.random())) # pick from smaller end with some randomness idx = min(int(rng.betavariate(1.2, 3.0) * len(candidates)), len(candidates) - 1) chosen = candidates[idx] rect_id = len(rects) for r, c in chosen.cells(): grid[r][c] = rect_id rects.append(chosen) uncovered = [(r, c) for r, c in uncovered if grid[r][c] == -1] if not failed and all(grid[r][c] != -1 for r in range(size) for c in range(size)): # Place clue numbers — one per rectangle, at a random interior cell clues = {} for rect in rects: cells = list(rect.cells()) cr, cc = rng.choice(cells) clues[(cr, cc)] = rect.area return clues, rects raise RuntimeError("Puzzle generation failed after many attempts") def verify_solution(size: int, player_rects: list[PlayerRect], clues: dict) -> tuple[bool, str]: """Check whether the player's rectangles constitute a valid solution.""" # Each cell must be covered exactly once coverage = {} for i, pr in enumerate(player_rects): for cell in pr.cells(): if cell in coverage: return False, f"Cells overlap between two of your rectangles" coverage[cell] = i total = size * size if len(coverage) != total: return False, f"Not all cells are covered ({len(coverage)}/{total})" # Each rectangle must contain exactly one clue and match its number for i, pr in enumerate(player_rects): cells = set(pr.cells()) clues_inside = {cell: clues[cell] for cell in cells if cell in clues} if len(clues_inside) == 0: return False, "One of your rectangles contains no clue number" if len(clues_inside) > 1: return False, "One of your rectangles contains more than one clue number" clue_val = next(iter(clues_inside.values())) if pr.area() != clue_val: return False, f"A rectangle contains {clue_val} but has {pr.area()} cells" return True, "Solved!" # ─── GTK Application ────────────────────────────────────────────────────────── PADDING = 32 # Cell size and font scale down for larger grids so they fit comfortably on screen CELL_SIZES = { 5: 56, 7: 52, 10: 46, 15: 36, 20: 28, 25: 24, } FONT_SIZES = { 5: 14, 7: 13, 10: 12, 15: 10, 20: 8, 25: 7, } def cell_size(grid_size: int) -> int: return CELL_SIZES.get(grid_size, 40) def font_size(grid_size: int) -> int: return FONT_SIZES.get(grid_size, 10) # Palette — deep ink & paper feel BG_COLOR = (0.11, 0.10, 0.13, 1.0) # near-black background PAPER_COLOR = (0.95, 0.93, 0.88, 1.0) # warm off-white grid GRID_LINE = (0.55, 0.52, 0.48, 1.0) # warm grey lines GRID_LINE_BOLD = (0.20, 0.18, 0.22, 1.0) # border CLUE_COLOR = (0.10, 0.10, 0.12, 1.0) # near black text RECT_COLORS = [ (0.36, 0.61, 0.84, 0.45), # blue (0.42, 0.78, 0.60, 0.45), # green (0.90, 0.55, 0.35, 0.45), # orange (0.76, 0.45, 0.82, 0.45), # purple (0.88, 0.76, 0.30, 0.45), # yellow (0.45, 0.82, 0.85, 0.45), # cyan (0.85, 0.42, 0.52, 0.45), # rose (0.55, 0.72, 0.35, 0.45), # lime ] RECT_BORDER = [ (0.20, 0.42, 0.70, 1.0), (0.22, 0.60, 0.40, 1.0), (0.75, 0.35, 0.12, 1.0), (0.56, 0.25, 0.65, 1.0), (0.70, 0.58, 0.08, 1.0), (0.22, 0.62, 0.68, 1.0), (0.68, 0.20, 0.32, 1.0), (0.35, 0.54, 0.18, 1.0), ] DRAG_COLOR = (0.50, 0.70, 0.95, 0.30) DRAG_BORDER = (0.30, 0.55, 0.90, 0.90) ERROR_COLOR = (0.90, 0.25, 0.25, 0.30) ERROR_BORDER = (0.80, 0.10, 0.10, 0.90) class ShikakuCanvas(Gtk.DrawingArea): def __init__(self, game): super().__init__() self.game = game self.set_draw_func(self._draw) self.set_can_focus(True) # Drag state self._drag_start = None self._drag_end = None self._drag_preview: Optional[PlayerRect] = None self._error_rects: set[int] = set() # Callback fired after every board change — set by the window self.on_board_changed = None # Gestures drag = Gtk.GestureDrag() drag.connect("drag-begin", self._on_drag_begin) drag.connect("drag-update", self._on_drag_update) drag.connect("drag-end", self._on_drag_end) self.add_controller(drag) click = Gtk.GestureClick() click.set_button(3) # right click click.connect("pressed", self._on_right_click) self.add_controller(click) def _cell_at(self, x, y): cs = cell_size(self.game.size) col = int((x - PADDING) // cs) row = int((y - PADDING) // cs) size = self.game.size if 0 <= row < size and 0 <= col < size: return row, col return None def _on_drag_begin(self, gesture, x, y): cell = self._cell_at(x, y) if cell: self._drag_start = cell self._drag_end = cell self._drag_preview = PlayerRect(*cell, *cell) self.queue_draw() def _on_drag_update(self, gesture, dx, dy): if self._drag_start is None: return sx, sy = gesture.get_start_point()[1], gesture.get_start_point()[2] cell = self._cell_at(sx + dx, sy + dy) if cell: self._drag_end = cell sr, sc = self._drag_start er, ec = cell self._drag_preview = PlayerRect(sr, sc, er, ec) self.queue_draw() def _on_drag_end(self, gesture, dx, dy): if self._drag_start and self._drag_end: sr, sc = self._drag_start er, ec = self._drag_end pr = PlayerRect(sr, sc, er, ec) self.game.place_rect(pr) self._drag_start = None self._drag_end = None self._drag_preview = None self.queue_draw() if self.on_board_changed: self.on_board_changed() def _on_right_click(self, gesture, n, x, y): cell = self._cell_at(x, y) if cell: self.game.remove_rect_at(*cell) self.queue_draw() if self.on_board_changed: self.on_board_changed() def mark_errors(self, error_indices: set[int]): self._error_rects = error_indices self.queue_draw() def clear_errors(self): self._error_rects = set() self.queue_draw() def _draw(self, area, cr, width, height): size = self.game.size clues = self.game.clues player_rects = self.game.player_rects cs = cell_size(size) grid_w = size * cs # Background cr.set_source_rgba(*BG_COLOR) cr.paint() # Grid paper cr.set_source_rgba(*PAPER_COLOR) cr.rectangle(PADDING, PADDING, grid_w, grid_w) cr.fill() # Draw placed rectangles for i, pr in enumerate(player_rects): color_idx = i % len(RECT_COLORS) n = pr.normalized() rx = PADDING + n.start_col * cs ry = PADDING + n.start_row * cs rw = (n.end_col - n.start_col + 1) * cs rh = (n.end_row - n.start_row + 1) * cs if i in self._error_rects: cr.set_source_rgba(*ERROR_COLOR) else: cr.set_source_rgba(*RECT_COLORS[color_idx]) _rounded_rect(cr, rx + 2, ry + 2, rw - 4, rh - 4, 6) cr.fill() if i in self._error_rects: cr.set_source_rgba(*ERROR_BORDER) else: cr.set_source_rgba(*RECT_BORDER[color_idx]) cr.set_line_width(2.5) _rounded_rect(cr, rx + 2, ry + 2, rw - 4, rh - 4, 6) cr.stroke() # Drag preview if self._drag_preview: n = self._drag_preview.normalized() rx = PADDING + n.start_col * cs ry = PADDING + n.start_row * cs rw = (n.end_col - n.start_col + 1) * cs rh = (n.end_row - n.start_row + 1) * cs cr.set_source_rgba(*DRAG_COLOR) _rounded_rect(cr, rx + 2, ry + 2, rw - 4, rh - 4, 6) cr.fill() cr.set_source_rgba(*DRAG_BORDER) cr.set_line_width(2.0) _rounded_rect(cr, rx + 2, ry + 2, rw - 4, rh - 4, 6) cr.stroke() # Grid lines cr.set_line_width(0.8) cr.set_source_rgba(*GRID_LINE) for i in range(size + 1): x = PADDING + i * cs cr.move_to(x, PADDING) cr.line_to(x, PADDING + grid_w) cr.stroke() cr.move_to(PADDING, x) cr.line_to(PADDING + grid_w, x) cr.stroke() # Border cr.set_line_width(3.0) cr.set_source_rgba(*GRID_LINE_BOLD) cr.rectangle(PADDING, PADDING, grid_w, grid_w) cr.stroke() # Clue numbers layout = self.create_pango_layout("") fs = font_size(size) layout.set_font_description(Pango.FontDescription(f"Iosevka, Monospace Bold {fs}")) for (r, c), num in clues.items(): layout.set_text(str(num), -1) _, ext = layout.get_pixel_extents() tx = PADDING + c * cs + cs // 2 - ext.width // 2 ty = PADDING + r * cs + cs // 2 - ext.height // 2 cr.set_source_rgba(*CLUE_COLOR) cr.move_to(tx, ty) from gi.repository import PangoCairo PangoCairo.show_layout(cr, layout) def _rounded_rect(cr, x, y, w, h, r): cr.new_sub_path() cr.arc(x + r, y + r, r, 3.14159, 1.5 * 3.14159) cr.arc(x + w - r, y + r, r, 1.5 * 3.14159, 0) cr.arc(x + w - r, y + h - r, r, 0, 0.5 * 3.14159) cr.arc(x + r, y + h - r, r, 0.5 * 3.14159, 3.14159) cr.close_path() # ─── Game state ─────────────────────────────────────────────────────────────── class ShikakuGame: def __init__(self, size: int): self.size = size self.clues: dict = {} self.solution: list[Rect] = [] self.player_rects: list[PlayerRect] = [] self.start_time: float = 0 self.elapsed: float = 0 self.solved = False self.new_puzzle() def new_puzzle(self, seed=None): self.clues, self.solution = generate_puzzle(self.size, seed or random.randint(0, 2**31)) self.player_rects = [] self.start_time = time.time() self.elapsed = 0 self.solved = False def place_rect(self, pr: PlayerRect): """Add a rectangle, removing any existing ones that overlap.""" new_cells = set(pr.cells()) self.player_rects = [r for r in self.player_rects if not set(r.cells()) & new_cells] self.player_rects.append(pr) def remove_rect_at(self, row, col): self.player_rects = [r for r in self.player_rects if (row, col) not in set(r.cells())] def check_solution(self) -> tuple[bool, str, set[int]]: ok, msg = verify_solution(self.size, self.player_rects, self.clues) error_indices = set() if not ok: # Mark rectangles that are invalid for i, pr in enumerate(self.player_rects): cells = set(pr.cells()) clues_inside = {cell: self.clues[cell] for cell in cells if cell in self.clues} if len(clues_inside) != 1 or pr.area() != next(iter(clues_inside.values()), -1): error_indices.add(i) else: self.solved = True self.elapsed = time.time() - self.start_time return ok, msg, error_indices # ─── Main window ────────────────────────────────────────────────────────────── class ShikakuWindow(Gtk.ApplicationWindow): def __init__(self, app): super().__init__(application=app, title="Shikaku") self.set_resizable(True) self._current_size = 5 self.game = ShikakuGame(self._current_size) self._build_ui() self._update_canvas_size() self._start_timer() def _build_ui(self): outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) self.set_child(outer) # ── Top bar ── topbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) topbar.set_margin_top(14) topbar.set_margin_bottom(10) topbar.set_margin_start(20) topbar.set_margin_end(20) outer.append(topbar) title = Gtk.Label(label="SHIKAKU") title.add_css_class("title-label") topbar.append(title) spacer = Gtk.Box() spacer.set_hexpand(True) topbar.append(spacer) self._timer_label = Gtk.Label(label="0:00") self._timer_label.add_css_class("timer-label") topbar.append(self._timer_label) # ── Size buttons ── size_bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) size_bar.set_halign(Gtk.Align.CENTER) size_bar.set_margin_bottom(10) outer.append(size_bar) sizes_label = Gtk.Label(label="Grid:") sizes_label.add_css_class("sizes-label") size_bar.append(sizes_label) self._size_buttons = {} for sz in [5, 7, 10, 15, 20, 25]: btn = Gtk.Button(label=f"{sz}×{sz}") btn.add_css_class("size-btn") btn.connect("clicked", self._on_size_clicked, sz) size_bar.append(btn) self._size_buttons[sz] = btn self._update_size_buttons() # ── Canvas inside a scrolled window for large grids ── scroll = Gtk.ScrolledWindow() scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) scroll.set_hexpand(True) scroll.set_vexpand(True) self.canvas = ShikakuCanvas(self.game) self.canvas.set_halign(Gtk.Align.CENTER) self.canvas.set_valign(Gtk.Align.CENTER) self.canvas.on_board_changed = self._trigger_auto_check scroll.set_child(self.canvas) outer.append(scroll) # ── Bottom bar ── botbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) botbar.set_halign(Gtk.Align.CENTER) botbar.set_margin_top(14) botbar.set_margin_bottom(18) outer.append(botbar) new_btn = Gtk.Button(label="New Puzzle") new_btn.add_css_class("action-btn") new_btn.connect("clicked", self._on_new) botbar.append(new_btn) check_btn = Gtk.Button(label="Check") check_btn.add_css_class("action-btn") check_btn.connect("clicked", self._on_check) botbar.append(check_btn) clear_btn = Gtk.Button(label="Clear") clear_btn.add_css_class("action-btn-ghost") clear_btn.connect("clicked", self._on_clear) botbar.append(clear_btn) self._status_label = Gtk.Label(label="Draw rectangles around each number — right-click to erase") self._status_label.add_css_class("status-label") self._status_label.set_margin_top(4) self._status_label.set_margin_bottom(8) outer.append(self._status_label) # ── CSS ── css = Gtk.CssProvider() css.load_from_string(self._css()) Gtk.StyleContext.add_provider_for_display( Gdk.Display.get_default(), css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION ) def _css(self): return """ window { background-color: #1c1a21; } .title-label { font-family: "Iosevka", "Monospace"; font-size: 22px; font-weight: 900; letter-spacing: 6px; color: #e8e4dc; } .timer-label { font-family: "Iosevka", "Monospace"; font-size: 18px; color: #8a8070; font-weight: 600; letter-spacing: 2px; } .sizes-label { font-family: "Iosevka", "Monospace"; font-size: 12px; color: #6a6060; letter-spacing: 2px; } .size-btn { font-family: "Iosevka", "Monospace"; font-size: 12px; padding: 4px 12px; border-radius: 4px; background: #2a2730; color: #9a9090; border: 1px solid #3a3540; } .size-btn:hover { background: #3a3540; color: #d0c8c0; } .size-btn-active { background: #4a4060; color: #e8e0d8; border: 1px solid #7a6090; } .action-btn { font-family: "Iosevka", "Monospace"; font-size: 13px; font-weight: 700; letter-spacing: 1px; padding: 8px 22px; border-radius: 5px; background: #5a4880; color: #e8e0ff; border: none; } .action-btn:hover { background: #7060a0; } .action-btn-ghost { font-family: "Iosevka", "Monospace"; font-size: 13px; letter-spacing: 1px; padding: 8px 22px; border-radius: 5px; background: transparent; color: #6a6070; border: 1px solid #3a3540; } .action-btn-ghost:hover { color: #a09090; border-color: #5a5060; } .status-label { font-family: "Iosevka", "Monospace"; font-size: 11px; color: #5a5560; letter-spacing: 1px; } .status-ok { color: #60c080; } .status-err { color: #c06060; } """ def _update_size_buttons(self): for sz, btn in self._size_buttons.items(): btn.remove_css_class("size-btn-active") if sz == self._current_size: btn.add_css_class("size-btn-active") def _update_canvas_size(self): sz = self.game.size cs = cell_size(sz) total = sz * cs + 2 * PADDING self.canvas.set_content_width(total) self.canvas.set_content_height(total) def _on_size_clicked(self, btn, size): self._current_size = size self.game = ShikakuGame(size) self.canvas.game = self.game self.canvas.on_board_changed = self._trigger_auto_check self.canvas._error_rects = set() self._update_canvas_size() self._update_size_buttons() self._status_label.remove_css_class("status-ok") self._status_label.remove_css_class("status-err") self._status_label.set_text("Draw rectangles around each number — right-click to erase") self.canvas.queue_draw() def _on_new(self, btn): self.game.new_puzzle() self.canvas._error_rects = set() self._status_label.remove_css_class("status-ok") self._status_label.remove_css_class("status-err") self._status_label.set_text("Draw rectangles around each number — right-click to erase") self.canvas.queue_draw() def _trigger_auto_check(self): """Silently check after every move; announce only on a correct solution.""" if self.game.solved: return ok, msg, errors = self.game.check_solution() if ok: self.canvas.clear_errors() mins = int(self.game.elapsed) // 60 secs = int(self.game.elapsed) % 60 self._status_label.remove_css_class("status-err") self._status_label.add_css_class("status-ok") self._status_label.set_text(f"✓ Solved in {mins}:{secs:02d}!") else: # Don't highlight errors on auto-check — only the manual Check button does that self.canvas.clear_errors() def _on_check(self, btn): ok, msg, errors = self.game.check_solution() self.canvas.mark_errors(errors) self._status_label.remove_css_class("status-ok") self._status_label.remove_css_class("status-err") if ok: mins = int(self.game.elapsed) // 60 secs = int(self.game.elapsed) % 60 self._status_label.set_text(f"✓ Solved in {mins}:{secs:02d}!") self._status_label.add_css_class("status-ok") else: self._status_label.set_text(f"✗ {msg}") self._status_label.add_css_class("status-err") def _on_clear(self, btn): self.game.player_rects = [] self.canvas.clear_errors() self._status_label.remove_css_class("status-ok") self._status_label.remove_css_class("status-err") self._status_label.set_text("Cleared") self.canvas.queue_draw() def _start_timer(self): GLib.timeout_add(1000, self._tick) def _tick(self): if not self.game.solved: elapsed = int(time.time() - self.game.start_time) m = elapsed // 60 s = elapsed % 60 self._timer_label.set_text(f"{m}:{s:02d}") return True # keep ticking # ─── Application entry ──────────────────────────────────────────────────────── class ShikakuApp(Gtk.Application): def __init__(self): super().__init__(application_id="com.shikaku.puzzle") def do_activate(self): win = ShikakuWindow(self) win.present() def main(): app = ShikakuApp() sys.exit(app.run(sys.argv)) if __name__ == "__main__": main()