From 32fc1ff4706d6186e9e6ad6371355ad505b87716 Mon Sep 17 00:00:00 2001 From: vlad ciotescu Date: Tue, 28 Apr 2026 20:33:11 +0300 Subject: [PATCH 1/3] Improve macOS login tree accessibility --- .../desktop/ui/enhance_wx/tree_selection.py | 143 ++++++++++++++++++ clients/desktop/ui/login_dialog.py | 1 + 2 files changed, 144 insertions(+) diff --git a/clients/desktop/ui/enhance_wx/tree_selection.py b/clients/desktop/ui/enhance_wx/tree_selection.py index 83fecf2d..3d3bb205 100644 --- a/clients/desktop/ui/enhance_wx/tree_selection.py +++ b/clients/desktop/ui/enhance_wx/tree_selection.py @@ -15,12 +15,16 @@ from __future__ import annotations +import sys from typing import Any import wx from .list_selection import FocusAfterDelete +if sys.platform == "darwin": + import wx.dataview as dv + # ============================================================================= # MIXIN @@ -106,3 +110,142 @@ class ManagedTreeCtrl(TreeSelectionManagerMixin, wx.TreeCtrl): """A wx.TreeCtrl with automatic selection management.""" pass + + +if sys.platform == "darwin": + + class ManagedTreeCtrl(dv.TreeListCtrl): + """A macOS-friendly tree control backed by DataView for VoiceOver.""" + + def __init__( + self, + parent: wx.Window, + id: int = wx.ID_ANY, + pos: wx.Point = wx.DefaultPosition, + size: wx.Size = wx.DefaultSize, + style: int = 0, + focus_after_delete: FocusAfterDelete = FocusAfterDelete.PREVIOUS, + **kwargs: Any, + ) -> None: + # TreeListCtrl integrates with VoiceOver more reliably than TreeCtrl on macOS. + super().__init__(parent, id=id, pos=pos, size=size, **kwargs) + self.AppendColumn("Name", width=wx.COL_WIDTH_AUTOSIZE) + self.focus_after_delete = focus_after_delete + self._compat_root = super().GetRootItem() + self._item_data: dict[Any, Any] = {} + self._parent_by_item: dict[Any, Any] = {} + self._children_by_parent: dict[Any, list[Any]] = { + self._item_key(self._compat_root): [] + } + self.Bind(wx.EVT_SET_FOCUS, self._on_tree_focus_ensure_selection) + + @staticmethod + def _item_key(item: Any) -> Any: + """Return a stable key for a tree item.""" + if not item: + return None + get_id = getattr(item, "GetID", None) + if callable(get_id): + return get_id() + return item + + def _bind_event(self, event: Any) -> Any: + """Map TreeCtrl event binders to TreeListCtrl equivalents.""" + if event == wx.EVT_TREE_SEL_CHANGED: + return dv.EVT_TREELIST_SELECTION_CHANGED + if event == wx.EVT_TREE_ITEM_ACTIVATED: + return dv.EVT_TREELIST_ITEM_ACTIVATED + return event + + def Bind(self, event: Any, handler: Any, *args: Any, **kwargs: Any) -> None: + """Bind handlers using wx.TreeCtrl-compatible event names.""" + super().Bind(self._bind_event(event), handler, *args, **kwargs) + + def _on_tree_focus_ensure_selection(self, evt: wx.FocusEvent) -> None: + """Auto-select the first visible item when focus lands on the tree.""" + evt.Skip() + sel = self.GetSelection() + if sel and sel.IsOk(): + return + first_child, _ = self.GetFirstChild(self.GetRootItem()) + if first_child and first_child.IsOk(): + self.SelectItem(first_child) + + def GetRootItem(self) -> Any: + """Return the compatibility root item.""" + return self._compat_root + + def AddRoot(self, text: str) -> Any: + """Match wx.TreeCtrl's explicit-root API without creating a visible node.""" + return self._compat_root + + def AppendItem(self, parent: Any, text: str) -> Any: + """Append a child item and track parent/child relationships.""" + item = super().AppendItem(parent, text) + parent_key = self._item_key(parent) + item_key = self._item_key(item) + self._parent_by_item[item_key] = parent + self._children_by_parent.setdefault(parent_key, []).append(item) + self._children_by_parent.setdefault(item_key, []) + return item + + def DeleteAllItems(self) -> None: + """Clear all items and compatibility bookkeeping.""" + super().DeleteAllItems() + self._compat_root = super().GetRootItem() + self._item_data.clear() + self._parent_by_item.clear() + self._children_by_parent = {self._item_key(self._compat_root): []} + + def SetItemData(self, item: Any, data: Any) -> None: + """Store arbitrary item data like wx.TreeCtrl does.""" + self._item_data[self._item_key(item)] = data + + def GetItemData(self, item: Any) -> Any: + """Return previously stored item data.""" + return self._item_data.get(self._item_key(item)) + + def GetFirstChild(self, parent: Any) -> tuple[Any, Any]: + """Return the first child and a cookie for GetNextChild().""" + children = self._children_by_parent.get(self._item_key(parent), []) + if not children: + return dv.TreeListItem(), None + first_child = children[0] + return first_child, first_child + + def GetNextChild(self, parent: Any, cookie: Any) -> tuple[Any, Any]: + """Return the next child and updated cookie.""" + children = self._children_by_parent.get(self._item_key(parent), []) + if not cookie: + return dv.TreeListItem(), None + try: + index = children.index(cookie) + 1 + except ValueError: + return dv.TreeListItem(), None + if index >= len(children): + return dv.TreeListItem(), None + next_child = children[index] + return next_child, next_child + + def GetItemParent(self, item: Any) -> Any: + """Return the item's parent.""" + return self._parent_by_item.get(self._item_key(item), dv.TreeListItem()) + + def GetChildrenCount(self, parent: Any, recursively: bool = False) -> int: + """Return the number of direct or recursive children.""" + children = self._children_by_parent.get(self._item_key(parent), []) + if not recursively: + return len(children) + return len(children) + sum(self.GetChildrenCount(child, True) for child in children) + + def SelectItem(self, item: Any) -> None: + """Select an item using wx.TreeCtrl naming.""" + self.Select(item) + + def GetItemText(self, item: Any, column: int = 0) -> str: + """Return item text using wx.TreeCtrl's columnless default.""" + return super().GetItemText(item, column) + + def SetItemText(self, item: Any, text: str, column: int = 0) -> None: + """Set item text using a wx.TreeCtrl-compatible signature.""" + super().SetItemText(item, column, text) diff --git a/clients/desktop/ui/login_dialog.py b/clients/desktop/ui/login_dialog.py index 20c5e586..7543a8cf 100644 --- a/clients/desktop/ui/login_dialog.py +++ b/clients/desktop/ui/login_dialog.py @@ -64,6 +64,7 @@ def _create_ui(self): self.tree = ManagedTreeCtrl( self.panel, style=wx.TR_HAS_BUTTONS | wx.TR_SINGLE | wx.TR_HIDE_ROOT, + name="Servers and accounts", ) sizer.Add(self.tree, 1, wx.EXPAND | wx.LEFT | wx.RIGHT, 10) From 92f4733c643f9e45fab5c4635a63c251c9e53d9d Mon Sep 17 00:00:00 2001 From: vlad ciotescu Date: Thu, 30 Apr 2026 14:20:34 +0300 Subject: [PATCH 2/3] Address login tree review feedback --- clients/desktop/tests/test_tree_selection.py | 101 +++++++++++++++ .../desktop/ui/enhance_wx/tree_selection.py | 120 ++++++++++++------ 2 files changed, 185 insertions(+), 36 deletions(-) create mode 100644 clients/desktop/tests/test_tree_selection.py diff --git a/clients/desktop/tests/test_tree_selection.py b/clients/desktop/tests/test_tree_selection.py new file mode 100644 index 00000000..28b62bc6 --- /dev/null +++ b/clients/desktop/tests/test_tree_selection.py @@ -0,0 +1,101 @@ +import sys + +import pytest +import wx + +from ui.enhance_wx.list_selection import FocusAfterDelete +from ui.enhance_wx.tree_selection import ManagedTreeCtrl + + +def _build_tree(control): + root = control.AddRoot("") + server_a = control.AppendItem(root, "Server A") + account_a1 = control.AppendItem(server_a, "alice") + account_a2 = control.AppendItem(server_a, "bob") + server_b = control.AppendItem(root, "Server B") + control.SetItemData(server_a, ("server", "srv-a", None)) + control.SetItemData(account_a1, ("account", "srv-a", "acct-a1")) + control.SetItemData(account_a2, ("account", "srv-a", "acct-a2")) + control.SetItemData(server_b, ("server", "srv-b", None)) + return root, server_a, account_a1, account_a2, server_b + + +def test_managed_tree_ctrl_tracks_children_and_item_data(wx_app): + frame = wx.Frame(None) + panel = wx.Panel(frame) + tree = ManagedTreeCtrl(panel, name="Test tree") + try: + root, server_a, account_a1, account_a2, server_b = _build_tree(tree) + + first_server, cookie = tree.GetFirstChild(root) + second_server, next_cookie = tree.GetNextChild(root, cookie) + end_item, end_cookie = tree.GetNextChild(root, next_cookie) + + assert first_server.IsOk() + assert tree.GetItemText(first_server) == "Server A" + assert second_server.IsOk() + assert tree.GetItemText(second_server) == "Server B" + assert not end_item.IsOk() + assert end_cookie is None + + assert tree.GetChildrenCount(root, False) == 2 + assert tree.GetChildrenCount(server_a, False) == 2 + assert tree.GetChildrenCount(root, True) == 4 + assert tree.GetItemParent(account_a1) == server_a + assert tree.GetItemData(account_a2) == ("account", "srv-a", "acct-a2") + finally: + frame.Destroy() + + +def test_managed_tree_ctrl_select_after_delete_prefers_configured_neighbor(wx_app): + frame = wx.Frame(None) + panel = wx.Panel(frame) + tree = ManagedTreeCtrl(panel, name="Test tree", focus_after_delete=FocusAfterDelete.NEXT) + try: + root, server_a, account_a1, account_a2, server_b = _build_tree(tree) + + tree.select_after_delete(server_a, account_a1, account_a2) + selection = tree.GetSelection() + + assert selection.IsOk() + assert tree.GetItemText(selection) == "bob" + + tree.select_after_delete(root, server_a, server_b) + selection = tree.GetSelection() + + assert selection.IsOk() + assert tree.GetItemText(selection) == "Server B" + finally: + frame.Destroy() + + +def test_managed_tree_ctrl_delete_all_items_resets_bookkeeping(wx_app): + frame = wx.Frame(None) + panel = wx.Panel(frame) + tree = ManagedTreeCtrl(panel, name="Test tree") + try: + root, server_a, account_a1, account_a2, server_b = _build_tree(tree) + + tree.DeleteAllItems() + + root = tree.GetRootItem() + first_child, cookie = tree.GetFirstChild(root) + + assert tree.GetChildrenCount(root, False) == 0 + assert not first_child.IsOk() + assert cookie is None + assert tree.GetItemData(server_a) is None + finally: + frame.Destroy() + + +@pytest.mark.skipif(sys.platform != "darwin", reason="macOS TreeListCtrl shim only") +def test_managed_tree_ctrl_rejects_unsupported_tree_event_binders(wx_app): + frame = wx.Frame(None) + panel = wx.Panel(frame) + tree = ManagedTreeCtrl(panel, name="Test tree") + try: + with pytest.raises(NotImplementedError): + tree.Bind(wx.EVT_TREE_ITEM_EXPANDED, lambda evt: None) + finally: + frame.Destroy() diff --git a/clients/desktop/ui/enhance_wx/tree_selection.py b/clients/desktop/ui/enhance_wx/tree_selection.py index 3d3bb205..805dffbf 100644 --- a/clients/desktop/ui/enhance_wx/tree_selection.py +++ b/clients/desktop/ui/enhance_wx/tree_selection.py @@ -16,6 +16,7 @@ from __future__ import annotations import sys +from collections.abc import Callable from typing import Any import wx @@ -101,20 +102,18 @@ def select_after_delete( self.SelectItem(target) -# ============================================================================= -# READY-TO-USE CONTROL -# ============================================================================= - - -class ManagedTreeCtrl(TreeSelectionManagerMixin, wx.TreeCtrl): - """A wx.TreeCtrl with automatic selection management.""" - - pass - - if sys.platform == "darwin": - - class ManagedTreeCtrl(dv.TreeListCtrl): + _EVENT_BINDER_MAP: dict[wx.PyEventBinder, wx.PyEventBinder] = { + wx.EVT_TREE_SEL_CHANGED: dv.EVT_TREELIST_SELECTION_CHANGED, + wx.EVT_TREE_ITEM_ACTIVATED: dv.EVT_TREELIST_ITEM_ACTIVATED, + } + _TREE_EVENT_BINDERS = tuple( + getattr(wx, name) + for name in dir(wx) + if name.startswith("EVT_TREE_") and isinstance(getattr(wx, name), wx.PyEventBinder) + ) + + class _MacManagedTreeCtrl(dv.TreeListCtrl): """A macOS-friendly tree control backed by DataView for VoiceOver.""" def __init__( @@ -127,20 +126,20 @@ def __init__( focus_after_delete: FocusAfterDelete = FocusAfterDelete.PREVIOUS, **kwargs: Any, ) -> None: - # TreeListCtrl integrates with VoiceOver more reliably than TreeCtrl on macOS. + # TreeCtrl style flags do not apply to TreeListCtrl, so they are ignored here. super().__init__(parent, id=id, pos=pos, size=size, **kwargs) self.AppendColumn("Name", width=wx.COL_WIDTH_AUTOSIZE) self.focus_after_delete = focus_after_delete self._compat_root = super().GetRootItem() - self._item_data: dict[Any, Any] = {} - self._parent_by_item: dict[Any, Any] = {} - self._children_by_parent: dict[Any, list[Any]] = { + self._item_data: dict[object, object] = {} + self._parent_by_item: dict[object, dv.TreeListItem] = {} + self._children_by_parent: dict[object, list[dv.TreeListItem]] = { self._item_key(self._compat_root): [] } self.Bind(wx.EVT_SET_FOCUS, self._on_tree_focus_ensure_selection) @staticmethod - def _item_key(item: Any) -> Any: + def _item_key(item: dv.TreeListItem) -> object | None: """Return a stable key for a tree item.""" if not item: return None @@ -149,15 +148,24 @@ def _item_key(item: Any) -> Any: return get_id() return item - def _bind_event(self, event: Any) -> Any: + def _bind_event(self, event: wx.PyEventBinder) -> wx.PyEventBinder: """Map TreeCtrl event binders to TreeListCtrl equivalents.""" - if event == wx.EVT_TREE_SEL_CHANGED: - return dv.EVT_TREELIST_SELECTION_CHANGED - if event == wx.EVT_TREE_ITEM_ACTIVATED: - return dv.EVT_TREELIST_ITEM_ACTIVATED + mapped_event = _EVENT_BINDER_MAP.get(event) + if mapped_event is not None: + return mapped_event + if event in _TREE_EVENT_BINDERS: + raise NotImplementedError( + "ManagedTreeCtrl on macOS does not support this wx.TreeCtrl event binder." + ) return event - def Bind(self, event: Any, handler: Any, *args: Any, **kwargs: Any) -> None: + def Bind( + self, + event: wx.PyEventBinder, + handler: Callable[..., object], + *args: Any, + **kwargs: Any, + ) -> None: """Bind handlers using wx.TreeCtrl-compatible event names.""" super().Bind(self._bind_event(event), handler, *args, **kwargs) @@ -171,15 +179,15 @@ def _on_tree_focus_ensure_selection(self, evt: wx.FocusEvent) -> None: if first_child and first_child.IsOk(): self.SelectItem(first_child) - def GetRootItem(self) -> Any: + def GetRootItem(self) -> dv.TreeListItem: """Return the compatibility root item.""" return self._compat_root - def AddRoot(self, text: str) -> Any: + def AddRoot(self, text: str) -> dv.TreeListItem: """Match wx.TreeCtrl's explicit-root API without creating a visible node.""" return self._compat_root - def AppendItem(self, parent: Any, text: str) -> Any: + def AppendItem(self, parent: dv.TreeListItem, text: str) -> dv.TreeListItem: """Append a child item and track parent/child relationships.""" item = super().AppendItem(parent, text) parent_key = self._item_key(parent) @@ -197,15 +205,17 @@ def DeleteAllItems(self) -> None: self._parent_by_item.clear() self._children_by_parent = {self._item_key(self._compat_root): []} - def SetItemData(self, item: Any, data: Any) -> None: + def SetItemData(self, item: dv.TreeListItem, data: object) -> None: """Store arbitrary item data like wx.TreeCtrl does.""" self._item_data[self._item_key(item)] = data - def GetItemData(self, item: Any) -> Any: + def GetItemData(self, item: dv.TreeListItem) -> object | None: """Return previously stored item data.""" return self._item_data.get(self._item_key(item)) - def GetFirstChild(self, parent: Any) -> tuple[Any, Any]: + def GetFirstChild( + self, parent: dv.TreeListItem + ) -> tuple[dv.TreeListItem, dv.TreeListItem | None]: """Return the first child and a cookie for GetNextChild().""" children = self._children_by_parent.get(self._item_key(parent), []) if not children: @@ -213,7 +223,9 @@ def GetFirstChild(self, parent: Any) -> tuple[Any, Any]: first_child = children[0] return first_child, first_child - def GetNextChild(self, parent: Any, cookie: Any) -> tuple[Any, Any]: + def GetNextChild( + self, parent: dv.TreeListItem, cookie: dv.TreeListItem | None + ) -> tuple[dv.TreeListItem, dv.TreeListItem | None]: """Return the next child and updated cookie.""" children = self._children_by_parent.get(self._item_key(parent), []) if not cookie: @@ -227,25 +239,61 @@ def GetNextChild(self, parent: Any, cookie: Any) -> tuple[Any, Any]: next_child = children[index] return next_child, next_child - def GetItemParent(self, item: Any) -> Any: + def GetItemParent(self, item: dv.TreeListItem) -> dv.TreeListItem: """Return the item's parent.""" return self._parent_by_item.get(self._item_key(item), dv.TreeListItem()) - def GetChildrenCount(self, parent: Any, recursively: bool = False) -> int: + def GetChildrenCount(self, parent: dv.TreeListItem, recursively: bool = False) -> int: """Return the number of direct or recursive children.""" children = self._children_by_parent.get(self._item_key(parent), []) if not recursively: return len(children) return len(children) + sum(self.GetChildrenCount(child, True) for child in children) - def SelectItem(self, item: Any) -> None: + def SelectItem(self, item: dv.TreeListItem) -> None: """Select an item using wx.TreeCtrl naming.""" self.Select(item) - def GetItemText(self, item: Any, column: int = 0) -> str: + def GetItemText(self, item: dv.TreeListItem, column: int = 0) -> str: """Return item text using wx.TreeCtrl's columnless default.""" return super().GetItemText(item, column) - def SetItemText(self, item: Any, text: str, column: int = 0) -> None: + def SetItemText(self, item: dv.TreeListItem, text: str, column: int = 0) -> None: """Set item text using a wx.TreeCtrl-compatible signature.""" super().SetItemText(item, column, text) + + def select_after_delete( + self, + parent: dv.TreeListItem, + prev_sibling: dv.TreeListItem | None, + next_sibling: dv.TreeListItem | None, + ) -> None: + """Select an appropriate item after deletion, matching TreeSelectionManagerMixin.""" + if self.focus_after_delete == FocusAfterDelete.PREVIOUS: + primary, fallback = prev_sibling, next_sibling + else: + primary, fallback = next_sibling, prev_sibling + + target = None + if primary and primary.IsOk(): + target = primary + elif fallback and fallback.IsOk(): + target = fallback + elif parent and parent.IsOk() and parent != self.GetRootItem(): + target = parent + + if target: + self.SelectItem(target) + + ManagedTreeCtrl = _MacManagedTreeCtrl + +else: + + # ============================================================================= + # READY-TO-USE CONTROL + # ============================================================================= + + class ManagedTreeCtrl(TreeSelectionManagerMixin, wx.TreeCtrl): + """A wx.TreeCtrl with automatic selection management.""" + + pass From 4ac857f088647b1b09d21fda33da9f0f76d11282 Mon Sep 17 00:00:00 2001 From: vlad ciotescu Date: Thu, 30 Apr 2026 17:23:21 +0300 Subject: [PATCH 3/3] Remove mac login tree header title --- clients/desktop/ui/enhance_wx/tree_selection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/desktop/ui/enhance_wx/tree_selection.py b/clients/desktop/ui/enhance_wx/tree_selection.py index 805dffbf..74d1f4cd 100644 --- a/clients/desktop/ui/enhance_wx/tree_selection.py +++ b/clients/desktop/ui/enhance_wx/tree_selection.py @@ -128,7 +128,7 @@ def __init__( ) -> None: # TreeCtrl style flags do not apply to TreeListCtrl, so they are ignored here. super().__init__(parent, id=id, pos=pos, size=size, **kwargs) - self.AppendColumn("Name", width=wx.COL_WIDTH_AUTOSIZE) + self.AppendColumn("", width=wx.COL_WIDTH_AUTOSIZE) self.focus_after_delete = focus_after_delete self._compat_root = super().GetRootItem() self._item_data: dict[object, object] = {}