commit 41175511985e32a5e6f4760c06c1a5ba8c47b6d1 Author: brian Date: Wed Apr 29 17:01:34 2026 -0400 working game, needs some touch up diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..baeee44 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Nix build outputs +result +result-* + +# Nix evaluation caches +.direnv/ +.envrc + +# Editor noise +.vscode/ +.idea/ +*.swp +*~ diff --git a/README.md b/README.md new file mode 100644 index 0000000..03818b1 --- /dev/null +++ b/README.md @@ -0,0 +1,168 @@ +# Shikaku — Native NixOS Puzzle Game + +A fully offline, ad-free [Shikaku](https://en.wikipedia.org/wiki/Shikaku) rectangle +puzzle game. Built with Python + GTK4. Packaged to nixpkgs standards. + +## What is Shikaku? + +Divide the grid into rectangles. Each rectangle must: +- Contain **exactly one** number clue +- Have an **area (in cells) equal to that number** + +All puzzles are procedurally generated and guaranteed solvable — the generator +works backwards, partitioning the grid into valid rectangles first and then +placing clue numbers, so every puzzle has a solution by construction. + +## Controls + +| Action | Input | +|---------------------|----------------------------------| +| Draw a rectangle | Click and drag across cells | +| Erase a rectangle | Right-click any cell inside it | +| Check your solution | Click **Check** | +| New puzzle | Click **New Puzzle** | +| Clear all | Click **Clear** | + +## Puzzle sizes + +| Size | Status | +|-------|--------| +| 5×5 | ✅ | +| 7×7 | ✅ | +| 10×10 | 🔜 | +| 15×15 | 🔜 | +| 20×20 | 🔜 | +| 25×25 | 🔜 | + +--- + +## Using the package locally (before it lands in nixpkgs) + +### Quick test with nix-build + +Copy the `pkgs/by-name/sh/shikaku/` directory into your local nixpkgs checkout, +then from the nixpkgs root: + +```bash +nix-build -A shikaku +./result/bin/shikaku +``` + +### Install into your profile + +```bash +nix-env -f '' -iA shikaku +``` + +### In your NixOS `configuration.nix` (via an overlay) + +```nix +nixpkgs.overlays = [ + (final: prev: { + shikaku = final.callPackage /path/to/pkgs/by-name/sh/shikaku/package.nix { }; + }) +]; + +environment.systemPackages = [ pkgs.shikaku ]; +``` + +--- + +## Submitting to nixpkgs + +### File layout (already correct for the PR) + +``` +pkgs/by-name/sh/shikaku/ +├── package.nix ← the derivation +└── shikaku.py ← the game source (single file) +``` + +The `pkgs/by-name` directory is automatically wired into nixpkgs — no edits to +`all-packages.nix` are needed. The package attribute `pkgs.shikaku` is created +automatically from the directory name. + +### Steps before opening the PR + +1. **Host the source.** Push this repo to GitHub (or another forge). The + `src.rev` and `src.hash` in `package.nix` must point to a real tagged commit. + Get the hash with: + ```bash + nix-prefetch-url --unpack https://github.com/YOU/shikaku/archive/v0.1.0.tar.gz + # or with the newer tooling: + nix store prefetch-file --hash-type sha256 --unpack \ + https://github.com/YOU/shikaku/archive/refs/tags/v0.1.0.tar.gz + ``` + +2. **Add yourself as a maintainer.** Edit `maintainers/maintainer-list.nix` in + nixpkgs to add your handle, then reference it in `package.nix`: + ```nix + maintainers = with lib.maintainers; [ your-handle ]; + ``` + +3. **Build and run locally** from the nixpkgs root: + ```bash + nix-build -A shikaku + ./result/bin/shikaku + ``` + +4. **Check nixpkgs-vet passes** (CI runs this automatically, but you can run it + locally too): + ```bash + nix-build pkgs/top-level/release.nix -A tarball.nixpkgs-basic-release-checks \ + --arg supportedSystems '["x86_64-linux"]' + ``` + +5. **Open the PR** with the commit message format nixpkgs expects: + ``` + shikaku: init at 0.1.0 + ``` + Include in the PR description: + - A screenshot of the game running + - Link to the upstream source + - Confirmation it builds on x86_64-linux (and aarch64-linux if you can) + +### nixpkgs reviewer checklist (pre-answered) + +| Requirement | Status | +|---|---| +| `pkgs/by-name/sh/shikaku/package.nix` | ✅ | +| `pname` lowercase, no spaces | ✅ `"shikaku"` | +| `version` present | ✅ | +| Source from `fetchFromGitHub` with pinned `rev` and `hash` | ✅ (fill in hash) | +| `meta.description` — short, no trailing period | ✅ | +| `meta.license` | ✅ `lib.licenses.mit` | +| `meta.maintainers` | ⚠️ add yourself | +| `meta.homepage` | ⚠️ add your repo URL | +| `meta.platforms` | ✅ `lib.platforms.linux` (GTK4, Linux only) | +| `meta.mainProgram` | ✅ `"shikaku"` | +| `wrapGAppsHook4` for GTK4 app | ✅ | +| `gobject-introspection` in `nativeBuildInputs` | ✅ | +| No `flake.nix` in the package dir | ✅ | + +--- + +## How puzzle generation works + +Puzzles are generated in reverse, guaranteeing solvability: + +1. The full grid is **partitioned into random non-overlapping rectangles** + (every cell belongs to exactly one rectangle, no gaps). +2. Each rectangle gets **one randomly chosen clue cell**, with the rectangle's + area written as the number. +3. The player sees only the clue numbers and must reconstruct the rectangles. + +Because the rectangles are generated first and clues derived from them, every +puzzle is solvable by construction. There is no backtracking solver needed. + +## Dependencies + +| Dependency | Why | +|---|---| +| `python3` | Runtime | +| `pygobject3` | GTK4 Python bindings | +| `gtk4` | GUI toolkit | +| `gobject-introspection` | Required at build time for PyGObject | +| `wrapGAppsHook4` | Wraps binary with correct GDK/GI env vars at runtime | + +All managed by Nix — nothing to install manually. diff --git a/default.nix b/default.nix new file mode 100644 index 0000000..078999b --- /dev/null +++ b/default.nix @@ -0,0 +1,3 @@ +{ pkgs ? import { } }: + +pkgs.callPackage ./package.nix { } \ No newline at end of file diff --git a/package.nix b/package.nix new file mode 100644 index 0000000..608b581 --- /dev/null +++ b/package.nix @@ -0,0 +1,89 @@ +{ + lib, + python3Packages, + gtk4, + gobject-introspection, + wrapGAppsHook4, +}: + +python3Packages.buildPythonApplication rec { + pname = "shikaku"; + version = "0.1.0"; + pyproject = false; + + src = ./.; + + nativeBuildInputs = [ + gobject-introspection + wrapGAppsHook4 + ]; + + buildInputs = [ + gtk4 + ]; + + dependencies = with python3Packages; [ + pygobject3 + ]; + + # Pure Python — nothing to compile + dontBuild = true; + + installPhase = '' + runHook preInstall + + install -Dm755 shikaku.py $out/share/shikaku/shikaku.py + + # Wrapper script so wrapGAppsHook4 can inject GDK/GI env vars + mkdir -p $out/bin + cat > $out/bin/shikaku << EOF + #!${python3Packages.python.interpreter} + import sys + sys.path.insert(0, "$out/share/shikaku") + import shikaku + shikaku.main() + EOF + chmod +x $out/bin/shikaku + + # Desktop entry for app launchers (GNOME, KDE, etc.) + install -Dm644 /dev/stdin $out/share/applications/shikaku.desktop << EOF + [Desktop Entry] + Version=1.0 + Type=Application + Name=Shikaku + Comment=Rectangle partition logic puzzle + Exec=shikaku + Icon=applications-games + Terminal=false + Categories=Game;LogicGame;BoardGame; + Keywords=puzzle;shikaku;logic;grid;rectangle; + EOF + + runHook postInstall + ''; + + # wrapGAppsHook4 handles GDK_PIXBUF_MODULE_FILE, XDG_DATA_DIRS, GI_TYPELIB_PATH, etc. + # but it expects to wrap a binary. Point it at our wrapper script. + dontWrapGApps = false; + + passthru.tests.shikaku-starts = python3Packages.pytestCheckHook; + + meta = { + description = "Rectangle partition logic puzzle (Shikaku)"; + longDescription = '' + Shikaku is a logic puzzle where you partition a grid into non-overlapping + rectangles. Each rectangle must contain exactly one number, and that number + equals the area of the rectangle in cells. Puzzles are procedurally generated + and guaranteed to be solvable. Supports 5×5 and 7×7 grids, with larger sizes + planned. Built with Python and GTK4; plays fully offline. + ''; + homepage = "https://github.com/your-github-username/shikaku"; # TODO + # changelog = "https://github.com/your-github-username/shikaku/blob/v${version}/CHANGELOG.md"; + license = lib.licenses.mit; + maintainers = with lib.maintainers; [ + # your-nixpkgs-handle # TODO: add yourself to maintainers/maintainer-list.nix first + ]; + platforms = lib.platforms.linux; + mainProgram = "shikaku"; + }; +} diff --git a/shikaku.py b/shikaku.py new file mode 100644 index 0000000..b9d0db2 --- /dev/null +++ b/shikaku.py @@ -0,0 +1,802 @@ +#!/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() \ No newline at end of file