From 41175511985e32a5e6f4760c06c1a5ba8c47b6d1 Mon Sep 17 00:00:00 2001 From: brian Date: Wed, 29 Apr 2026 17:01:34 -0400 Subject: [PATCH 1/7] working game, needs some touch up --- .gitignore | 13 + README.md | 168 +++++++++++ default.nix | 3 + package.nix | 89 ++++++ shikaku.py | 802 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1075 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 default.nix create mode 100644 package.nix create mode 100644 shikaku.py 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 From 903fc7bde46945d73920f2612398bd9310acfd8f Mon Sep 17 00:00:00 2001 From: brian Date: Wed, 29 Apr 2026 17:12:24 -0400 Subject: [PATCH 2/7] added todo --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 03818b1..7b5e1bb 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,14 @@ placing clue numbers, so every puzzle has a solution by construction. | 25×25 | 🔜 | --- +## TODO +- [ ] Fix window size to auto resize when changing game mode +- [ ] Show number of cells selected during a mouse drag, before placing +- [ ] Remove ability to select just 1 cell +- [ ] Add some kind of zoom feature + - Scroll wheel + - and/or a ui button for zoom in/out +--- ## Using the package locally (before it lands in nixpkgs) From 304886fcd573ec86576d59f6fc026e7925ce59cf Mon Sep 17 00:00:00 2001 From: brian Date: Wed, 29 Apr 2026 17:14:16 -0400 Subject: [PATCH 3/7] added possible features --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 7b5e1bb..4fdfcb5 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,10 @@ placing clue numbers, so every puzzle has a solution by construction. - [ ] Add some kind of zoom feature - Scroll wheel - and/or a ui button for zoom in/out + +### Possible Features +- Keep local "high score" for each puzzle type + --- ## Using the package locally (before it lands in nixpkgs) From fa717c5871727e9858db4b7d9919e0b3341a7bad Mon Sep 17 00:00:00 2001 From: brian Date: Thu, 30 Apr 2026 10:12:28 -0400 Subject: [PATCH 4/7] fixed window size and added area of drag --- README.md | 9 ++-- shikaku.py | 134 ++++++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 121 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 4fdfcb5..a453ecd 100644 --- a/README.md +++ b/README.md @@ -36,12 +36,13 @@ placing clue numbers, so every puzzle has a solution by construction. --- ## TODO -- [ ] Fix window size to auto resize when changing game mode -- [ ] Show number of cells selected during a mouse drag, before placing -- [ ] Remove ability to select just 1 cell -- [ ] Add some kind of zoom feature +- [x] Fix window size to auto resize when changing game mode +- [x] Show number of cells selected during a mouse drag, before placing +- [x] Remove ability to select just 1 cell +- [x] Add some kind of zoom feature - Scroll wheel - and/or a ui button for zoom in/out +- [x] Remove red color from rectangle option ### Possible Features - Keep local "high score" for each puzzle type diff --git a/shikaku.py b/shikaku.py index b9d0db2..25e0cf9 100644 --- a/shikaku.py +++ b/shikaku.py @@ -221,8 +221,8 @@ def verify_solution(size: int, player_rects: list[PlayerRect], clues: dict) -> t PADDING = 32 -# Cell size and font scale down for larger grids so they fit comfortably on screen -CELL_SIZES = { +# Base cell sizes per grid — zoom multiplier is applied on top of these +BASE_CELL_SIZES = { 5: 56, 7: 52, 10: 46, @@ -230,7 +230,7 @@ CELL_SIZES = { 20: 28, 25: 24, } -FONT_SIZES = { +BASE_FONT_SIZES = { 5: 14, 7: 13, 10: 12, @@ -239,11 +239,18 @@ FONT_SIZES = { 25: 7, } -def cell_size(grid_size: int) -> int: - return CELL_SIZES.get(grid_size, 40) +ZOOM_MIN = 0.5 +ZOOM_MAX = 3.0 +ZOOM_STEP = 0.15 -def font_size(grid_size: int) -> int: - return FONT_SIZES.get(grid_size, 10) +def cell_size(grid_size: int, zoom: float = 1.0) -> int: + return max(8, int(BASE_CELL_SIZES.get(grid_size, 40) * zoom)) + +def font_size(grid_size: int, zoom: float = 1.0) -> int: + return max(5, int(BASE_FONT_SIZES.get(grid_size, 10) * zoom)) + +# Approximate height of window chrome (toolbar + size bar + bottom bar + status) +CHROME_HEIGHT = 160 # Palette — deep ink & paper feel BG_COLOR = (0.11, 0.10, 0.13, 1.0) # near-black background @@ -258,7 +265,7 @@ RECT_COLORS = [ (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.90, 0.65, 0.80, 0.45), # rose (0.55, 0.72, 0.35, 0.45), # lime ] RECT_BORDER = [ @@ -268,7 +275,7 @@ RECT_BORDER = [ (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.80, 0.45, 0.65, 1.0), (0.35, 0.54, 0.18, 1.0), ] DRAG_COLOR = (0.50, 0.70, 0.95, 0.30) @@ -281,6 +288,7 @@ class ShikakuCanvas(Gtk.DrawingArea): def __init__(self, game): super().__init__() self.game = game + self.zoom = 1.0 self.set_draw_func(self._draw) self.set_can_focus(True) @@ -292,6 +300,8 @@ class ShikakuCanvas(Gtk.DrawingArea): # Callback fired after every board change — set by the window self.on_board_changed = None + # Callback fired when zoom changes — set by the window + self.on_zoom_changed = None # Gestures drag = Gtk.GestureDrag() @@ -305,8 +315,14 @@ class ShikakuCanvas(Gtk.DrawingArea): click.connect("pressed", self._on_right_click) self.add_controller(click) + # Scroll wheel for zoom + scroll = Gtk.EventControllerScroll() + scroll.set_flags(Gtk.EventControllerScrollFlags.VERTICAL) + scroll.connect("scroll", self._on_scroll) + self.add_controller(scroll) + def _cell_at(self, x, y): - cs = cell_size(self.game.size) + cs = cell_size(self.game.size, self.zoom) col = int((x - PADDING) // cs) row = int((y - PADDING) // cs) size = self.game.size @@ -339,13 +355,14 @@ class ShikakuCanvas(Gtk.DrawingArea): sr, sc = self._drag_start er, ec = self._drag_end pr = PlayerRect(sr, sc, er, ec) - self.game.place_rect(pr) + if pr.area() > 1: # enforce minimum area of 2 + self.game.place_rect(pr) + if self.on_board_changed: + self.on_board_changed() 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) @@ -355,6 +372,20 @@ class ShikakuCanvas(Gtk.DrawingArea): if self.on_board_changed: self.on_board_changed() + def _on_scroll(self, controller, dx, dy): + # Ctrl+scroll or plain scroll both zoom + old_zoom = self.zoom + self.zoom = max(ZOOM_MIN, min(ZOOM_MAX, self.zoom - dy * ZOOM_STEP)) + if self.zoom != old_zoom: + if self.on_zoom_changed: + self.on_zoom_changed() + return True # consume event + + def set_zoom(self, zoom: float): + self.zoom = max(ZOOM_MIN, min(ZOOM_MAX, zoom)) + if self.on_zoom_changed: + self.on_zoom_changed() + def mark_errors(self, error_indices: set[int]): self._error_rects = error_indices self.queue_draw() @@ -367,7 +398,7 @@ class ShikakuCanvas(Gtk.DrawingArea): size = self.game.size clues = self.game.clues player_rects = self.game.player_rects - cs = cell_size(size) + cs = cell_size(size, self.zoom) grid_w = size * cs # Background @@ -410,14 +441,36 @@ class ShikakuCanvas(Gtk.DrawingArea): 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) + preview_area = self._drag_preview.area() + is_invalid = preview_area == 1 + + cr.set_source_rgba(*(ERROR_COLOR if is_invalid else DRAG_COLOR)) _rounded_rect(cr, rx + 2, ry + 2, rw - 4, rh - 4, 6) cr.fill() - cr.set_source_rgba(*DRAG_BORDER) + cr.set_source_rgba(*(ERROR_BORDER if is_invalid else DRAG_BORDER)) cr.set_line_width(2.0) _rounded_rect(cr, rx + 2, ry + 2, rw - 4, rh - 4, 6) cr.stroke() + # Draw cell count label inside the drag preview + count_layout = self.create_pango_layout("") + count_fs = max(8, int(font_size(size, self.zoom) * 0.95)) + count_layout.set_font_description( + Pango.FontDescription(f"Iosevka, Monospace Bold {count_fs}") + ) + count_layout.set_text(str(preview_area), -1) + _, ext = count_layout.get_pixel_extents() + tx = rx + rw // 2 - ext.width // 2 + ty = ry + rh // 2 - ext.height // 2 + # White text with dark shadow for readability on any background + cr.set_source_rgba(0.1, 0.05, 0.15, 0.6) + cr.move_to(tx + 1, ty + 1) + from gi.repository import PangoCairo + PangoCairo.show_layout(cr, count_layout) + cr.set_source_rgba(1.0, 1.0, 1.0, 0.95) + cr.move_to(tx, ty) + PangoCairo.show_layout(cr, count_layout) + # Grid lines cr.set_line_width(0.8) cr.set_source_rgba(*GRID_LINE) @@ -438,7 +491,7 @@ class ShikakuCanvas(Gtk.DrawingArea): # Clue numbers layout = self.create_pango_layout("") - fs = font_size(size) + fs = font_size(size, self.zoom) layout.set_font_description(Pango.FontDescription(f"Iosevka, Monospace Bold {fs}")) for (r, c), num in clues.items(): layout.set_text(str(num), -1) @@ -574,6 +627,7 @@ class ShikakuWindow(Gtk.ApplicationWindow): self.canvas.set_halign(Gtk.Align.CENTER) self.canvas.set_valign(Gtk.Align.CENTER) self.canvas.on_board_changed = self._trigger_auto_check + self.canvas.on_zoom_changed = self._on_zoom_changed scroll.set_child(self.canvas) outer.append(scroll) @@ -584,6 +638,31 @@ class ShikakuWindow(Gtk.ApplicationWindow): botbar.set_margin_bottom(18) outer.append(botbar) + zoombar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + zoombar.set_halign(Gtk.Align.CENTER) + zoombar.set_margin_bottom(10) + outer.append(zoombar) + + zoom_out_btn = Gtk.Button(label="−") + zoom_out_btn.add_css_class("zoom-btn") + zoom_out_btn.connect("clicked", self._on_zoom_out) + zoombar.append(zoom_out_btn) + + self._zoom_label = Gtk.Label(label="100%") + self._zoom_label.add_css_class("zoom-label") + self._zoom_label.set_width_chars(5) + zoombar.append(self._zoom_label) + + zoom_in_btn = Gtk.Button(label="+") + zoom_in_btn.add_css_class("zoom-btn") + zoom_in_btn.connect("clicked", self._on_zoom_in) + zoombar.append(zoom_in_btn) + + zoom_reset_btn = Gtk.Button(label="1:1") + zoom_reset_btn.add_css_class("action-btn-ghost") + zoom_reset_btn.connect("clicked", self._on_zoom_reset) + zoombar.append(zoom_reset_btn) + new_btn = Gtk.Button(label="New Puzzle") new_btn.add_css_class("action-btn") new_btn.connect("clicked", self._on_new) @@ -599,6 +678,7 @@ class ShikakuWindow(Gtk.ApplicationWindow): 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) @@ -706,10 +786,11 @@ class ShikakuWindow(Gtk.ApplicationWindow): def _update_canvas_size(self): sz = self.game.size - cs = cell_size(sz) + cs = cell_size(sz, self.canvas.zoom) total = sz * cs + 2 * PADDING self.canvas.set_content_width(total) self.canvas.set_content_height(total) + self.set_default_size(total + 80, total + CHROME_HEIGHT + 60) def _on_size_clicked(self, btn, size): self._current_size = size @@ -717,6 +798,8 @@ class ShikakuWindow(Gtk.ApplicationWindow): self.canvas.game = self.game self.canvas.on_board_changed = self._trigger_auto_check self.canvas._error_rects = set() + self.canvas.zoom = 1.0 # reset zoom on size change + self._zoom_label.set_text("100%") # reset zoom label self._update_canvas_size() self._update_size_buttons() self._status_label.remove_css_class("status-ok") @@ -781,6 +864,21 @@ class ShikakuWindow(Gtk.ApplicationWindow): self._timer_label.set_text(f"{m}:{s:02d}") return True # keep ticking + def _on_zoom_changed(self): + pct = int(self.canvas.zoom * 100) + self._zoom_label.set_text(f"{pct}%") + self._update_canvas_size() + self.canvas.queue_draw() + + def _on_zoom_in(self, btn): + self.canvas.set_zoom(self.canvas.zoom + ZOOM_STEP) + + def _on_zoom_out(self, btn): + self.canvas.set_zoom(self.canvas.zoom - ZOOM_STEP) + + def _on_zoom_reset(self, btn): + self.canvas.set_zoom(1.0) + # ─── Application entry ──────────────────────────────────────────────────────── From 1bce0295b3893b3c63d59e66a7ce44f80602f505 Mon Sep 17 00:00:00 2001 From: brian Date: Thu, 30 Apr 2026 10:35:09 -0400 Subject: [PATCH 5/7] updated todo --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index a453ecd..b7cfe0d 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ placing clue numbers, so every puzzle has a solution by construction. - Scroll wheel - and/or a ui button for zoom in/out - [x] Remove red color from rectangle option +- [ ] Allow user to show the "seed" of the puzzle +- [ ] Option to input a custom seed ### Possible Features - Keep local "high score" for each puzzle type From 9e3c7f7b19f84037ee082a8c0ae3906cf9c6de7a Mon Sep 17 00:00:00 2001 From: brian Date: Thu, 30 Apr 2026 11:45:20 -0400 Subject: [PATCH 6/7] added custom seed input --- README.md | 4 +-- shikaku.py | 101 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b7cfe0d..4485643 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,8 @@ placing clue numbers, so every puzzle has a solution by construction. - Scroll wheel - and/or a ui button for zoom in/out - [x] Remove red color from rectangle option -- [ ] Allow user to show the "seed" of the puzzle -- [ ] Option to input a custom seed +- [x] Allow user to show the "seed" of the puzzle +- [x] Option to input a custom seed ### Possible Features - Keep local "high score" for each puzzle type diff --git a/shikaku.py b/shikaku.py index 25e0cf9..36d6aec 100644 --- a/shikaku.py +++ b/shikaku.py @@ -527,6 +527,7 @@ class ShikakuGame: self.new_puzzle() def new_puzzle(self, seed=None): + self.current_seed = seed or random.randint(0, 2**31) self.clues, self.solution = generate_puzzle(self.size, seed or random.randint(0, 2**31)) self.player_rects = [] self.start_time = time.time() @@ -678,6 +679,10 @@ class ShikakuWindow(Gtk.ApplicationWindow): clear_btn.connect("clicked", self._on_clear) botbar.append(clear_btn) + seed_btn = Gtk.Button(label="Seed") + seed_btn.add_css_class("action-btn-ghost") + seed_btn.connect("clicked", self._on_seed) + botbar.append(seed_btn) self._status_label = Gtk.Label(label="Draw rectangles around each number — right-click to erase") self._status_label.add_css_class("status-label") @@ -776,6 +781,24 @@ class ShikakuWindow(Gtk.ApplicationWindow): .status-err { color: #c06060; } + .seed-heading { + font-family: "Iosevka", "Monospace"; + font-size: 10px; + letter-spacing: 3px; + color: #6a6070; + } + .seed-value { + font-family: "Iosevka", "Monospace"; + font-size: 20px; + font-weight: 700; + color: #c0b8d8; + letter-spacing: 2px; + } + .seed-entry { + font-family: "Iosevka", "Monospace"; + font-size: 14px; + color: #e8e0ff; + } """ def _update_size_buttons(self): @@ -879,6 +902,84 @@ class ShikakuWindow(Gtk.ApplicationWindow): def _on_zoom_reset(self, btn): self.canvas.set_zoom(1.0) + def _on_seed(self, btn): + dialog = Gtk.Dialog(title="Puzzle Seed", transient_for=self, modal=True) + dialog.set_resizable(False) + + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + box.set_margin_top(20) + box.set_margin_bottom(20) + box.set_margin_start(24) + box.set_margin_end(24) + dialog.get_content_area().append(box) + + # Current seed display + current_label = Gtk.Label(label="CURRENT SEED") + current_label.add_css_class("seed-heading") + current_label.set_halign(Gtk.Align.START) + box.append(current_label) + + seed_val = Gtk.Label(label=str(self.game.current_seed)) + seed_val.add_css_class("seed-value") + seed_val.set_selectable(True) + seed_val.set_halign(Gtk.Align.START) + box.append(seed_val) + + sep = Gtk.Separator() + sep.set_margin_top(4) + sep.set_margin_bottom(4) + box.append(sep) + + # Custom seed entry + entry_label = Gtk.Label(label="ENTER SEED") + entry_label.add_css_class("seed-heading") + entry_label.set_halign(Gtk.Align.START) + box.append(entry_label) + + entry = Gtk.Entry() + entry.set_placeholder_text("e.g. 123456789") + entry.set_input_purpose(Gtk.InputPurpose.DIGITS) + entry.add_css_class("seed-entry") + box.append(entry) + + # Buttons + btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + btn_box.set_halign(Gtk.Align.END) + btn_box.set_margin_top(8) + box.append(btn_box) + + cancel_btn = Gtk.Button(label="Cancel") + cancel_btn.add_css_class("action-btn-ghost") + cancel_btn.connect("clicked", lambda b: dialog.close()) + btn_box.append(cancel_btn) + + go_btn = Gtk.Button(label="Start Puzzle") + go_btn.add_css_class("action-btn") + btn_box.append(go_btn) + + def _on_go(b): + text = entry.get_text().strip() + try: + seed = int(text) + except ValueError: + entry.add_css_class("error") + return + dialog.close() + self.game.new_puzzle(seed=seed) + self.canvas._error_rects = set() + self.canvas.zoom = 1.0 + self._zoom_label.set_text("100%") + self._update_canvas_size() + 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() + + go_btn.connect("clicked", _on_go) + entry.connect("activate", _on_go) # Enter key submits + + dialog.present() + # ─── Application entry ──────────────────────────────────────────────────────── From 7b13e4eae43815718be53d26d170b8a11bdf3043 Mon Sep 17 00:00:00 2001 From: brian Date: Thu, 30 Apr 2026 11:55:02 -0400 Subject: [PATCH 7/7] fixed seed input --- shikaku.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shikaku.py b/shikaku.py index 36d6aec..94361f8 100644 --- a/shikaku.py +++ b/shikaku.py @@ -528,7 +528,7 @@ class ShikakuGame: def new_puzzle(self, seed=None): self.current_seed = seed or random.randint(0, 2**31) - self.clues, self.solution = generate_puzzle(self.size, seed or random.randint(0, 2**31)) + self.clues, self.solution = generate_puzzle(self.size, self.current_seed) self.player_rects = [] self.start_time = time.time() self.elapsed = 0