feat(settings): make the settings category list scrollable (#886)

* feat(settings): make the settings category list scrollable

Wrap the sidebar category ListBox in a Gtk.ScrolledWindow (matching the
page wrappers) so the dialog can shrink vertically without clipping
categories and new pages do not pin the minimum window height.

Fixes #678

* fix(settings): reveal the selected sidebar row in the scroller

* fix(settings): retry sidebar scroll until the selected row is laid out

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
This commit is contained in:
devin-ai-integration[bot]
2026-09-29 22:59:58 -07:00
committed by GitHub
co-authored by Jatin K Malik
parent ff108595fd
commit 9e6a03cd20
2 changed files with 66 additions and 4 deletions
+51 -1
View File
@@ -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()
+15 -3
View File
@@ -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."""