From c10f49661251a009f077cec0d4cf77a3542df101 Mon Sep 17 00:00:00 2001 From: Alex J Lennon Date: Mon, 3 Aug 2026 02:11:11 +0100 Subject: [PATCH 1/4] fix(mcq): size image dialogs for smallest / host monitor Dual-monitor defaults were taken from the largest Gdk/xrandr mode, so image MCQs opened at ~72% of 4K and overflowed the laptop eDP. Prefer the pointer's monitor when known, otherwise the smallest connected workarea/geometry; maximize stays compositor-local to the host display. Co-authored-by: Cursor --- scripts/test_dialog_ergonomics.py | 50 ++++++++++++ src/ask_question_mcp/gtk4_list_ask.py | 108 ++++++++++++++++++++++---- 2 files changed, 143 insertions(+), 15 deletions(-) diff --git a/scripts/test_dialog_ergonomics.py b/scripts/test_dialog_ergonomics.py index 2c449ff..e153b44 100644 --- a/scripts/test_dialog_ergonomics.py +++ b/scripts/test_dialog_ergonomics.py @@ -59,9 +59,59 @@ def test_window_geometry(tmp_path: Path | None = None) -> None: prefs_mod._PREFS_PATH = old +def test_pick_sizing_monitor_wh() -> None: + """Dual-monitor: defaults follow host or smallest panel, not 4K.""" + # Import helpers without running the Gtk dialog entrypoint. + import importlib.util + + path = ROOT / "src" / "ask_question_mcp" / "gtk4_list_ask.py" + spec = importlib.util.spec_from_file_location("gtk4_list_ask_under_test", path) + assert spec is not None and spec.loader is not None + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + laptop = (1803, 1202) + external = (3840, 2160) + assert mod._pick_sizing_monitor_wh([laptop, external]) == laptop + assert mod._pick_sizing_monitor_wh([external, laptop]) == laptop + assert mod._pick_sizing_monitor_wh( + [laptop, external], preferred=external + ) == external + assert mod._pick_sizing_monitor_wh([], preferred=None) == (1280, 800) + + # Pointer on eDP vs DP-2 (Alex Framework + Samsung layout). + rects = [(0, 1386, 1803, 1202), (1803, 0, 3840, 2160)] + import subprocess as sp + + real_check = sp.check_output + + def fake_check(cmd, **kwargs): # type: ignore[no-untyped-def] + if cmd and cmd[0] == "xdotool": + return "X=200\nY=1500\nSCREEN=0\nWINDOW=0\n" + return real_check(cmd, **kwargs) + + sp.check_output = fake_check # type: ignore[assignment] + try: + assert mod._monitor_wh_under_pointer(rects) == laptop + finally: + sp.check_output = real_check # type: ignore[assignment] + + def fake_check_big(cmd, **kwargs): # type: ignore[no-untyped-def] + if cmd and cmd[0] == "xdotool": + return "X=2500\nY=500\nSCREEN=0\nWINDOW=0\n" + return real_check(cmd, **kwargs) + + sp.check_output = fake_check_big # type: ignore[assignment] + try: + assert mod._monitor_wh_under_pointer(rects) == external + finally: + sp.check_output = real_check # type: ignore[assignment] + + def main() -> None: test_hotkeys() test_window_geometry() + test_pick_sizing_monitor_wh() print("OK dialog ergonomics (hotkeys + window geometry)") diff --git a/src/ask_question_mcp/gtk4_list_ask.py b/src/ask_question_mcp/gtk4_list_ask.py index a5a33e5..e206f84 100644 --- a/src/ask_question_mcp/gtk4_list_ask.py +++ b/src/ask_question_mcp/gtk4_list_ask.py @@ -70,9 +70,75 @@ def _ipc_root() -> Path: return Path.home() / ".cache" / "ask-question-mcp" +def _pick_sizing_monitor_wh( + sizes: list[tuple[int, int]], + *, + preferred: tuple[int, int] | None = None, +) -> tuple[int, int]: + """Pick width/height for dialog defaults / clamps. + + Dual-monitor: size against the host monitor when known, otherwise the + *smallest* connected display — never the largest (a 4K panel must not + drive defaults that overflow a laptop eDP). + """ + if preferred is not None: + pw, ph = int(preferred[0]), int(preferred[1]) + if pw > 0 and ph > 0: + return pw, ph + usable = [(int(w), int(h)) for w, h in sizes if int(w) > 0 and int(h) > 0] + if not usable: + return 1280, 800 + return min(usable, key=lambda wh: wh[0] * wh[1]) + + +def _monitor_wh_under_pointer( + rects: list[tuple[int, int, int, int]], +) -> tuple[int, int] | None: + """Return (w, h) of the monitor containing the pointer, if known. + + ``rects`` are ``(x, y, w, h)`` in the same coordinate space as the pointer + query (X11 / XWayland via ``xdotool`` when available). + """ + if not rects: + return None + try: + out = subprocess.check_output( + ["xdotool", "getmouselocation", "--shell"], + env=os.environ, + text=True, + timeout=1, + stderr=subprocess.DEVNULL, + ) + except Exception: # noqa: BLE001 + return None + px = py = None + for line in out.splitlines(): + if line.startswith("X="): + try: + px = int(line.split("=", 1)[1]) + except ValueError: + return None + elif line.startswith("Y="): + try: + py = int(line.split("=", 1)[1]) + except ValueError: + return None + if px is None or py is None: + return None + for x, y, w, h in rects: + if x <= px < x + w and y <= py < y + h: + return w, h + return None + + def _display_size_px(display: object | None = None) -> tuple[int, int]: - """Best-effort monitor size for image-MCQ window defaults (Gtk4).""" - best_w, best_h = 0, 0 + """Best-effort monitor size for image-MCQ window defaults (Gtk4). + + Prefer the monitor under the pointer; else the smallest connected output. + Maximize remains compositor-local to whichever monitor hosts the window. + """ + sizes: list[tuple[int, int]] = [] + rects: list[tuple[int, int, int, int]] = [] try: import gi @@ -87,14 +153,26 @@ def _display_size_px(display: object | None = None) -> tuple[int, int]: mon = monitors.get_item(i) if mon is None: continue - geom = mon.get_geometry() + # Prefer workarea (excludes panels) when the backend exposes it; + # GdkWaylandMonitor often only has full geometry. + geom = None + get_wa = getattr(mon, "get_workarea", None) + if callable(get_wa): + try: + geom = get_wa() + except Exception: # noqa: BLE001 + geom = None + if geom is None: + geom = mon.get_geometry() w, h = int(geom.width), int(geom.height) - if w * h > best_w * best_h: - best_w, best_h = w, h + if w > 0 and h > 0: + sizes.append((w, h)) + rects.append((int(geom.x), int(geom.y), w, h)) except Exception: # noqa: BLE001 pass - if best_w >= 800 and best_h >= 600: - return best_w, best_h + if sizes: + preferred = _monitor_wh_under_pointer(rects) + return _pick_sizing_monitor_wh(sizes, preferred=preferred) # Gdk sometimes has no monitors early; prefer a single connected output # (xrandr) over xdpyinfo's combined virtual desktop on multi-head. try: @@ -107,22 +185,22 @@ def _display_size_px(display: object | None = None) -> tuple[int, int]: timeout=2, stderr=subprocess.DEVNULL, ) - # Prefer the largest connected mode (laptop "primary" is often a - # scaled eDP while the big external panel is where stills are judged). - largest = (0, 0) + x_sizes: list[tuple[int, int]] = [] + x_rects: list[tuple[int, int, int, int]] = [] for line in out.splitlines(): # e.g. "DP-2 connected primary 3840x2160+1803+0 ..." m = re.search( - r" connected(?: primary)? (\d+)x(\d+)\+", + r" connected(?: primary)? (\d+)x(\d+)\+(\d+)\+(\d+)", line, ) if not m: continue w, h = int(m.group(1)), int(m.group(2)) - if w * h > largest[0] * largest[1]: - largest = (w, h) - if largest[0] >= 800: - return largest + x_sizes.append((w, h)) + x_rects.append((int(m.group(3)), int(m.group(4)), w, h)) + if x_sizes: + preferred = _monitor_wh_under_pointer(x_rects) + return _pick_sizing_monitor_wh(x_sizes, preferred=preferred) except Exception: # noqa: BLE001 pass return 1280, 800 From f627b671425645d37ad88341a7a29cc937dadc07 Mon Sep 17 00:00:00 2001 From: Alex J Lennon Date: Mon, 3 Aug 2026 02:35:47 +0100 Subject: [PATCH 2/4] fix(mcq): fit image dialogs to usable host monitor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default image-MCQ geometry no longer uses absolute 720×700 floors or a 62% preview that overflows Framework eDP once chrome is counted. Maximize reflows the still to the host panel and restores the prior size reliably. Co-authored-by: Cursor --- scripts/test_dialog_ergonomics.py | 71 ++++++- src/ask_question_mcp/gtk4_list_ask.py | 267 ++++++++++++++++++++++---- 2 files changed, 290 insertions(+), 48 deletions(-) diff --git a/scripts/test_dialog_ergonomics.py b/scripts/test_dialog_ergonomics.py index e153b44..4cebb7d 100644 --- a/scripts/test_dialog_ergonomics.py +++ b/scripts/test_dialog_ergonomics.py @@ -3,6 +3,7 @@ from __future__ import annotations +import importlib.util import sys from pathlib import Path @@ -17,6 +18,15 @@ ) +def _load_gtk4_list_ask(): + path = ROOT / "src" / "ask_question_mcp" / "gtk4_list_ask.py" + spec = importlib.util.spec_from_file_location("gtk4_list_ask_under_test", path) + assert spec is not None and spec.loader is not None + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + def test_hotkeys() -> None: assert option_hotkey_index(0x031) == 0 assert option_hotkey_index(0x038) == 7 @@ -61,14 +71,7 @@ def test_window_geometry(tmp_path: Path | None = None) -> None: def test_pick_sizing_monitor_wh() -> None: """Dual-monitor: defaults follow host or smallest panel, not 4K.""" - # Import helpers without running the Gtk dialog entrypoint. - import importlib.util - - path = ROOT / "src" / "ask_question_mcp" / "gtk4_list_ask.py" - spec = importlib.util.spec_from_file_location("gtk4_list_ask_under_test", path) - assert spec is not None and spec.loader is not None - mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) + mod = _load_gtk4_list_ask() laptop = (1803, 1202) external = (3840, 2160) @@ -108,11 +111,61 @@ def fake_check_big(cmd, **kwargs): # type: ignore[no-untyped-def] sp.check_output = real_check # type: ignore[assignment] +def test_image_mcq_sizing_fits_laptop() -> None: + """Preview + chrome + default window must fit usable eDP, not overflow.""" + mod = _load_gtk4_list_ask() + laptop = (1803, 1202) + external = (3840, 2160) + + uw, uh = mod._usable_monitor_wh(laptop) + assert uw == 1803 - mod._EDGE_MARGIN_W + assert uh == 1202 - mod._PANEL_RESERVE_H + + exp_w, exp_h = mod._preview_max_wh(laptop, maximized=False, expanded=True) + assert exp_h + mod._IMAGE_MCQ_CHROME_H <= uh + assert exp_w <= uw + assert exp_h < int(uh * 0.55) # ~50% usable, not old 62% raw + + compact = mod._preview_max_wh(laptop, maximized=False, expanded=False) + assert compact[1] <= 280 + assert compact[0] <= 640 + + geom_w, geom_h = mod._image_mcq_default_size(laptop) + assert geom_w <= uw and geom_h <= uh + assert geom_h >= exp_h + mod._IMAGE_MCQ_CHROME_H - 1 + # Must not use absolute floors that exceed a small panel. + tiny = (800, 600) + tw, th = mod._image_mcq_default_size(tiny) + tuw, tuh = mod._usable_monitor_wh(tiny) + assert tw <= tuw and th <= tuh + + # 4K must not drive default when sizing monitor is laptop. + g4k = mod._image_mcq_default_size(external) + assert g4k[0] > geom_w # larger host → larger default + # But pick_sizing prefers laptop when both present (covered elsewhere). + + max_w, max_h = mod._preview_max_wh(laptop, maximized=True, expanded=True) + assert max_w > exp_w and max_h > exp_h + assert max_h == 1202 - mod._IMAGE_MCQ_CHROME_H + + # Maximize on host 4K while defaults were laptop-sized. + big_w, big_h = mod._preview_max_wh(external, maximized=True, expanded=True) + assert big_w == 3840 - mod._EDGE_MARGIN_W + assert big_h == 2160 - mod._IMAGE_MCQ_CHROME_H + assert big_w > max_w + + text_w, text_h = mod._text_mcq_default_size( + laptop, prefs_w=520, prefs_h=480, question_len=40, n_options=3 + ) + assert text_w <= 900 and text_h <= 480 + + def main() -> None: test_hotkeys() test_window_geometry() test_pick_sizing_monitor_wh() - print("OK dialog ergonomics (hotkeys + window geometry)") + test_image_mcq_sizing_fits_laptop() + print("OK dialog ergonomics (hotkeys + window geometry + sizing)") if __name__ == "__main__": diff --git a/src/ask_question_mcp/gtk4_list_ask.py b/src/ask_question_mcp/gtk4_list_ask.py index e206f84..3b04c3e 100644 --- a/src/ask_question_mcp/gtk4_list_ask.py +++ b/src/ask_question_mcp/gtk4_list_ask.py @@ -131,6 +131,126 @@ def _monitor_wh_under_pointer( return None +# Image-MCQ layout budget (header + hint + question + options floor + footer). +# Preview height must leave this much room or Cancel/OK get crushed off-screen. +_IMAGE_MCQ_CHROME_H = 400 +# GdkWaylandMonitor has no get_workarea — reserve for GNOME top panel / margins. +_PANEL_RESERVE_H = 48 +_EDGE_MARGIN_W = 48 + + +def _usable_monitor_wh(monitor_wh: tuple[int, int]) -> tuple[int, int]: + """Usable px after edge/panel reserve (Wayland often lacks workarea).""" + w, h = int(monitor_wh[0]), int(monitor_wh[1]) + if w <= 0 or h <= 0: + return 1280, 800 + return max(320, w - _EDGE_MARGIN_W), max(280, h - _PANEL_RESERVE_H) + + +def _preview_max_wh( + monitor_wh: tuple[int, int], + *, + maximized: bool, + expanded: bool, +) -> tuple[int, int]: + """Return (max_w, max_h) for the image preview under current mode. + + Expanded defaults stay inside a chrome budget so preview + question + + options + footer fit the usable host/smallest monitor. Maximized uses + nearly the *host* panel (dialog monitor), not the opening clamp. + """ + raw_w, raw_h = int(monitor_wh[0]), int(monitor_wh[1]) + if raw_w <= 0 or raw_h <= 0: + raw_w, raw_h = 1280, 800 + if maximized: + budget_h = max(160, raw_h - _IMAGE_MCQ_CHROME_H) + budget_w = max(320, raw_w - _EDGE_MARGIN_W) + return budget_w, budget_h + uw, uh = _usable_monitor_wh((raw_w, raw_h)) + budget_h = max(160, uh - _IMAGE_MCQ_CHROME_H) + budget_w = max(320, uw - 32) + if expanded: + # ~50% of usable height, never past chrome budget (old 62% overflowed + # Framework eDP once header/options/footer were added). + return ( + max(320, min(int(uw * 0.90), budget_w)), + max(160, min(int(uh * 0.50), budget_h)), + ) + return min(640, budget_w), min(280, budget_h) + + +def _image_mcq_default_size( + monitor_wh: tuple[int, int], + *, + prefs_w: int = 520, + prefs_h: int = 480, +) -> tuple[int, int]: + """Default image-MCQ window size — fits usable host/smallest monitor.""" + uw, uh = _usable_monitor_wh(monitor_wh) + prev_w, prev_h = _preview_max_wh( + monitor_wh, maximized=False, expanded=True + ) + need_w = min(uw, prev_w + _EDGE_MARGIN_W) + need_h = min(uh, prev_h + _IMAGE_MCQ_CHROME_H) + # Soft target ~88×90% usable; never exceed usable; no absolute 720×700 + # floors that can outgrow a small panel. + geom_w = min(uw, max(int(prefs_w), int(uw * 0.88), need_w)) + geom_h = min(uh, max(int(prefs_h), int(uh * 0.90), need_h)) + return max(320, geom_w), max(280, geom_h) + + +def _text_mcq_default_size( + monitor_wh: tuple[int, int], + *, + prefs_w: int = 520, + prefs_h: int = 480, + question_len: int = 0, + n_options: int = 0, +) -> tuple[int, int]: + """Compact text-only MCQ size, clamped to usable monitor.""" + uw, uh = _usable_monitor_wh(monitor_wh) + geom_w = min(max(int(prefs_w), 420), min(900, uw)) + geom_h = min(max(int(prefs_h), 360), min(560, uh)) + if question_len < 200 and n_options <= 6: + geom_h = min(geom_h, 480, uh) + return max(320, geom_w), max(280, geom_h) + + +def _workarea_wh_for_window(win: object) -> tuple[int, int] | None: + """Return workarea (w, h) of the monitor currently hosting ``win``. + + Used when maximizing so the preview tracks the *host* panel (eDP vs 4K), + not the opening-time sizing monitor. + """ + try: + display = win.get_display() # type: ignore[attr-defined] + surface = win.get_surface() # type: ignore[attr-defined] + if display is None: + return None + mon = None + if surface is not None: + get_at = getattr(display, "get_monitor_at_surface", None) + if callable(get_at): + mon = get_at(surface) + if mon is None: + return None + geom = None + get_wa = getattr(mon, "get_workarea", None) + if callable(get_wa): + try: + geom = get_wa() + except Exception: # noqa: BLE001 + geom = None + if geom is None: + geom = mon.get_geometry() + w, h = int(geom.width), int(geom.height) + if w > 0 and h > 0: + return w, h + except Exception: # noqa: BLE001 + return None + return None + + def _display_size_px(display: object | None = None) -> tuple[int, int]: """Best-effort monitor size for image-MCQ window defaults (Gtk4). @@ -448,24 +568,21 @@ def _build_and_run(application: Adw.Application) -> None: pass scr_w, scr_h = _display_size_px(win.get_display()) if image_paths: - # Image MCQs open large (~72×78% of the monitor) so stills are - # readable; text-only stays compact below. Persisted prefs are - # capped on save, so they only seed a floor here. - geom_w = min( - max(geom_w, int(scr_w * 0.72), 720), - max(scr_w - 48, 720), - ) - geom_h = min( - max(geom_h, int(scr_h * 0.78), 700), - max(scr_h - 72, 700), + # Image MCQs open large but must fit usable host/smallest monitor + # (preview + chrome). Absolute 720×700 floors overflowed eDP. + geom_w, geom_h = _image_mcq_default_size( + (scr_w, scr_h), prefs_w=geom_w, prefs_h=geom_h ) else: # Never reopen at a near-fullscreen height left by a previous tall # Confirm dialog — that left a huge empty band under Cancel/OK. - geom_w = min(max(geom_w, 420), 900) - geom_h = min(max(geom_h, 360), 560) - if len(question) < 200 and len(ids) <= 6: - geom_h = min(geom_h, 480) + geom_w, geom_h = _text_mcq_default_size( + (scr_w, scr_h), + prefs_w=geom_w, + prefs_h=geom_h, + question_len=len(question), + n_options=len(ids), + ) win.set_default_size(geom_w, geom_h) win.set_modal(True) @@ -703,35 +820,24 @@ def on_replay(*_args: object) -> None: header_replay.connect("clicked", on_replay) header.pack_end(header_replay) - def toggle_window_maximize(*_args: object) -> None: - if win.is_maximized(): - win.unmaximize() - else: - win.maximize() - - if image_paths: - # Maximize / restore so large stills can use most of the screen. - max_btn = Gtk.Button() - max_btn.set_icon_name("window-maximize-symbolic") - max_btn.set_tooltip_text("Maximize / restore window (F)") - max_btn.set_focusable(False) - max_btn.add_css_class("flat") - max_btn.connect("clicked", toggle_window_maximize) - header.pack_end(max_btn) - root.append(header) - - # Preview scale state shared with click-toggle + keyboard (F = maximize). + # Preview / maximize state (image MCQs). Defined before the header + # maximize control so click handlers can reflow the still. preview_expanded = {"v": True} preview_pictures: list[tuple[str, Gtk.Picture]] = [] + soft_maximized = {"v": False} + restore_geom: dict[str, tuple[int, int] | None] = {"wh": None} + max_btn: Gtk.Button | None = None def _preview_limits() -> tuple[int, int]: - """Return (max_w, max_h) for the current compact/expanded mode.""" - if preview_expanded["v"]: - # Leave room for question + options + footer (~280px chrome). - max_h = max(420, min(int(scr_h * 0.62), scr_h - 280)) - max_w = max(720, min(int(scr_w * 0.85), scr_w - 80)) - return max_w, max_h - return 720, 320 + """Return (max_w, max_h) for compact / expanded / maximized mode.""" + host = None + if win.is_maximized() or soft_maximized["v"]: + host = _workarea_wh_for_window(win) + return _preview_max_wh( + host or (scr_w, scr_h), + maximized=bool(win.is_maximized() or soft_maximized["v"]), + expanded=bool(preview_expanded["v"]), + ) def _apply_preview_scale() -> None: max_w, max_h = _preview_limits() @@ -763,6 +869,89 @@ def _apply_preview_scale() -> None: ) picture.set_tooltip_text(tip) + def _sync_max_btn() -> None: + if max_btn is None: + return + on = bool(win.is_maximized() or soft_maximized["v"]) + max_btn.set_icon_name( + "window-restore-symbolic" if on else "window-maximize-symbolic" + ) + max_btn.set_tooltip_text( + "Restore window (F)" if on else "Maximize / restore window (F)" + ) + + def toggle_window_maximize(*_args: object) -> None: + """Maximize / restore on the host monitor and reflow the still. + + Compositor ``maximize()`` alone is easy to miss: image MCQs already + size-request near-full height on a laptop, and the preview never + reloaded larger. Soft-size to the host workarea when needed. + """ + if win.is_maximized() or soft_maximized["v"]: + soft_maximized["v"] = False + prev = restore_geom["wh"] + restore_geom["wh"] = None + # Shrink preview min-size *before* restore size — otherwise + # set_default_size cannot beat a maximized size_request. + _apply_preview_scale() + _sync_max_btn() + if win.is_maximized(): + win.unmaximize() + + def _restore_size() -> bool: + if prev is not None: + pw, ph = int(prev[0]), int(prev[1]) + if pw > 0 and ph > 0: + # Re-apply preview in case notify::maximized raced. + _apply_preview_scale() + win.set_default_size(pw, ph) + return False + + # Two passes: first after unmaximize, second after allocate. + GLib.timeout_add(50, _restore_size) + GLib.timeout_add(200, _restore_size) + return + restore_geom["wh"] = ( + max(1, int(win.get_width() or geom_w)), + max(1, int(win.get_height() or geom_h)), + ) + win.maximize() + soft_maximized["v"] = True + + def _after_maximize() -> bool: + host = _workarea_wh_for_window(win) or (scr_w, scr_h) + hw, hh = int(host[0]), int(host[1]) + cur_w = int(win.get_width() or 0) + cur_h = int(win.get_height() or 0) + # Compositor maximize sometimes only grows one axis (seen on + # dual-head Wayland). set_default_size is ignored while + # is_maximized — unmaximize then size to the host panel. + grew = cur_w >= int(hw * 0.92) and cur_h >= int(hh * 0.90) + if hw > 0 and hh > 0 and not grew: + if win.is_maximized(): + win.unmaximize() + win.set_default_size(hw, hh) + _apply_preview_scale() + _sync_max_btn() + return False + + GLib.timeout_add(50, _after_maximize) + + if image_paths: + # Maximize / restore so large stills can use most of the screen. + max_btn = Gtk.Button() + max_btn.set_icon_name("window-maximize-symbolic") + max_btn.set_tooltip_text("Maximize / restore window (F)") + max_btn.set_focusable(False) + max_btn.add_css_class("flat") + max_btn.connect("clicked", toggle_window_maximize) + header.pack_end(max_btn) + win.connect( + "notify::maximized", + lambda *_a: (_apply_preview_scale(), _sync_max_btn()), + ) + root.append(header) + def _append_image_previews(parent: Gtk.Box) -> None: """PNG/JPEG preview above the question; click toggles large/compact.""" if not image_paths: From 0197762a07e681da8d323e0d483b065fb464121c Mon Sep 17 00:00:00 2001 From: Alex J Lennon Date: Mon, 3 Aug 2026 02:43:46 +0100 Subject: [PATCH 3/4] fix(mcq): restore 1s OK/Enter arm for dangerous dialogs DEFAULT_DANGER_ARM_MS had stayed at 4000 on main/sizing after the 1s preference lived only on the unmerged primary-display branch; bring both arms back to 1000ms without touching sizing/maximize. Co-authored-by: Cursor --- README.md | 2 +- docs/AGENTS.md | 9 +++++---- docs/WINDOWS.md | 2 +- src/ask_question_mcp/danger_arm.py | 13 ++++++------- src/ask_question_mcp/gtk4_list_ask.py | 4 ++-- src/ask_question_mcp/server.py | 2 +- src/ask_question_mcp/win_list_ask.py | 2 +- 7 files changed, 17 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 6358f65..ceab523 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ Full env / prefs: [SETUP.md](SETUP.md). Never commit tokens. - Remembers last dialog size (`prefs.window`; position on Windows; size-only on typical Wayland) - Windows: scrollable option list -- Danger chrome; OK/Enter briefly armed (~1s / ~4s) +- Danger chrome; OK/Enter briefly armed (~1s) - Something else is always available (type, or Speak→STT when configured) - Works text-only without TTS/STT; lean JSON results by default - Optional TTS / mic answers / acks (auto-listen and acks **off** until opted in) diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 0770507..7feb791 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -70,7 +70,7 @@ skill via `ask-question-install --skill` (`~/.cursor/skills/ask-multiple-choice` | `recommended_ids` | string[] \| null | no | Multi-select preferred ids | | `allow_multiple` | bool | no | default `false` (radio); `true` = checklist | | `allow_other` | bool | no | **Ignored** — Something else is always appended when missing | -| `dangerous` | bool | no | Danger chrome; OK/Enter armed ~4s (`ASK_QUESTION_DANGER_ARM_MS`). Normal MCQs arm ~1s (`ASK_QUESTION_ARM_MS`). | +| `dangerous` | bool | no | Danger chrome; OK/Enter armed ~1s (`ASK_QUESTION_DANGER_ARM_MS`, same default as normal). Normal MCQs arm ~1s (`ASK_QUESTION_ARM_MS`). | | `speak` | bool | no | default `true` (honours mute env / missing TTS) | | `title` | string | no | default `"Decide"` — short noun phrase | | `agent` | string \| null | **strongly yes** | Window title prefix `[agent]` | @@ -151,9 +151,10 @@ Routine forks stay short (no referent dump): } ``` -OK and Enter stay locked briefly after open (countdown on OK): **~1s** normal -(`ASK_QUESTION_ARM_MS`), **~4s** when `dangerous` (`ASK_QUESTION_DANGER_ARM_MS`). -Set either env to `0` to disable. Cancel / Escape always work immediately. +OK and Enter stay locked briefly after open (countdown on OK): **~1s** for both +normal (`ASK_QUESTION_ARM_MS`) and `dangerous` (`ASK_QUESTION_DANGER_ARM_MS`). +(Dangerous used to be ~4s; shortened 2026-08-01.) Set either env to `0` to +disable. Cancel / Escape always work immediately. ## Dialog UX (humans) diff --git a/docs/WINDOWS.md b/docs/WINDOWS.md index 7e6b163..f5e43fb 100644 --- a/docs/WINDOWS.md +++ b/docs/WINDOWS.md @@ -26,7 +26,7 @@ Canonical install steps: this file (and a one-line pointer from the 9. Smoke **dangerous** — ask for an irreversible choice (`dangerous=true`). Expect: - Window title / options prefixed with **⛔** (no-entry) - Pink **Confirm** banner with the question - - Red **OK** that stays disabled ~4s (`OK (Ns)`) before confirm + - Red **OK** that stays disabled ~1s (`OK (Ns)`) before confirm 10. Resize the dialog, OK, reopen — size (and position) should roughly match. 11. When nudged for platform feedback: choose **works** (or open a GitHub issue) so maintainers can flip the README matrix row to **Verified**. diff --git a/src/ask_question_mcp/danger_arm.py b/src/ask_question_mcp/danger_arm.py index 1d0413b..af2a81c 100644 --- a/src/ask_question_mcp/danger_arm.py +++ b/src/ask_question_mcp/danger_arm.py @@ -4,9 +4,8 @@ previous keystroke (or mid-typing) cannot dismiss the dialog. - Normal MCQs: ``ASK_QUESTION_ARM_MS`` (default **1000**). Set ``0`` to disable. -- Dangerous (no-entry mark): ``ASK_QUESTION_DANGER_ARM_MS`` (default **4000**). - Set ``0`` to disable the danger-only longer arm (safe arm still applies - unless also 0). +- Dangerous (no-entry mark): ``ASK_QUESTION_DANGER_ARM_MS`` (default **1000**, + same as safe — Alex 2026-08-01; was 4000). Set ``0`` to disable. """ from __future__ import annotations @@ -17,7 +16,7 @@ DANGER_MARK = "⛔" DEFAULT_SAFE_ARM_MS = 1000 -DEFAULT_DANGER_ARM_MS = 4000 +DEFAULT_DANGER_ARM_MS = 1000 ENV_SAFE_ARM_MS = "ASK_QUESTION_ARM_MS" ENV_DANGER_ARM_MS = "ASK_QUESTION_DANGER_ARM_MS" _MAX_ARM_MS = 60_000 @@ -56,9 +55,9 @@ def _parse_arm_ms(env_name: str, default: int) -> int: def danger_arm_ms(*, dangerous: bool = True) -> int: """Milliseconds to block OK / Enter after the dialog opens. - Dangerous dialogs use the longer danger arm (default 4s). Normal dialogs - use the safe arm (default 1s) so accidental Return while typing does not - confirm. + Dangerous and normal dialogs both default to 1s so accidental Return + while typing does not confirm. Override with ``ASK_QUESTION_DANGER_ARM_MS`` + / ``ASK_QUESTION_ARM_MS`` if a longer danger arm is wanted. """ if dangerous: return _parse_arm_ms(ENV_DANGER_ARM_MS, DEFAULT_DANGER_ARM_MS) diff --git a/src/ask_question_mcp/gtk4_list_ask.py b/src/ask_question_mcp/gtk4_list_ask.py index 3b04c3e..13d642f 100644 --- a/src/ask_question_mcp/gtk4_list_ask.py +++ b/src/ask_question_mcp/gtk4_list_ask.py @@ -1411,11 +1411,11 @@ def on_always_listen_toggled(btn: Gtk.CheckButton) -> None: root.append(footer) win.set_content(root) - # Dangerous dialogs arm for a few seconds so a stray Return cannot OK. + # Arm briefly so a stray Return cannot OK (default 1s; see danger_arm). if _danger_arm is not None: arm_ms = int(_danger_arm.danger_arm_ms(dangerous=dangerous)) else: - arm_ms = 4000 if dangerous else 1000 + arm_ms = 1000 armed = {"v": arm_ms <= 0} def _arm_confirm() -> None: diff --git a/src/ask_question_mcp/server.py b/src/ask_question_mcp/server.py index c267bd3..6820820 100644 --- a/src/ask_question_mcp/server.py +++ b/src/ask_question_mcp/server.py @@ -65,7 +65,7 @@ def ask_multiple_choice( image: str | None = None, images: list[str] | None = None, ) -> str: - """Desktop MCQ for every decision fork — never markdown A/B/C when available. agent=LANE.id; recommended in label + recommended_id; Something else always; optional image/images (local path or file://) for Gtk preview; dangerous arms OK ~4s. On cancel/errors → check_setup once.""" + """Desktop MCQ for every decision fork — never markdown A/B/C when available. agent=LANE.id; recommended in label + recommended_id; Something else always; optional image/images (local path or file://) for Gtk preview; dangerous arms OK ~1s. On cancel/errors → check_setup once.""" try: result = ask_zenity( question, diff --git a/src/ask_question_mcp/win_list_ask.py b/src/ask_question_mcp/win_list_ask.py index 4bfc287..9f3c611 100644 --- a/src/ask_question_mcp/win_list_ask.py +++ b/src/ask_question_mcp/win_list_ask.py @@ -321,7 +321,7 @@ def on_cancel() -> None: if _danger_arm is not None: arm_ms = int(_danger_arm.danger_arm_ms(dangerous=show_danger)) else: - arm_ms = 4000 if show_danger else 1000 + arm_ms = 1000 armed = {"v": arm_ms <= 0} def _arm_confirm() -> None: From 3b29aad7f4d3b71e50a2611200ba1f6bec56d08f Mon Sep 17 00:00:00 2001 From: Alex J Lennon Date: Mon, 3 Aug 2026 02:56:59 +0100 Subject: [PATCH 4/4] fix(mcq): clamp multi-image dialogs to primary usable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked previews each took the full single-image height size_request, so 2–3 stills overflowed Framework eDP. Share one stack budget, scroll inside, and size against the OS primary panel (not pointer/largest). Co-authored-by: Cursor --- docs/AGENTS.md | 14 +- scripts/test_dialog_ergonomics.py | 78 ++++++++-- skills/ask-multiple-choice/SKILL.md | 10 +- src/ask_question_mcp/gtk4_list_ask.py | 201 ++++++++++++++++++++------ 4 files changed, 240 insertions(+), 63 deletions(-) diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 7feb791..10fd735 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -82,11 +82,15 @@ skill via `ask-question-install --skill` (`~/.cursor/skills/ask-multiple-choice` **Images in the dialog (Linux Gtk):** pass an absolute path or `file://` URI so Alex sees the still *inside* the MCQ (not only in chat). Chat `Read` of a PNG does not put pixels in the dialog — use `image` / `images`. When images are -present the window opens large (~70%+ of the monitor); click the preview to -toggle compact (~320px) vs large, and use the header maximize button or **F** -for a near-fullscreen window. Text-only MCQs stay compact. Windows Phase 1 -ignores these args (text-only). Pattern: `mcq-with-image` (signed-off — -agents **must** pass `image=`/`images=` when the human must judge a still). +present the window opens large on the **primary** usable workarea (not the +largest / secondary 4K); click the preview to toggle compact (~320px) vs large, +and use the header maximize button or **F** for a soft-fill on the host panel. +**Multi-image (`images=`, max 4): the whole stack must fit ≤ primary usable +resolution** — previews share one height budget and scroll inside; never open a +window taller/wider than the primary (or smaller host) display. Text-only MCQs +stay compact. Windows Phase 1 ignores these args (text-only). Pattern: +`mcq-with-image` (signed-off — agents **must** pass `image=`/`images=` when the +human must judge a still). ### Example (single choice) diff --git a/scripts/test_dialog_ergonomics.py b/scripts/test_dialog_ergonomics.py index 4cebb7d..6071d96 100644 --- a/scripts/test_dialog_ergonomics.py +++ b/scripts/test_dialog_ergonomics.py @@ -70,19 +70,23 @@ def test_window_geometry(tmp_path: Path | None = None) -> None: def test_pick_sizing_monitor_wh() -> None: - """Dual-monitor: defaults follow host or smallest panel, not 4K.""" + """Dual-monitor: defaults follow primary or smallest panel, not 4K.""" mod = _load_gtk4_list_ask() laptop = (1803, 1202) external = (3840, 2160) assert mod._pick_sizing_monitor_wh([laptop, external]) == laptop assert mod._pick_sizing_monitor_wh([external, laptop]) == laptop + # Explicit preferred = primary (even if not the smallest). + assert mod._pick_sizing_monitor_wh( + [laptop, external], preferred=laptop + ) == laptop assert mod._pick_sizing_monitor_wh( [laptop, external], preferred=external ) == external assert mod._pick_sizing_monitor_wh([], preferred=None) == (1280, 800) - # Pointer on eDP vs DP-2 (Alex Framework + Samsung layout). + # Pointer helper still works for diagnostics; sizing no longer uses it. rects = [(0, 1386, 1803, 1202), (1803, 0, 3840, 2160)] import subprocess as sp @@ -121,18 +125,27 @@ def test_image_mcq_sizing_fits_laptop() -> None: assert uw == 1803 - mod._EDGE_MARGIN_W assert uh == 1202 - mod._PANEL_RESERVE_H - exp_w, exp_h = mod._preview_max_wh(laptop, maximized=False, expanded=True) - assert exp_h + mod._IMAGE_MCQ_CHROME_H <= uh - assert exp_w <= uw - assert exp_h < int(uh * 0.55) # ~50% usable, not old 62% raw + stack_w, stack_h = mod._preview_stack_max_wh( + laptop, maximized=False, expanded=True + ) + assert stack_h + mod._IMAGE_MCQ_CHROME_H <= uh + assert stack_w <= uw + assert stack_h < int(uh * 0.55) # ~50% usable, not old 62% raw + + exp_w, exp_h = mod._preview_max_wh( + laptop, maximized=False, expanded=True, n_images=1 + ) + assert exp_w == stack_w and exp_h == stack_h - compact = mod._preview_max_wh(laptop, maximized=False, expanded=False) + compact = mod._preview_max_wh( + laptop, maximized=False, expanded=False, n_images=1 + ) assert compact[1] <= 280 assert compact[0] <= 640 geom_w, geom_h = mod._image_mcq_default_size(laptop) assert geom_w <= uw and geom_h <= uh - assert geom_h >= exp_h + mod._IMAGE_MCQ_CHROME_H - 1 + assert geom_h >= stack_h + mod._IMAGE_MCQ_CHROME_H - 1 # Must not use absolute floors that exceed a small panel. tiny = (800, 600) tw, th = mod._image_mcq_default_size(tiny) @@ -144,12 +157,16 @@ def test_image_mcq_sizing_fits_laptop() -> None: assert g4k[0] > geom_w # larger host → larger default # But pick_sizing prefers laptop when both present (covered elsewhere). - max_w, max_h = mod._preview_max_wh(laptop, maximized=True, expanded=True) + max_w, max_h = mod._preview_max_wh( + laptop, maximized=True, expanded=True, n_images=1 + ) assert max_w > exp_w and max_h > exp_h assert max_h == 1202 - mod._IMAGE_MCQ_CHROME_H # Maximize on host 4K while defaults were laptop-sized. - big_w, big_h = mod._preview_max_wh(external, maximized=True, expanded=True) + big_w, big_h = mod._preview_max_wh( + external, maximized=True, expanded=True, n_images=1 + ) assert big_w == 3840 - mod._EDGE_MARGIN_W assert big_h == 2160 - mod._IMAGE_MCQ_CHROME_H assert big_w > max_w @@ -160,11 +177,52 @@ def test_image_mcq_sizing_fits_laptop() -> None: assert text_w <= 900 and text_h <= 480 +def test_multi_image_stack_fits_primary() -> None: + """Fake primary 1803×1202 + 2–3 large stills → window/stack ≤ usable. + + Regression: each preview used the full single-image height size_request, + so N stacked images summed past the Framework eDP primary. + """ + mod = _load_gtk4_list_ask() + primary = (1803, 1202) + uw, uh = mod._usable_monitor_wh(primary) + stack_w, stack_h = mod._preview_stack_max_wh( + primary, maximized=False, expanded=True + ) + + for n in (2, 3, 4): + per_w, per_h = mod._preview_max_wh( + primary, maximized=False, expanded=True, n_images=n + ) + assert per_w <= stack_w <= uw + req_h = mod._multi_image_stack_request_h( + primary, maximized=False, expanded=True, n_images=n + ) + assert req_h <= stack_h + assert per_h * n + mod._IMAGE_STACK_GAP * (n - 1) == req_h + # Old bug: N × ~50% usable ≫ primary height. + assert n * per_h < uh + geom_w, geom_h = mod._image_mcq_default_size(primary, n_images=n) + assert geom_w <= uw and geom_h <= uh + assert geom_h <= uh + + # Compact multi-image also divides — 3×280 must not win. + c_req = mod._multi_image_stack_request_h( + primary, maximized=False, expanded=False, n_images=3 + ) + _cw, c_stack = mod._preview_stack_max_wh( + primary, maximized=False, expanded=False + ) + assert c_req <= c_stack <= 280 + assert c_req + mod._IMAGE_MCQ_CHROME_H <= uh + + def main() -> None: test_hotkeys() test_window_geometry() test_pick_sizing_monitor_wh() test_image_mcq_sizing_fits_laptop() + test_multi_image_stack_fits_primary() print("OK dialog ergonomics (hotkeys + window geometry + sizing)") diff --git a/skills/ask-multiple-choice/SKILL.md b/skills/ask-multiple-choice/SKILL.md index c1982c2..efeab47 100644 --- a/skills/ask-multiple-choice/SKILL.md +++ b/skills/ask-multiple-choice/SKILL.md @@ -34,10 +34,12 @@ choose among options the human must decide. 7. **Images the human must judge** (Alex loves this — signed-off 2026-08-03): pass **`image=`** (one path / `file://` URI) or **`images=`** (list, max 4). Chat `Read` of a PNG does **not** put pixels in the MCQ — agents **must** - pass the path into the dialog. Linux Gtk: opens large (~70%+ monitor); - human can **click the preview** (large ↔ compact ~320px) and **maximize** - (header button or **F**). Text-only MCQs stay compact. Pattern: - `mcq-with-image`. + pass the path into the dialog. Linux Gtk: opens large on the **primary** + usable workarea (not the largest secondary); human can **click the preview** + (large ↔ compact ~320px) and **maximize** (header button or **F**). + **P0 — multi-image must never exceed primary usable resolution** (stack + shares one height budget / scrolls inside). Text-only MCQs stay compact. + Pattern: `mcq-with-image`. 8. Wait for the JSON result. On cancel → stop. On freeform → use **`freeform_text`**. Humans use the dialog keyboard (**1–8**, Enter, Esc; **F** maximize when images); diff --git a/src/ask_question_mcp/gtk4_list_ask.py b/src/ask_question_mcp/gtk4_list_ask.py index 13d642f..b84850d 100644 --- a/src/ask_question_mcp/gtk4_list_ask.py +++ b/src/ask_question_mcp/gtk4_list_ask.py @@ -77,9 +77,10 @@ def _pick_sizing_monitor_wh( ) -> tuple[int, int]: """Pick width/height for dialog defaults / clamps. - Dual-monitor: size against the host monitor when known, otherwise the - *smallest* connected display — never the largest (a 4K panel must not - drive defaults that overflow a laptop eDP). + Dual-monitor: size against the *primary* (or explicit preferred) panel + when known, otherwise the *smallest* connected display — never the + largest (a 4K secondary must not drive defaults that overflow a laptop + eDP primary). """ if preferred is not None: pw, ph = int(preferred[0]), int(preferred[1]) @@ -96,8 +97,9 @@ def _monitor_wh_under_pointer( ) -> tuple[int, int] | None: """Return (w, h) of the monitor containing the pointer, if known. - ``rects`` are ``(x, y, w, h)`` in the same coordinate space as the pointer - query (X11 / XWayland via ``xdotool`` when available). + Kept for diagnostics / tests. Opening image-MCQ geometry prefers the OS + *primary* panel (not pointer) so a cursor parked on a 4K secondary cannot + oversized the dialog past the laptop eDP. """ if not rects: return None @@ -137,6 +139,9 @@ def _monitor_wh_under_pointer( # GdkWaylandMonitor has no get_workarea — reserve for GNOME top panel / margins. _PANEL_RESERVE_H = 48 _EDGE_MARGIN_W = 48 +# Vertical gap between stacked multi-image frames (matches Gtk.Box spacing=8). +_IMAGE_STACK_GAP = 8 +_IMAGE_HINT_RESERVE_H = 28 def _usable_monitor_wh(monitor_wh: tuple[int, int]) -> tuple[int, int]: @@ -147,17 +152,25 @@ def _usable_monitor_wh(monitor_wh: tuple[int, int]) -> tuple[int, int]: return max(320, w - _EDGE_MARGIN_W), max(280, h - _PANEL_RESERVE_H) -def _preview_max_wh( +def _clamp_n_images(n_images: int) -> int: + """Normalize preview count (MCQ allows at most 4).""" + try: + n = int(n_images) + except (TypeError, ValueError): + n = 1 + return max(1, min(4, n)) + + +def _preview_stack_max_wh( monitor_wh: tuple[int, int], *, maximized: bool, expanded: bool, ) -> tuple[int, int]: - """Return (max_w, max_h) for the image preview under current mode. + """Total (max_w, max_h) for the entire preview stack (all images together). - Expanded defaults stay inside a chrome budget so preview + question + - options + footer fit the usable host/smallest monitor. Maximized uses - nearly the *host* panel (dialog monitor), not the opening clamp. + Multi-image MCQs must share this budget — never give each still the full + single-image height (that summed past primary usable and blew the window). """ raw_w, raw_h = int(monitor_wh[0]), int(monitor_wh[1]) if raw_w <= 0 or raw_h <= 0: @@ -170,8 +183,8 @@ def _preview_max_wh( budget_h = max(160, uh - _IMAGE_MCQ_CHROME_H) budget_w = max(320, uw - 32) if expanded: - # ~50% of usable height, never past chrome budget (old 62% overflowed - # Framework eDP once header/options/footer were added). + # ~50% of usable height for the whole stack, never past chrome budget + # (old 62% overflowed Framework eDP once header/options/footer added). return ( max(320, min(int(uw * 0.90), budget_w)), max(160, min(int(uh * 0.50), budget_h)), @@ -179,19 +192,61 @@ def _preview_max_wh( return min(640, budget_w), min(280, budget_h) +def _preview_max_wh( + monitor_wh: tuple[int, int], + *, + maximized: bool, + expanded: bool, + n_images: int = 1, +) -> tuple[int, int]: + """Return (max_w, max_h) for *one* image under current mode. + + ``n_images`` > 1 divides the stack budget so N vertical ``size_request`` + mins cannot sum past primary usable height. + """ + stack_w, stack_h = _preview_stack_max_wh( + monitor_wh, maximized=maximized, expanded=expanded + ) + n = _clamp_n_images(n_images) + gap = _IMAGE_STACK_GAP * (n - 1) + per_h = max(64, (int(stack_h) - gap) // n) + return int(stack_w), int(per_h) + + +def _multi_image_stack_request_h( + monitor_wh: tuple[int, int], + *, + maximized: bool, + expanded: bool, + n_images: int, +) -> int: + """Sum of per-image size_request heights (+ gaps) for N stacked previews.""" + n = _clamp_n_images(n_images) + _pw, per_h = _preview_max_wh( + monitor_wh, + maximized=maximized, + expanded=expanded, + n_images=n, + ) + return int(per_h) * n + _IMAGE_STACK_GAP * (n - 1) + + def _image_mcq_default_size( monitor_wh: tuple[int, int], *, prefs_w: int = 520, prefs_h: int = 480, + n_images: int = 1, ) -> tuple[int, int]: - """Default image-MCQ window size — fits usable host/smallest monitor.""" + """Default image-MCQ window size — fits usable *primary* monitor.""" uw, uh = _usable_monitor_wh(monitor_wh) - prev_w, prev_h = _preview_max_wh( + prev_w, stack_h = _preview_stack_max_wh( monitor_wh, maximized=False, expanded=True ) + # Multi-image still uses the same stack budget (not N× single height). + _ = _clamp_n_images(n_images) need_w = min(uw, prev_w + _EDGE_MARGIN_W) - need_h = min(uh, prev_h + _IMAGE_MCQ_CHROME_H) + need_h = min(uh, stack_h + _IMAGE_MCQ_CHROME_H) # Soft target ~88×90% usable; never exceed usable; no absolute 720×700 # floors that can outgrow a small panel. geom_w = min(uw, max(int(prefs_w), int(uw * 0.88), need_w)) @@ -254,11 +309,12 @@ def _workarea_wh_for_window(win: object) -> tuple[int, int] | None: def _display_size_px(display: object | None = None) -> tuple[int, int]: """Best-effort monitor size for image-MCQ window defaults (Gtk4). - Prefer the monitor under the pointer; else the smallest connected output. - Maximize remains compositor-local to whichever monitor hosts the window. + Prefer the OS *primary* panel (Framework eDP when marked primary), else the + smallest connected output — never the largest / never pointer-on-4K. + Soft-fill maximize stays within the dialog host's usable workarea. """ sizes: list[tuple[int, int]] = [] - rects: list[tuple[int, int, int, int]] = [] + primary_wh: tuple[int, int] | None = None try: import gi @@ -287,14 +343,22 @@ def _display_size_px(display: object | None = None) -> tuple[int, int]: w, h = int(geom.width), int(geom.height) if w > 0 and h > 0: sizes.append((w, h)) - rects.append((int(geom.x), int(geom.y), w, h)) + is_pri = getattr(mon, "is_primary", None) + if primary_wh is None and callable(is_pri): + try: + if bool(is_pri()): + primary_wh = (w, h) + except Exception: # noqa: BLE001 + pass except Exception: # noqa: BLE001 pass + if sizes and primary_wh is not None: + return _pick_sizing_monitor_wh(sizes, preferred=primary_wh) if sizes: - preferred = _monitor_wh_under_pointer(rects) - return _pick_sizing_monitor_wh(sizes, preferred=preferred) - # Gdk sometimes has no monitors early; prefer a single connected output - # (xrandr) over xdpyinfo's combined virtual desktop on multi-head. + # Gdk often lacks is_primary on Wayland — fall through to xrandr + # primary, then smallest among Gdk sizes. + pass + # Gdk sometimes has no monitors early; also used to learn xrandr primary. try: import re @@ -306,23 +370,28 @@ def _display_size_px(display: object | None = None) -> tuple[int, int]: stderr=subprocess.DEVNULL, ) x_sizes: list[tuple[int, int]] = [] - x_rects: list[tuple[int, int, int, int]] = [] + x_primary: tuple[int, int] | None = None for line in out.splitlines(): - # e.g. "DP-2 connected primary 3840x2160+1803+0 ..." + # e.g. "eDP-1 connected primary 1803x1202+0+1386 ..." + # "DP-2 connected 3840x2160+1803+0 ..." m = re.search( - r" connected(?: primary)? (\d+)x(\d+)\+(\d+)\+(\d+)", + r" connected( primary)? (\d+)x(\d+)\+(\d+)\+(\d+)", line, ) if not m: continue - w, h = int(m.group(1)), int(m.group(2)) + w, h = int(m.group(2)), int(m.group(3)) x_sizes.append((w, h)) - x_rects.append((int(m.group(3)), int(m.group(4)), w, h)) - if x_sizes: - preferred = _monitor_wh_under_pointer(x_rects) - return _pick_sizing_monitor_wh(x_sizes, preferred=preferred) + if m.group(1) is not None: + x_primary = (w, h) + preferred = primary_wh or x_primary + pool = sizes or x_sizes + if pool: + return _pick_sizing_monitor_wh(pool, preferred=preferred) except Exception: # noqa: BLE001 pass + if sizes: + return _pick_sizing_monitor_wh(sizes, preferred=primary_wh) return 1280, 800 @@ -568,10 +637,14 @@ def _build_and_run(application: Adw.Application) -> None: pass scr_w, scr_h = _display_size_px(win.get_display()) if image_paths: - # Image MCQs open large but must fit usable host/smallest monitor - # (preview + chrome). Absolute 720×700 floors overflowed eDP. + # Image MCQs open large but must fit usable *primary* monitor + # (preview stack + chrome). Absolute 720×700 floors overflowed eDP; + # multi-image must share one stack budget (not N× single height). geom_w, geom_h = _image_mcq_default_size( - (scr_w, scr_h), prefs_w=geom_w, prefs_h=geom_h + (scr_w, scr_h), + prefs_w=geom_w, + prefs_h=geom_h, + n_images=len(image_paths), ) else: # Never reopen at a near-fullscreen height left by a previous tall @@ -829,7 +902,7 @@ def on_replay(*_args: object) -> None: max_btn: Gtk.Button | None = None def _preview_limits() -> tuple[int, int]: - """Return (max_w, max_h) for compact / expanded / maximized mode.""" + """Return per-image (max_w, max_h) for compact/expanded/maximized.""" host = None if win.is_maximized() or soft_maximized["v"]: host = _workarea_wh_for_window(win) @@ -837,6 +910,18 @@ def _preview_limits() -> tuple[int, int]: host or (scr_w, scr_h), maximized=bool(win.is_maximized() or soft_maximized["v"]), expanded=bool(preview_expanded["v"]), + n_images=max(1, len(image_paths)), + ) + + def _stack_budget() -> tuple[int, int]: + """Total preview-stack (max_w, max_h) — never N× per-image.""" + host = None + if win.is_maximized() or soft_maximized["v"]: + host = _workarea_wh_for_window(win) + return _preview_stack_max_wh( + host or (scr_w, scr_h), + maximized=bool(win.is_maximized() or soft_maximized["v"]), + expanded=bool(preview_expanded["v"]), ) def _apply_preview_scale() -> None: @@ -920,17 +1005,19 @@ def _restore_size() -> bool: def _after_maximize() -> bool: host = _workarea_wh_for_window(win) or (scr_w, scr_h) - hw, hh = int(host[0]), int(host[1]) + # Soft-fill only within usable host workarea (primary/eDP when + # that is where the dialog lives) — never a raw 4K geometry. + uw, uh = _usable_monitor_wh((int(host[0]), int(host[1]))) cur_w = int(win.get_width() or 0) cur_h = int(win.get_height() or 0) # Compositor maximize sometimes only grows one axis (seen on # dual-head Wayland). set_default_size is ignored while - # is_maximized — unmaximize then size to the host panel. - grew = cur_w >= int(hw * 0.92) and cur_h >= int(hh * 0.90) - if hw > 0 and hh > 0 and not grew: + # is_maximized — unmaximize then size to usable host. + grew = cur_w >= int(uw * 0.92) and cur_h >= int(uh * 0.90) + if uw > 0 and uh > 0 and not grew: if win.is_maximized(): win.unmaximize() - win.set_default_size(hw, hh) + win.set_default_size(uw, uh) _apply_preview_scale() _sync_max_btn() return False @@ -953,11 +1040,19 @@ def _after_maximize() -> bool: root.append(header) def _append_image_previews(parent: Gtk.Box) -> None: - """PNG/JPEG preview above the question; click toggles large/compact.""" + """PNG/JPEG preview above the question; click toggles large/compact. + + Multi-image: each still shares the stack height budget and the + stack lives in a scrolled viewport so size_request mins cannot + force a window taller than primary usable. + """ if not image_paths: return max_w, max_h = _preview_limits() - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + stack_w, stack_h = _stack_budget() + box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, spacing=_IMAGE_STACK_GAP + ) box.set_margin_top(8) box.set_margin_start(16) box.set_margin_end(16) @@ -995,6 +1090,7 @@ def _append_image_previews(parent: Gtk.Box) -> None: except AttributeError: pass picture.set_halign(Gtk.Align.CENTER) + # Cap each still; N× this height stays ≤ stack_h (+ gaps). picture.set_size_request( -1, min(max_h, int(pixbuf.get_height())) ) @@ -1021,8 +1117,25 @@ def _on_preview_click( frame.set_child(picture) box.append(frame) shown += 1 - if shown: - parent.append(box) + if not shown: + return + # Viewport capped to stack budget (+ hint). ScrolledWindow keeps + # child size_request mins from forcing the outer window past + # primary usable; per-image heights already share stack_h. + max_scroll_h = int(stack_h) + _IMAGE_HINT_RESERVE_H + scroll = Gtk.ScrolledWindow() + scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scroll.set_vexpand(False) + scroll.set_hexpand(True) + scroll.set_propagate_natural_height(True) + try: + scroll.set_max_content_height(max_scroll_h) + except AttributeError: + # Older Gtk: hard-cap only when multiple stills would stack tall. + if shown > 1: + scroll.set_size_request(min(int(stack_w), max_w), max_scroll_h) + scroll.set_child(box) + parent.append(scroll) _append_image_previews(root)