From cc4d90581b71c069567cbb7da8c2b5601ecd3cdd Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Mon, 10 Aug 2026 01:44:41 -0600 Subject: [PATCH 1/2] fix: make drawButton's clickable region match the box it draws The hit test used strict inequalities on both axes while pygame.draw.rect fills a half-open range, so the painted left and top edge lines of every button were dead while the right and bottom ones worked. Comparing half-open on both axes makes the clickable region exactly the drawn one, and keeps edge-to-edge buttons from both claiming a shared boundary coordinate. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 +- .../python/preponderous/graphik/graphik.py | 13 +++- .../preponderous/graphik/test_graphik.py | 66 +++++++++++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5c864d6..ac2686d 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Public `Graphik` methods: - `getVersion()` — returns the installed graphik version string. - `drawRectangle(xpos, ypos, width, height, color)` — `xpos`/`ypos` are the rectangle's top-left corner. - `drawText(text, xpos, ypos, size, color)` — `xpos`/`ypos` are the **center** of the rendered text, not its top-left corner. This differs from `drawRectangle`, `drawButton` and `drawImage`, which all position their top-left corner at the given coordinates. -- `drawButton(xpos, ypos, width, height, colorBox, colorText, sizeText, text, function)` — draws a rectangle and centered text, and calls `function()` on every call where the mouse is held inside the button with button 1 down (there is no click-edge detection, so a held-down mouse fires `function()` once per call, not once per click). Callers wanting once-per-click semantics must debounce on their side. +- `drawButton(xpos, ypos, width, height, colorBox, colorText, sizeText, text, function)` — draws a rectangle and centered text, and calls `function()` on every call where the mouse is held inside the button with button 1 down (there is no click-edge detection, so a held-down mouse fires `function()` once per call, not once per click). Callers wanting once-per-click semantics must debounce on their side. The clickable region is exactly the drawn box — `xpos` through `xpos + width - 1`, and `ypos` through `ypos + height - 1` — so buttons laid out edge to edge share no clickable coordinate. - `drawImage(filePath, xpos, ypos, width, height)` — caches the loaded and scaled surface by `filePath`, so an image edited on disk mid-run will not be picked up until the process restarts. Color constants: `Graphik.black`, `Graphik.white`, `Graphik.red`, `Graphik.green`, `Graphik.blue`. diff --git a/src/main/python/preponderous/graphik/graphik.py b/src/main/python/preponderous/graphik/graphik.py index cb7cd2d..63e1498 100644 --- a/src/main/python/preponderous/graphik/graphik.py +++ b/src/main/python/preponderous/graphik/graphik.py @@ -145,6 +145,12 @@ def drawButton(self, xpos, ypos, width, height, colorBox, colorText, sizeText, t click. A caller wanting once-per-click semantics must debounce on its own side; see the implementation note below for why. + The clickable region is exactly the region the box is drawn over: + ``xpos`` through ``xpos + width - 1`` horizontally, and ``ypos`` + through ``ypos + height - 1`` vertically. Buttons laid out edge to + edge therefore share no clickable coordinate -- a boundary belongs to + the button whose left/top edge sits on it. + Args: xpos: X coordinate of the box's left edge, in pixels. ypos: Y coordinate of the box's top edge, in pixels. @@ -168,7 +174,12 @@ def drawButton(self, xpos, ypos, width, height, colorBox, colorText, sizeText, t # if clicked then do function mouse = pygame.mouse.get_pos() - if (xpos + width > mouse[0] > xpos and ypos + height > mouse[1] > ypos): + # Half-open on both axes, matching the range pygame.draw.rect fills, so + # the clickable region is exactly the drawn one. A strict `> xpos` here + # would leave the painted left and top edge columns unclickable, and a + # closed `<= xpos + width` would let edge-to-edge buttons both claim + # their shared boundary. + if (xpos <= mouse[0] < xpos + width and ypos <= mouse[1] < ypos + height): click = pygame.mouse.get_pressed() if click[0] == 1: function() diff --git a/src/test/python/preponderous/graphik/test_graphik.py b/src/test/python/preponderous/graphik/test_graphik.py index 2d55c7c..ccdb2c0 100644 --- a/src/test/python/preponderous/graphik/test_graphik.py +++ b/src/test/python/preponderous/graphik/test_graphik.py @@ -393,6 +393,72 @@ def test_draw_button_invokes_callback_only_on_inside_click( assert calls == ([True] if expect_call else []) +def _click_at(monkeypatch, graphik, pos, box=(10, 10, 20, 20)): + # Draw one button and report whether a press at `pos` reached the callback. + calls = [] + monkeypatch.setattr(pygame.mouse, "get_pos", lambda: pos) + monkeypatch.setattr(pygame.mouse, "get_pressed", lambda: (1, 0, 0)) + xpos, ypos, width, height = box + graphik.drawButton( + xpos, ypos, width, height, Graphik.blue, Graphik.white, 10, "Go", lambda: calls.append(True) + ) + return bool(calls) + + +@pytest.mark.parametrize( + "pos, expect_call", + [ + # The box drawn at (10,10) 20x20 covers pixels 10..29 on both axes, so + # every one of its four edges must be clickable... + pytest.param((10, 15), True, id="left_edge"), + pytest.param((15, 10), True, id="top_edge"), + pytest.param((29, 15), True, id="right_edge"), + pytest.param((15, 29), True, id="bottom_edge"), + pytest.param((10, 10), True, id="top_left_corner"), + # ...and nothing outside that region may be, including the first + # coordinate past the far edge, which is not painted. + pytest.param((9, 15), False, id="just_left_of_box"), + pytest.param((15, 9), False, id="just_above_box"), + pytest.param((30, 15), False, id="just_right_of_box"), + pytest.param((15, 30), False, id="just_below_box"), + ], +) +def test_draw_button_clickable_region_matches_the_drawn_box(monkeypatch, pos, expect_call): + # Regression guard: the hit test used strict inequalities on both axes, which + # left the painted left and top edge lines dead while the right and bottom + # ones worked. The clickable region must be exactly the drawn region. + graphik = _make_graphik((40, 40)) + assert _click_at(monkeypatch, graphik, pos) is expect_call + + +def test_draw_button_edges_are_painted_where_they_are_clickable(): + # Anchors the test above to what is actually drawn, so the two cannot drift: + # the edge pixels asserted clickable are the same ones filled with colorBox. + graphik = _make_graphik((40, 40)) + display = graphik.getGameDisplay() + display.fill(Graphik.black) + + graphik.drawButton(10, 10, 20, 20, Graphik.blue, Graphik.white, 10, "Go", lambda: None) + + assert _rgb(display, (10, 15)) == Graphik.blue + assert _rgb(display, (15, 10)) == Graphik.blue + assert _rgb(display, (29, 15)) == Graphik.blue + assert _rgb(display, (15, 29)) == Graphik.blue + # One past the far edge is outside the fill, matching the half-open bounds. + assert _rgb(display, (30, 15)) == Graphik.black + assert _rgb(display, (15, 30)) == Graphik.black + + +def test_adjacent_buttons_do_not_share_a_clickable_boundary(monkeypatch): + # With half-open bounds a shared boundary belongs to exactly one button, so + # stacking buttons edge to edge cannot fire both callbacks from one press. + graphik = _make_graphik((60, 40)) + left = _click_at(monkeypatch, graphik, (30, 15), box=(10, 10, 20, 20)) + right = _click_at(monkeypatch, graphik, (30, 15), box=(30, 10, 20, 20)) + + assert (left, right) == (False, True) + + def test_draw_button_fires_callback_once_per_call_while_mouse_held(monkeypatch): # Pins the documented repeat-fire behavior: there is no click-edge # detection, so a held-down mouse inside the button fires the callback From abe1bee3bbe428d8f5da8d7feabd4c2d11393e03 Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Mon, 10 Aug 2026 01:45:58 -0600 Subject: [PATCH 2/2] test: compare drawButton hit-test result by value rather than identity Co-Authored-By: Claude Opus 5 (1M context) --- src/test/python/preponderous/graphik/test_graphik.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/python/preponderous/graphik/test_graphik.py b/src/test/python/preponderous/graphik/test_graphik.py index ccdb2c0..bca4941 100644 --- a/src/test/python/preponderous/graphik/test_graphik.py +++ b/src/test/python/preponderous/graphik/test_graphik.py @@ -428,7 +428,7 @@ def test_draw_button_clickable_region_matches_the_drawn_box(monkeypatch, pos, ex # left the painted left and top edge lines dead while the right and bottom # ones worked. The clickable region must be exactly the drawn region. graphik = _make_graphik((40, 40)) - assert _click_at(monkeypatch, graphik, pos) is expect_call + assert _click_at(monkeypatch, graphik, pos) == expect_call def test_draw_button_edges_are_painted_where_they_are_clickable():