From 984a3fb2cdebdb047465b08624427e92b866437c Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Thu, 3 Sep 2026 11:35:35 +0000 Subject: [PATCH] fix: stop sketcher status bar from resizing the window (#385) The sketcher status bar rebuilds its shortcut entries whenever tool state changes, which happens continuously while dragging elements. Because the entries differ in width between states, the bar's minimum width - and with it the main window's minimum size - changed during drags. GTK enforces the minimum window size, so on macOS the window visibly resized, and the sketcher side panel followed because the Paned divider gets clamped on every window resize. Host the shortcut row in a scrolled window with an EXTERNAL horizontal policy. The bar no longer propagates the content width into the window's size request; if it is too narrow, entries are clipped instead of resizing the window. Height still follows the content. --- rayforge/ui_gtk/shared/status_bar.py | 36 +++++++++-- tests/ui_gtk/shared/test_status_bar.py | 90 ++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 7 deletions(-) create mode 100644 tests/ui_gtk/shared/test_status_bar.py diff --git a/rayforge/ui_gtk/shared/status_bar.py b/rayforge/ui_gtk/shared/status_bar.py index ce746757b..fafe0d0c0 100644 --- a/rayforge/ui_gtk/shared/status_bar.py +++ b/rayforge/ui_gtk/shared/status_bar.py @@ -4,10 +4,32 @@ class StatusBar(Gtk.Box): + """ + Displays context sensitive shortcut hints in a single row. + + Entries are added and removed whenever tool state changes, so their + combined width varies. The row is therefore hosted in a scrolled + window with an EXTERNAL horizontal policy: the bar never propagates + the content width into the window's size request. Otherwise such + state changes would resize the entire window while dragging in the + sketcher (observed on macOS, see issue #385). If the bar is too + narrow, entries are clipped instead of widening the window. + """ + def __init__(self, **kwargs): - super().__init__(orientation=Gtk.Orientation.HORIZONTAL, **kwargs) + super().__init__(orientation=Gtk.Orientation.VERTICAL, **kwargs) self.add_css_class("status-bar") - self.set_spacing(24) + + self._content = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, spacing=24 + ) + self._scroller = Gtk.ScrolledWindow() + self._scroller.set_policy( + Gtk.PolicyType.EXTERNAL, Gtk.PolicyType.NEVER + ) + self._scroller.set_child(self._content) + self._scroller.set_hexpand(True) + self.append(self._scroller) def add_shortcut_entry( self, @@ -19,7 +41,7 @@ def add_shortcut_entry( shortcut = Shortcut( keys=keys, description=description, separator=separator ) - self.append(shortcut) + self._content.append(shortcut) def add_separator(self): """Add a visual separator between shortcuts.""" @@ -27,11 +49,11 @@ def add_separator(self): separator.set_size_request(1, 16) separator.add_css_class("separator") separator.get_style_context().add_class("separator") - self.append(separator) + self._content.append(separator) def clear(self): """Remove all shortcuts from the status bar.""" - child = self.get_first_child() + child = self._content.get_first_child() while child is not None: - self.remove(child) - child = self.get_first_child() + self._content.remove(child) + child = self._content.get_first_child() diff --git a/tests/ui_gtk/shared/test_status_bar.py b/tests/ui_gtk/shared/test_status_bar.py new file mode 100644 index 000000000..e9449e5df --- /dev/null +++ b/tests/ui_gtk/shared/test_status_bar.py @@ -0,0 +1,90 @@ +# flake8: noqa: E402 +"""UI tests for the shared StatusBar widget.""" + +import gi + +gi.require_version("Gtk", "4.0") + +from gi.repository import Gtk + +import pytest + +from rayforge.ui_gtk.shared.status_bar import StatusBar + + +def _measure_width(widget: Gtk.Widget) -> int: + minimum, _, _, _ = widget.measure(Gtk.Orientation.HORIZONTAL, -1) + return minimum + + +def _measure_height(widget: Gtk.Widget) -> int: + minimum, _, _, _ = widget.measure(Gtk.Orientation.VERTICAL, -1) + return minimum + + +def _count_children(box: Gtk.Box) -> int: + count = 0 + child = box.get_first_child() + while child is not None: + count += 1 + child = child.get_next_sibling() + return count + + +@pytest.mark.ui +def test_entries_are_added_to_content_row(): + bar = StatusBar() + bar.add_shortcut_entry(["Space"], "Pan", separator="") + bar.add_shortcut_entry(["Shift", "Tab"], "Constrain to Axis") + assert _count_children(bar._content) == 2 + + +@pytest.mark.ui +def test_clear_removes_all_entries(): + bar = StatusBar() + bar.add_shortcut_entry(["Space"], "Pan", separator="") + bar.add_separator() + bar.add_shortcut_entry(["Shift"], "Constrain to Axis") + assert _count_children(bar._content) == 3 + + bar.clear() + assert _count_children(bar._content) == 0 + + +@pytest.mark.ui +def test_min_width_does_not_follow_content(): + """ + The status bar must not propagate the width of its shortcut entries + into the window's size request. Entry changes during sketch drags + would otherwise resize the whole window (issue #385). + """ + bar = StatusBar() + empty_width = _measure_width(bar) + + bar.add_shortcut_entry(["Space"], "Pan", separator="") + idle_width = _measure_width(bar) + + bar.add_shortcut_entry( + ["Shift", "Doubleclick"], + "Select Connected with a very long description", + ) + bar.add_shortcut_entry( + ["Ctrl", "Shift", "Alt", "Super"], + "Yet another very long shortcut description", + ) + assert _count_children(bar._content) == 3 + assert idle_width == empty_width + assert _measure_width(bar) == idle_width + + +@pytest.mark.ui +def test_height_follows_content(): + bar = StatusBar() + empty_height = _measure_height(bar) + + bar.add_shortcut_entry(["Space"], "Pan", separator="") + idle_height = _measure_height(bar) + assert idle_height > empty_height + + bar.clear() + assert _measure_height(bar) == empty_height