working game, needs some touch up

This commit is contained in:
2026-04-29 17:01:34 -04:00
commit 4117551198
5 changed files with 1075 additions and 0 deletions

13
.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
# Nix build outputs
result
result-*
# Nix evaluation caches
.direnv/
.envrc
# Editor noise
.vscode/
.idea/
*.swp
*~

168
README.md Normal file
View File

@@ -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 '<nixpkgs>' -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.

3
default.nix Normal file
View File

@@ -0,0 +1,3 @@
{ pkgs ? import <nixpkgs> { } }:
pkgs.callPackage ./package.nix { }

89
package.nix Normal file
View File

@@ -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";
};
}

802
shikaku.py Normal file
View File

@@ -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()