diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index 68d3b4c..d4b70a2 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -2697,7 +2697,15 @@ class SettingsDialog(Gtk.Dialog): for page in self._pages: self.sidebar_listbox.add(self._build_sidebar_row(page)) self.sidebar_listbox.connect("row-selected", self._on_sidebar_row_selected) - sidebar_box.pack_start(self.sidebar_listbox, True, True, 0) + # Same treatment as the pages: without a ScrolledWindow the category + # list's natural height pins the dialog's minimum height, so the + # window can never shrink past the list and new categories clip. + self.sidebar_scroller = Gtk.ScrolledWindow() + self.sidebar_scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + self.sidebar_scroller.set_shadow_type(Gtk.ShadowType.NONE) + self.sidebar_scroller.add(self.sidebar_listbox) + sidebar_scroller = self.sidebar_scroller + sidebar_box.pack_start(sidebar_scroller, True, True, 0) main_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) main_box.pack_start(sidebar_box, False, False, 0) @@ -2749,6 +2757,7 @@ class SettingsDialog(Gtk.Dialog): self.navigate_to_page(self._initial_page) else: self.sidebar_listbox.select_row(self.sidebar_listbox.get_row_at_index(0)) + self._scroll_sidebar_selection_into_view() # Restore the saved mode and point the simple questions at the live model # before the first visibility pass, so nothing flashes the wrong group. @@ -2773,11 +2782,50 @@ class SettingsDialog(Gtk.Dialog): # Navigation: sidebar, stack, and settings search # ------------------------------------------------------------------ + def _scroll_sidebar_selection_into_view(self) -> None: + """Bring the selected sidebar row into the scroller's viewport.""" + row = self.sidebar_listbox.get_selected_row() + if row is None: + return + + def scroll_to_row() -> bool: + allocation = row.get_allocation() + if allocation.height <= 0: + return True # not laid out yet — try again + adjustment = self.sidebar_scroller.get_vadjustment() + page_size = adjustment.get_page_size() + value = adjustment.get_value() + if allocation.y < value: + adjustment.set_value(max(adjustment.get_lower(), allocation.y)) + elif allocation.y + allocation.height > value + page_size: + adjustment.set_value( + min( + adjustment.get_upper() - page_size, + allocation.y + allocation.height - page_size, + ) + ) + return False + + # Allocations exist only once the row is mapped, which may be after + # one idle turn; retry briefly instead of leaving the row hidden. + # A new selection aborts the retry for the previously chosen row. + retries_left = 20 + + def scroll_once() -> bool: + nonlocal retries_left + retries_left -= 1 + if self.sidebar_listbox.get_selected_row() is not row: + return False + return scroll_to_row() and retries_left > 0 + + GLib.idle_add(scroll_once) + def navigate_to_page(self, page_name: str) -> bool: """Select a settings page by its internal name (e.g. ``about``).""" for page in self._pages: if page.name == page_name and page.sidebar_row is not None: self.sidebar_listbox.select_row(page.sidebar_row) + self._scroll_sidebar_selection_into_view() return True return False @@ -2932,6 +2980,7 @@ class SettingsDialog(Gtk.Dialog): self._pages[0], ) self.sidebar_listbox.select_row(page.sidebar_row) + self._scroll_sidebar_selection_into_view() self.settings_stack.set_visible_child_name(page.name) self._search_previous_page = None @@ -2996,6 +3045,7 @@ class SettingsDialog(Gtk.Dialog): if first_match_page is not None: self.sidebar_listbox.select_row(first_match_page.sidebar_row) + self._scroll_sidebar_selection_into_view() self.settings_stack.set_visible_child_name(first_match_page.name) else: self.sidebar_listbox.unselect_all() diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index db66da7..ccf027e 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -751,9 +751,7 @@ class TestSettingsDialogHelperFunctions(unittest.TestCase): def test_faster_whisper_recommendation_uses_language_for_english_variant(self): """English language nudges Faster Whisper recommendations to .en variants.""" - from vocalinux.ui.settings_dialog import ( - _recommended_faster_whisper_variant_for_language, - ) + from vocalinux.ui.settings_dialog import _recommended_faster_whisper_variant_for_language self.assertEqual( _recommended_faster_whisper_variant_for_language( @@ -1162,6 +1160,20 @@ class TestSettingsNavigation(unittest.TestCase): self.assertIn("self.search_entry.grab_focus()", body) self.assertIn('"escape"', body) + def test_sidebar_category_list_is_scrollable(self) -> None: + """The category list sits in a ScrolledWindow so the dialog can shrink + vertically without clipping categories (#678).""" + class_body = self.source_code.split("class SettingsDialog(Gtk.Dialog):")[1] + init_body = class_body.split("def __init__")[1].split("\n def ")[0] + self.assertIn("sidebar_scroller = Gtk.ScrolledWindow()", init_body) + self.assertIn( + "sidebar_scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)", + init_body, + ) + self.assertIn("sidebar_scroller.set_shadow_type(Gtk.ShadowType.NONE)", init_body) + self.assertIn("sidebar_scroller.add(self.sidebar_listbox)", init_body) + self.assertIn("sidebar_box.pack_start(sidebar_scroller", init_body) + class TestAboutPage(unittest.TestCase): """About page: this app, VocaHQ family, talk-to-us. Updater stays."""