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 ────────────────────────────────────────────────────────