Files
shikaku-nix/shikaku.py
2026-04-30 11:55:02 -04:00

1001 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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
# Base cell sizes per grid — zoom multiplier is applied on top of these
BASE_CELL_SIZES = {
5: 56,
7: 52,
10: 46,
15: 36,
20: 28,
25: 24,
}
BASE_FONT_SIZES = {
5: 14,
7: 13,
10: 12,
15: 10,
20: 8,
25: 7,
}
ZOOM_MIN = 0.5
ZOOM_MAX = 3.0
ZOOM_STEP = 0.15
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
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.90, 0.65, 0.80, 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.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)
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.zoom = 1.0
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
# Callback fired when zoom changes — set by the window
self.on_zoom_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)
# 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, self.zoom)
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)
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()
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 _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()
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, self.zoom)
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
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(*(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)
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, 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)
_, 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.current_seed = 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
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
self.canvas.on_zoom_changed = self._on_zoom_changed
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)
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)
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)
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")
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;
}
.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):
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, 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
self.game = ShikakuGame(size)
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")
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
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)
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 ────────────────────────────────────────────────────────
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()