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