From d27e62f1472adaea4d6da27bc35a993549c531cf Mon Sep 17 00:00:00 2001 From: Oliver-Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:03:44 +0100 Subject: [PATCH 01/30] Winforms Tree widget --- winforms/src/toga_winforms/factory.py | 2 + winforms/src/toga_winforms/libs/comctl32.py | 10 +- .../src/toga_winforms/libs/comctl32classes.py | 44 +- winforms/src/toga_winforms/libs/gdi32.py | 10 + winforms/src/toga_winforms/libs/user32.py | 63 +- winforms/src/toga_winforms/libs/win32.py | 7 +- .../src/toga_winforms/libs/windowconstants.py | 39 +- .../src/toga_winforms/widgets/detailedlist.py | 7 + winforms/src/toga_winforms/widgets/table.py | 222 +++-- winforms/src/toga_winforms/widgets/tree.py | 914 ++++++++++++++++++ 10 files changed, 1201 insertions(+), 117 deletions(-) create mode 100644 winforms/src/toga_winforms/libs/gdi32.py create mode 100644 winforms/src/toga_winforms/widgets/tree.py diff --git a/winforms/src/toga_winforms/factory.py b/winforms/src/toga_winforms/factory.py index f8ca3bef61..860819c9c9 100644 --- a/winforms/src/toga_winforms/factory.py +++ b/winforms/src/toga_winforms/factory.py @@ -31,6 +31,7 @@ from .widgets.table import Table from .widgets.textinput import TextInput from .widgets.timeinput import TimeInput +from .widgets.tree import Tree from .widgets.webview import WebView from .window import MainWindow, Window @@ -77,6 +78,7 @@ def not_implemented(feature): "Table", "TextInput", "TimeInput", + "Tree", "WebView", # Windows "Window", diff --git a/winforms/src/toga_winforms/libs/comctl32.py b/winforms/src/toga_winforms/libs/comctl32.py index d41db24e1e..14eec84866 100644 --- a/winforms/src/toga_winforms/libs/comctl32.py +++ b/winforms/src/toga_winforms/libs/comctl32.py @@ -1,8 +1,8 @@ from ctypes import windll -from ctypes.wintypes import BOOL, HWND, LPARAM, UINT, WPARAM +from ctypes.wintypes import BOOL, HDC, HWND, INT, LPARAM, UINT, WPARAM from .comctl32classes import SUBCLASSPROC -from .win32 import DWORD_PTR, LRESULT, UINT_PTR +from .win32 import DWORD_PTR, HIMAGELIST, LRESULT, UINT_PTR comctl32 = windll.comctl32 @@ -13,6 +13,12 @@ DefSubclassProc.argtypes = [HWND, UINT, WPARAM, LPARAM] +# https://learn.microsoft.com/en-us/windows/win32/api/commctrl/nf-commctrl-imagelist_draw +ImageList_Draw = comctl32.ImageList_Draw +ImageList_Draw.restype = BOOL +ImageList_Draw.argtypes = [HIMAGELIST, INT, HDC, INT, INT, UINT] + + # https://learn.microsoft.com/en-us/windows/win32/api/commctrl/nf-commctrl-setwindowsubclass RemoveWindowSubclass = comctl32.RemoveWindowSubclass RemoveWindowSubclass.restype = BOOL diff --git a/winforms/src/toga_winforms/libs/comctl32classes.py b/winforms/src/toga_winforms/libs/comctl32classes.py index 95a279eaf9..6893892aa1 100644 --- a/winforms/src/toga_winforms/libs/comctl32classes.py +++ b/winforms/src/toga_winforms/libs/comctl32classes.py @@ -2,7 +2,18 @@ WINFUNCTYPE, Structure as c_Structure, ) -from ctypes.wintypes import HWND, INT, LPARAM, LPWSTR, UINT, WPARAM +from ctypes.wintypes import ( + COLORREF, + DWORD, + HDC, + HWND, + INT, + LPARAM, + LPWSTR, + RECT, + UINT, + WPARAM, +) from .win32 import DWORD_PTR, INT_PTR, LRESULT, PUINT, UINT_PTR @@ -37,6 +48,37 @@ class NMHDR(c_Structure): ] +# https://learn.microsoft.com/en-us/windows/win32/api/commctrl/ns-commctrl-nmcustomdraw +class NMCUSTOMDRAW(c_Structure): + _fields_ = [ + ("hdr", NMHDR), + ("dwDrawStage", DWORD), + ("hdc", HDC), + ("rc", RECT), + ("dwItemSpec", DWORD_PTR), + ("uItemState", UINT), + ("lItemlParam", LPARAM), + ] + + +# https://learn.microsoft.com/en-us/windows/win32/api/commctrl/ns-commctrl-nmlvcustomdraw +class NMLVCUSTOMDRAW(c_Structure): + _fields_ = [ + ("nmcd", NMCUSTOMDRAW), + ("clrText", COLORREF), + ("clrTextBk", COLORREF), + ("iSubItem", INT), + ("dwItemType", DWORD), + ("clrFace", COLORREF), + ("iIconEffect", INT), + ("iIconPhase", INT), + ("iPartId", INT), + ("iStateId", INT), + ("rcText", RECT), + ("uAlign", UINT), + ] + + # https://learn.microsoft.com/en-us/windows/win32/api/commctrl/ns-commctrl-nmlvdispinfow class NMLVDISPINFOW(c_Structure): _fields_ = [ diff --git a/winforms/src/toga_winforms/libs/gdi32.py b/winforms/src/toga_winforms/libs/gdi32.py new file mode 100644 index 0000000000..a745bad2ba --- /dev/null +++ b/winforms/src/toga_winforms/libs/gdi32.py @@ -0,0 +1,10 @@ +from ctypes import windll +from ctypes.wintypes import COLORREF, HDC + +gdi32 = windll.GDI32 + + +# https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-settextcolor +SetTextColor = gdi32.SetTextColor +SetTextColor.restype = COLORREF +SetTextColor.argtypes = [HDC, COLORREF] diff --git a/winforms/src/toga_winforms/libs/user32.py b/winforms/src/toga_winforms/libs/user32.py index 693e163510..32d14b58fa 100644 --- a/winforms/src/toga_winforms/libs/user32.py +++ b/winforms/src/toga_winforms/libs/user32.py @@ -1,9 +1,21 @@ from ctypes import c_void_p, windll -from ctypes.wintypes import BOOL, DWORD, HMONITOR, HWND, LPARAM, LPRECT, UINT, WPARAM +from ctypes.wintypes import ( + BOOL, + DWORD, + HDC, + HMONITOR, + HWND, + INT, + LPARAM, + LPCWSTR, + LPRECT, + UINT, + WPARAM, +) from System import Environment -from .win32 import LRESULT +from .win32 import HBRUSH, LRESULT, RECT_PTR user32 = windll.user32 @@ -12,6 +24,39 @@ DPI_AWARENESS_CONTEXT_UNAWARE = -1 DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = -4 + +# https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-drawtextw +DrawTextW = user32.DrawTextW +DrawTextW.restype = INT +DrawTextW.argtypes = [HDC, LPCWSTR, INT, LPRECT, UINT] + + +# https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-fillrect +FillRect = user32.FillRect +FillRect.restype = INT +FillRect.argtypes = [HDC, RECT_PTR, HBRUSH] + + +# https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getsyscolor +GetSysColor = user32.GetSysColor +GetSysColor.restype = DWORD +GetSysColor.argtypes = [INT] + + +# https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-monitorfromrect +MONITOR_DEFAULTTONEAREST = 2 + +MonitorFromRect = user32.MonitorFromRect +MonitorFromRect.restype = HMONITOR +MonitorFromRect.argtypes = [LPRECT, DWORD] + + +# https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-sendmessagew +SendMessageW = user32.SendMessageW +SendMessageW.restype = LRESULT +SendMessageW.argtypes = [HWND, UINT, WPARAM, LPARAM] + + # https://www.lifewire.com/windows-version-numbers-2625171 win_version = Environment.OSVersion.Version if (win_version.Major, win_version.Minor, win_version.Build) >= (10, 0, 15063): @@ -29,17 +74,3 @@ "We recommend you upgrade to at least Windows 10 version 1703." ) SetProcessDpiAwarenessContext = SetThreadDpiAwarenessContext = None - - -# https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-monitorfromrect -MONITOR_DEFAULTTONEAREST = 2 - -MonitorFromRect = user32.MonitorFromRect -MonitorFromRect.restype = HMONITOR -MonitorFromRect.argtypes = [LPRECT, DWORD] - - -# https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-sendmessagew -SendMessageW = user32.SendMessageW -SendMessageW.restype = LRESULT -SendMessageW.argtypes = [HWND, UINT, WPARAM, LPARAM] diff --git a/winforms/src/toga_winforms/libs/win32.py b/winforms/src/toga_winforms/libs/win32.py index 59ab01c7ac..b217150bb9 100644 --- a/winforms/src/toga_winforms/libs/win32.py +++ b/winforms/src/toga_winforms/libs/win32.py @@ -1,8 +1,11 @@ -from ctypes import c_size_t -from ctypes.wintypes import LPARAM +from ctypes import POINTER, c_size_t +from ctypes.wintypes import HWND, LPARAM, RECT LRESULT = LPARAM # LPARAM is essentially equivalent to LRESULT UINT_PTR = c_size_t DWORD_PTR = c_size_t PUINT = c_size_t INT_PTR = c_size_t +RECT_PTR = POINTER(RECT) +HBRUSH = HWND +HIMAGELIST = HWND diff --git a/winforms/src/toga_winforms/libs/windowconstants.py b/winforms/src/toga_winforms/libs/windowconstants.py index d15a6ba50b..ea8348d933 100644 --- a/winforms/src/toga_winforms/libs/windowconstants.py +++ b/winforms/src/toga_winforms/libs/windowconstants.py @@ -1,13 +1,45 @@ # Window constants +# Custom Draw Draw Stage +CDDS_PREPAINT = 0x00000001 +CDDS_ITEMPREPAINT = 0x00010000 | 0x00000001 +CDDS_SUBITEM = 0x00020000 + +# Custom Draw Response Flag +CDRF_NEWFONT = 0x00000002 +CDRF_NOTIFYITEMDRAW = 0x00000020 +CDRF_NOTIFYSUBITEMDRAW = CDRF_NOTIFYITEMDRAW +CDRF_SKIPDEFAULT = 0x00000004 + +# Color +COLOR_HIGHLIGHT = 13 +COLOR_HIGHLIGHTTEXT = 14 +COLOR_HOTLIGHT = 26 +COLOR_WINDOW = 5 + +# Draw Text +DT_CALCRECT = 0x00000400 +DT_HCENTER = 0x00000001 +DT_NOCLIP = 0x00000100 +DT_SINGLELINE = 0x00000020 +DT_VCENTER = 0x00000004 +DT_WORD_ELLIPSIS = 0x40000 + # Edit Messages EM_SETCUEBANNER = 0x1501 -# List-View Item Format +# Image List Draw +ILD_NORMAL = 0x0000 +ILD_SELECTED = 0x0004 + +# List-View Item Flag LVIF_TEXT = 0x0001 LVIF_IMAGE = 0x0002 LVIF_STATE = 0x0008 +# List-View Item State +LVIS_SELECTED = 0x0002 + # List-View Management LVM_GETEXTENDEDLISTVIEWSTYLE = 0x1037 LVM_SETEXTENDEDLISTVIEWSTYLE = 0x1036 @@ -18,6 +50,11 @@ # List-View Styles (Extended) LVS_EX_SUBITEMIMAGES = 0x2 +# Notification Message +NM_CUSTOMDRAW = 0xFFFFFFF4 + # Window Message +WM_GETFONT = 0x0031 WM_NCDESTROY = 0x0082 +WM_NOTIFY = 0x004E WM_REFLECT_NOTIFY = 0x204E diff --git a/winforms/src/toga_winforms/widgets/detailedlist.py b/winforms/src/toga_winforms/widgets/detailedlist.py index 1d73fb1f7f..85e67f8dc5 100644 --- a/winforms/src/toga_winforms/widgets/detailedlist.py +++ b/winforms/src/toga_winforms/widgets/detailedlist.py @@ -23,7 +23,10 @@ def __getitem__(self, index): class DetailedList(Table): + ################################################################################# # The following methods are overridden from Table. + ################################################################################# + @property def _show_headings(self): return False @@ -48,6 +51,10 @@ def add_action_events(self): # DetailedList doesn't have an on_activate_handler. pass + ################################################################################# + # The following methods and class variable are not from Table. + ################################################################################# + def set_primary_action_enabled(self, enabled): self.primary_action_enabled = enabled diff --git a/winforms/src/toga_winforms/widgets/table.py b/winforms/src/toga_winforms/widgets/table.py index f1e099be1d..9a31bd6567 100644 --- a/winforms/src/toga_winforms/widgets/table.py +++ b/winforms/src/toga_winforms/widgets/table.py @@ -15,7 +15,10 @@ class Table(Widget): + ################################################################################# # The following methods are overridden in DetailedList. + ################################################################################# + @property def _show_headings(self): return self.interface._show_headings @@ -32,21 +35,21 @@ def _multiple_select(self): def _data(self): return self.interface.data - def __del__(self): - # The object self.pfn_subclass is a python class and is part of the native - # Windows process. When a Table is removed by the python GC, self.pfn_subclass - # is also removed and the Windows process has a dangling pointer. Calling - # Dispose() here fixes the problem by removing the self.pfn_subclass from the - # Windows process. - self.native.Dispose() + def add_action_events(self): + self.native.MouseDoubleClick += WeakrefCallable(self.winforms_double_click) + + ################################################################################# + # The following method is overridden in DetailedList and Tree + ################################################################################# def create(self): self.pfn_subclass = SUBCLASSPROC(self._subclass_proc) self.native = WinForms.ListView() + self._hwnd = int(self.native.Handle.ToString()) self._set_subclass() - self.native.HandleCreated += WeakrefCallable(self.handle_created) - self.native.HandleDestroyed += WeakrefCallable(self.handle_destroyed) + self.native.HandleCreated += WeakrefCallable(self.winforms_handle_created) + self.native.HandleDestroyed += WeakrefCallable(self.winforms_handle_destroyed) self.native.View = WinForms.View.Details self._enable_multi_icon_style() @@ -72,9 +75,6 @@ def create(self): self.native.SmallImageList = WinForms.ImageList() self.native.HideSelection = False - self.native.ItemSelectionChanged += WeakrefCallable( - self.winforms_item_selection_changed - ) self.native.RetrieveVirtualItem += WeakrefCallable( self.winforms_retrieve_virtual_item ) @@ -84,35 +84,22 @@ def create(self): self.native.SearchForVirtualItem += WeakrefCallable( self.winforms_search_for_virtual_item ) - self.native.VirtualItemsSelectionRangeChanged += WeakrefCallable( - self.winforms_virtual_items_selection_range_changed - ) self.add_action_events() - def _enable_multi_icon_style(self): - list_view_handle = int(self.native.Handle.ToString()) - - # Use SendMessage over PostMessage since the ListView object is on the same - # thread as the messaging call. - old_style = SendMessageW( - list_view_handle, wc.LVM_GETEXTENDEDLISTVIEWSTYLE, 0, 0 + # Name the WinForms event listeners for selection changes so that they can be + # added and removed. + self.selection_listener_single = WeakrefCallable( + self.winforms_item_selection_changed ) - new_style = old_style | wc.LVS_EX_SUBITEMIMAGES - - SendMessageW(list_view_handle, wc.LVM_SETEXTENDEDLISTVIEWSTYLE, 0, new_style) - - def handle_created(self, sender, e): - self._set_subclass() - - def handle_destroyed(self, sender, e): - # Remove the subclass when a handle is destroyed to prevent a memory leak. - self._remove_subclass() - - def _set_subclass(self): - SetWindowSubclass(int(self.native.Handle.ToString()), self.pfn_subclass, 0, 0) + self.selection_listener_multi = WeakrefCallable( + self.winforms_virtual_items_selection_range_changed + ) + self.native.ItemSelectionChanged += self.selection_listener_single + self.native.VirtualItemsSelectionRangeChanged += self.selection_listener_multi - def _remove_subclass(self): - RemoveWindowSubclass(int(self.native.Handle.ToString()), self.pfn_subclass, 0) + ################################################################################# + # The following methods are overridden in Tree + ################################################################################# def _subclass_proc( self, @@ -128,17 +115,81 @@ def _subclass_proc( if uMsg == wc.WM_NCDESTROY: RemoveWindowSubclass(hWnd, self.pfn_subclass, uIdSubclass) - if uMsg == wc.WM_REFLECT_NOTIFY: + elif uMsg == wc.WM_REFLECT_NOTIFY: phdr = cast(lParam, POINTER(NMHDR)).contents code = phdr.code if code == wc.LVN_GETDISPINFOW: disp_info = cast(lParam, POINTER(NMLVDISPINFOW)).contents - self._set_subitem_icon(disp_info.item) + self._lvn_getdispinfo(disp_info.item) # Call the original window procedure return DefSubclassProc(HWND(hWnd), UINT(uMsg), WPARAM(wParam), LPARAM(lParam)) - def _set_subitem_icon(self, lvitem: LVITEMW): + def _new_item(self, index): + raw_item = self._data[index] + if any(column.widget(raw_item) is not None for column in self._columns): + warn( + "Winforms does not support the use of widgets in cells", + stacklevel=1, + ) + + return self._construct_new_item(raw_item) + + def _process_selection_change(self): + self.interface.on_select() + + def _process_activation(self, x, y, list_view_item): + self.interface.on_activate(row=self._data[list_view_item.Index]) + + def update_data(self): + self.native.VirtualListSize = len(self._data) + self._cache = [] + + def insert(self, index, item): + self.update_data() + + def change(self, item): + self.update_data() + + def remove(self, index, item): + self.update_data() + + def get_selection(self): + selected_indices = list(self.native.SelectedIndices) + if self._multiple_select: + return selected_indices + elif len(selected_indices) == 0: + return None + else: + return selected_indices[0] + + ################################################################################# + # The following methods are shared (non-overridden) with DetailedList and Tree + ################################################################################# + + def __del__(self): + # The object self.pfn_subclass is a python class and is part of the native + # Windows process. When a Table is removed by the python GC, self.pfn_subclass + # is also removed and the Windows process has a dangling pointer. Calling + # Dispose() here fixes the problem by removing the self.pfn_subclass from the + # Windows process. + self.native.Dispose() + + def _set_subclass(self): + SetWindowSubclass(self._hwnd, self.pfn_subclass, 0, 0) + + def _remove_subclass(self): + RemoveWindowSubclass(self._hwnd, self.pfn_subclass, 0) + + def _enable_multi_icon_style(self): + # Use SendMessage over PostMessage since the ListView object is on the same + # thread as the messaging call. + old_style = SendMessageW(self._hwnd, wc.LVM_GETEXTENDEDLISTVIEWSTYLE, 0, 0) + new_style = old_style | wc.LVS_EX_SUBITEMIMAGES + + SendMessageW(self._hwnd, wc.LVM_SETEXTENDEDLISTVIEWSTYLE, 0, new_style) + + def _lvn_getdispinfo(self, lvitem: LVITEMW): row_index = lvitem.iItem column_index = lvitem.iSubItem @@ -151,21 +202,6 @@ def _set_subitem_icon(self, lvitem: LVITEMW): if lvitem.uiMask & wc.LVIF_IMAGE != 0 and icon_indices[column_index] > -1: lvitem.iImage = icon_indices[column_index] - def add_action_events(self): - self.native.MouseDoubleClick += WeakrefCallable(self.winforms_double_click) - - def set_bounds(self, x, y, width, height): - super().set_bounds(x, y, width, height) - if self._pending_resize: - self._pending_resize = False - self._resize_columns() - - def winforms_retrieve_virtual_item(self, sender, e): - # Because ListView is in VirtualMode, it's necessary implement - # VirtualItemsSelectionRangeChanged event to create ListViewItem - # when it's needed - e.Item, _ = self._toga_retrieve_virtual_item(e.ItemIndex) - def _toga_retrieve_virtual_item(self, item_index): if ( self._cache @@ -176,6 +212,20 @@ def _toga_retrieve_virtual_item(self, item_index): else: return self._new_item(item_index) + def winforms_handle_created(self, sender, e): + self._hwnd = int(self.native.Handle.ToString()) + self._set_subclass() + + def winforms_handle_destroyed(self, sender, e): + # Remove the subclass when a handle is destroyed to prevent a memory leak. + self._remove_subclass() + + def winforms_retrieve_virtual_item(self, sender, e): + # Because ListView is in VirtualMode, it's necessary implement + # VirtualItemsSelectionRangeChanged event to create ListViewItem + # when it's needed + e.Item = self._toga_retrieve_virtual_item(e.ItemIndex)[0] + def winforms_cache_virtual_items(self, sender, e): if ( self._cache @@ -237,7 +287,7 @@ def winforms_search_for_virtual_item(self, sender, e): e.Index = i def winforms_item_selection_changed(self, sender, e): - self.interface.on_select() + self._process_selection_change() def winforms_virtual_items_selection_range_changed(self, sender, e): # Event handler for the ListView.VirtualItemsSelectionRangeChanged @@ -250,18 +300,24 @@ def winforms_virtual_items_selection_range_changed(self, sender, e): # This is a workaround to avoid calling the on_select() method twice # when selecting a new item to replace an already selected item. if len(list(self.native.SelectedIndices)) > 1: - self.interface.on_select() + self._process_selection_change() def winforms_double_click(self, sender, e): hit_test = self.native.HitTest(e.X, e.Y) item = hit_test.Item if item is not None: - self.interface.on_activate(row=self._data[item.Index]) + self._process_activation(e.X, e.Y, item) else: # pragma: no cover # Double clicking outside of an item apparently doesn't raise the event, but # that isn't guaranteed by the documentation. pass + def set_bounds(self, x, y, width, height): + super().set_bounds(x, y, width, height) + if self._pending_resize: + self._pending_resize = False + self._resize_columns() + def _create_column(self, toga_column): col = WinForms.ColumnHeader() if self._show_headings: @@ -278,30 +334,10 @@ def _resize_columns(self): for col in self.native.Columns: col.Width = width - def change_source(self, source): - self.update_data() - def _icon_index(self, row, column) -> int: icon = column.icon(row) return -1 if icon is None else self._image_index(icon._impl) - def _new_item(self, index): - row = self._data[index] - - missing_value = self.interface.missing_value - lvi = WinForms.ListViewItem( - [column.text(row, missing_value) for column in self._columns], - ) - if any(column.widget(row) is not None for column in self._columns): - warn( - "Winforms does not support the use of widgets in cells", - stacklevel=1, - ) - - icon_indices = tuple(self._icon_index(row, column) for column in self._columns) - - return (lvi, icon_indices) - def _image_index(self, icon): images = self.native.SmallImageList.Images key = str(icon.path) @@ -311,31 +347,27 @@ def _image_index(self, icon): images.Add(key, icon.bitmap) return index - def update_data(self): - self.native.VirtualListSize = len(self._data) - self._cache = [] + def _construct_new_item(self, raw_item, indent: None | int = None): + missing_value = self.interface.missing_value + lvi = WinForms.ListViewItem( + [column.text(raw_item, missing_value) for column in self._columns], + ) - def insert(self, index, item): - self.update_data() + if indent is not None: + lvi.IndentCount = indent - def change(self, item): - self.update_data() + icon_indices = tuple( + self._icon_index(raw_item, column) for column in self._columns + ) - def remove(self, index, item): + return (lvi, icon_indices) + + def change_source(self, source): self.update_data() def clear(self): self.update_data() - def get_selection(self): - selected_indices = list(self.native.SelectedIndices) - if self._multiple_select: - return selected_indices - elif len(selected_indices) == 0: - return None - else: - return selected_indices[0] - def scroll_to_row(self, index): self.native.EnsureVisible(index) diff --git a/winforms/src/toga_winforms/widgets/tree.py b/winforms/src/toga_winforms/widgets/tree.py new file mode 100644 index 0000000000..bb4eee041e --- /dev/null +++ b/winforms/src/toga_winforms/widgets/tree.py @@ -0,0 +1,914 @@ +from collections.abc import Iterable, Iterator +from ctypes import POINTER, byref, c_wchar_p, cast +from ctypes.wintypes import HDC, HWND, LPARAM, RECT, UINT, WPARAM +from functools import partial +from warnings import warn + +import System.Windows.Forms as WinForms + +from toga.handlers import WeakrefCallable +from toga.sources.tree_source import Node, TreeSourceT + +from ..libs import windowconstants as wc +from ..libs.comctl32 import ( + DefSubclassProc, + ImageList_Draw, + RemoveWindowSubclass, +) +from ..libs.comctl32classes import NMHDR, NMLVCUSTOMDRAW, NMLVDISPINFOW +from ..libs.gdi32 import SetTextColor +from ..libs.user32 import DrawTextW, FillRect, GetSysColor +from ..libs.win32 import LRESULT +from .table import Table + + +class StateNode: + """A wrapper for a TreeSource Node that also contains display data. + + Attributes: + node: The associated Node. + tree: The StateTree which the StateNode is a part of. + depth: The smallest number of nodes between the StateNode and a root. + children: A list of child StateNode instances. This is None for leaf nodes. + text (non-leaf only): The display text of the Node. + arrow (non-leaf only): The expansion/contraction arrow of the StateNode. + icon (non-leaf only): The icon of the Node. + """ + + def __init__(self, node: Node, state_tree, depth: int): + """Initializes an instance for a given node and its relation to a StateTree. + + :param Node node: The underlying Node. + :param StateTree state_tree: The StateTree of which the StateNode is a part. + :param int depth: The smallest number of nodes between the StateNode and a + root. + """ + + self.node = node + self.tree = state_tree + self.depth = depth + + self._is_open = False + + self.children = None + if self.node.can_have_children(): + self.children = [StateNode(child, self.tree, depth + 1) for child in node] + + self.text = c_wchar_p("") + self.arrow = c_wchar_p("") + self.icon: int + + def __len__(self) -> int: + if self.children is None: + return 0 + else: + return len(self.children) + + def __iter__(self) -> Iterator: + return iter(self.children or []) + + def branch_iter(self, display=True) -> Iterable: + yield from display_branch_iter(self, ignore_closed=not display) + + @property + def is_leaf(self) -> bool: + """Can the Node (and hence StateNode) have children?""" + return self.children is None + + @property + def is_open(self) -> bool: + """Is the node open (expanded)? Leaf nodes are always closed (contracted).""" + return self._is_open + + @is_open.setter + def is_open(self, value) -> None: + if self.is_leaf: + self._is_open = False + else: + self._is_open = value + + def toggle_state(self, notify_tree: bool = True) -> bool: + """Toggles the state (open/closed) of the StateNode. + + :param bool notify_tree: Whether a notify is sent to the StateTree. A toggle + command from the StateTree would have notify_tree=False, otherwise this + should be notify_tree=True. + :return: A bool indicating whether a change of selection has occurred. + """ + if not self.is_leaf: + self.is_open = not self.is_open + if notify_tree: + return self.tree._display_list_toggle(self) + + return False + + def set_all_states(self, all_open: bool) -> None: + """Sets the state (open/closed) of all the child StateNode instances. + + :param bool all_open: Should the children be open (expanded) or closed + (contracted)? + """ + self.is_open = all_open + if not self.is_leaf: + for child in self.children: + child.set_all_states(all_open) + + def insert(self, index: int, node: Node) -> bool: + """Inserts a child StateNode for a given Node at a given index. + + :param int index: The index where the child StateNode will be placed in the + children list. + :param Node node: The Node from which to make a child StateNode. + :return: A bool indicating whether a refresh of the ListView is needed. + """ + refresh_needed = self.is_leaf + child = StateNode(node, self.tree, self.depth + 1) + if self.is_leaf: + self.children = [child] + else: + self.children.insert(index, child) + + self.tree._display_list_adjust(True, child) + return refresh_needed + + def remove(self, index: int) -> bool: + """Removes a child at a given index. + + :param int index: The index of the StateNode to be deleted. + :return: A bool indicating whether a change of selection has occurred. + """ + child = self.children[index] + notify_select = False + # Close the child node if it is open. + if child.is_open: + notify_select = child.toggle_state() + + notify_select = self.tree._display_list_adjust(False, child) or notify_select + + del self.children[index] + return notify_select + + +class StateTree(StateNode): + """A wrapper for a TreeSourceT that also contains display data. + + A StateTree manages the current status of the Tree widget via instances of the + StateNode class. This information is used to construct a display list, which will + be displayed by the ListView UI. Selections on the ListView UI are recorded via + their index in the display list. Changes to the StateNodes are sent to the + StateTree and then both the display list and the selected index list are updated + appropriately. + + Attributes: + tree_source: The TreeSourceT used to build the StateTree. + tree: The StateTree itself (from StateNode). + children: A list of root StateNodes, None for leaf nodes. + depth: -1 (from StateNode). + + text (non-leaf only): The display text of the Node. + arrow (non-leaf only): The expansion/contraction arrow of the StateNode. + icon (non-leaf only): The icon of the Node. + """ + + ################################################################################# + # Overrides/extensions of StateNode methods + ################################################################################# + + def __init__(self, tree_source: TreeSourceT): + """Initializes the instance for a TreeSourceT. + + :param TreeSourceT tree_source: The TreeSourceT used to build the StateTree. + """ + self.tree_source = tree_source + self.children = [ + StateNode(tree_source[i], self, 0) for i in range(len(tree_source)) + ] + + self.tree = self + self.depth = -1 + self._display_list: list[StateNode] = [] + self.display_list_refresh() + self._selected_indices: list[int] = [] + + @property + def is_leaf(self) -> bool: + """A StateTree cannot be a leaf node by definition.""" + return False + + @property + def is_open(self) -> bool: + """A StateTree is always open (expanded).""" + return True + + @is_open.setter + def is_open(self, value) -> None: + pass + + def toggle_state(self, notify_tree: bool = False) -> bool: + """A StateTree is always open (expanded).""" + return False + + def set_all_states(self, all_open: bool) -> None: + """Sets the state (open/closed) of all the child StateNode instances. + + :param bool all_open: Should the children be open (expanded) or closed + (contracted)? + """ + super().set_all_states(all_open) + self.display_list_refresh() + + ################################################################################# + # Display list methods + ################################################################################# + + @property + def display_list(self) -> list[StateNode]: + """The list of StateNodes which is displayed on the ListView instance.""" + return self._display_list + + def display_list_refresh(self): + """Refreshes the display list to reflect the current StateTree properties.""" + self._display_list: list[StateNode] = list(self.branch_iter(display=True)) + + def display_list_toggle_index(self, index) -> bool: + """Toggles the state (open/closed) of a StateNode in the display list. + + :param int index: The index of the StateNode in the display list. + :return: A bool indicating whether a change of selection has occurred. + """ + state_node = self._display_list[index] + state_node.toggle_state(notify_tree=False) + + insert: bool = state_node.is_open + sublist: list[StateNode] = list(state_node.branch_iter(display=True)) + start_index: int = index + 1 + + return self._display_list_modifier(insert, sublist, start_index) + + def _display_list_adjust(self, insert: bool, item: StateNode) -> bool: + """Adjusts the display list based on an item insertion or removal. + + :param bool insert: Is an item being inserted or removed? + :param StateNode item: The item being inserted or removed. + :return: For insert=True, a bool indicating whether a ListView refresh is + needed. For insert=False, a bool indicating whether a change of selection + has occurred. + """ + # Find the index of item in the display list. + start_index = -1 + for index, state_node in enumerate(self.branch_iter(display=True)): + if item == state_node: + start_index = index + + # If the item is not in the display list there's no need to modify the + # display list. + if start_index == -1: + return False + + notify_select = self._display_list_modifier(insert, [item], start_index) + if not self.insert: + return notify_select + + return False + + def _display_list_modifier( + self, insert: bool, sublist: list[StateNode], start_index: int + ) -> bool: + """Modifies the display list by either inserting or removing a sublist. + + The sublist contains either a single element, or a whole branch which will be + expanded/contracted during a toggle operation. + + :param bool insert: Is the sublist being inserted or removed? + :param int start_index: The index in the display list where the first item + of the sublist is/will be. + :return: A bool indicating whether a change of selection has occurred. + """ + if insert: + self._display_list = ( + self._display_list[:start_index] + + sublist + + self._display_list[start_index:] + ) + else: + self._display_list = ( + self._display_list[:start_index] + + self._display_list[start_index + len(sublist) :] + ) + + return self._selection_modifier(insert, start_index, len(sublist)) + + def _display_list_toggle(self, state_node: StateNode) -> bool: + """Updates the display list to reflect a node being toggled. + + :param StateNode state_node: The StateNode being toggled. + :return: A bool indicating whether a change of selection has occurred. + """ + insert: bool = state_node.is_open + sublist: list[StateNode] = list(state_node.branch_iter(display=True)) + try: + start_index: int = self._display_list.index(state_node) + 1 + except ValueError: + # state_node is not in the display list, so no need to change it. + return False + + return self._display_list_modifier(insert, sublist, start_index) + + ################################################################################# + # Selected Indices list methods + ################################################################################# + + @property + def selected_indices(self) -> list[int]: + """Indices of the display list that corresponds to the ListView selection.""" + return self._selected_indices + + def selected_indices_from_ui(self, selected_indices: list[int]): + """Updates the selected indices list of the StateNode to match the UI.""" + self._selected_indices = selected_indices + + def _selection_modifier( + self, insert: bool, start_index: int, range_size: int + ) -> bool: + """Modifies the selected indices list based on changes to the display list. + + Indices are changed based on the size and position of the sublist, as well as + if the sublist is inserted or removed. Indices smaller than the start index + are not changed. If the sublist is removed, any index corresponding to an + item in the sublist is also removed. The other indices are shifted up or + down based on the size of the sublist. + + :param bool insert: Is the sublist being inserted or removed? + :param int start_index: The index in the display list where the first item + of the sublist is/will be. + :param int range_size: The size of the sublist being inserted/removed. + :return: A bool indicating whether a change of selection has occurred. + """ + selection_updater = extend_indices if insert else reduce_indices + selection_updater = partial(selection_updater, start_index, range_size) + + def non_negative(x: int) -> bool: + return x >= 0 + + modified_indices = list( + filter(non_negative, map(selection_updater, self._selected_indices)) + ) + notify_select = len(modified_indices) < len(self._selected_indices) + + self._selected_indices = modified_indices + return notify_select + + ################################################################################# + # Utility methods + ################################################################################# + + def find_state_node(self, node: Node) -> StateNode | None: + """Searches in the StateTree for a StateNode associated to a given Node. + + :param Node node: The node for which to find the corresponding StateNode. + :return: If found the StateNode is returned, otherwise None is returned. + """ + for state_node in self.branch_iter(display=False): + if state_node.node == node: + return state_node + + return None + + +def display_branch_iter( + roots: Iterable[StateNode | StateTree], ignore_closed: bool = False +): + """An iterator for a branch of a StateTree or Node.""" + for root in roots: + yield from display_branch_recursive(root, ignore_closed) + + +def display_branch_recursive(branch: StateNode, ignore_closed: bool = False): + """A recursive element to be used with the iterator display_branch_iter.""" + if branch.is_leaf: + yield branch + else: + yield branch + + if branch.is_open or ignore_closed: + for sub_branch in branch: + yield from display_branch_recursive(sub_branch, ignore_closed) + + +def reduce_indices(reduction_start_index: int, reduction_size: int, index: int) -> int: + if index < reduction_start_index: + return index + elif index < reduction_start_index + reduction_size: + return -1 + else: + return index - reduction_size + + +def extend_indices(extension_start_index: int, extension_size: int, index: int) -> int: + if index < extension_start_index: + return index + else: + return index + extension_size + + +def index_modifier( + indices: list[int], insert: bool, start_index: int, range_size: int +) -> list[int]: + selection_updater = extend_indices if insert else reduce_indices + selection_updater = partial(selection_updater, start_index, range_size) + + def non_negative(x: int) -> bool: + return x >= 0 + + return list(filter(non_negative, map(selection_updater, indices))) + + +class Tree(Table): + """The Tree widget works by storing the tree structure in a StateNode instance. + This instance creates and updates a list of StateNodes which is then displayed by + a ListView UI. + + The displayed rows for non-leaf nodes contain a state-change arrow which toggles + the node state when clicked, and highlights on mouse hover. This functionality is + achieved by a hit-test which is initiated by the mouse events. + + Since clicks on the state-change arrows shouldn't affect the selection, the list + of selected indices of the ListView UI is overridden on these clicks. + + The displayed rows for non-leaf nodes are custom painted by responding to the + NM_CUSTOMDRAW message. + """ + + ################################################################################# + # The following methods override/extend methods from Table. + ################################################################################# + + def create(self): + super().create() + self._state_tree: StateTree + + # _mouse_move_hit is a record of the latest hit-test for a MouseMove event. + # This is used in the _new_branch method to assign the correct state-change + # arrow to the non-leaf node display rows. + self._mouse_move_hit = -1 + + # _mouse_down_hit is a record of the latest hit-test for a MouseDown event. + # This is used by the _process_selection_change method to determine whether + # a MouseDown event should trigger a change of selection in the UI. + self._mouse_down_hit = -1 + + # These are widths that are used in the painting of the non-leaf node rows. + # The 4 here is undocumented left padding of the ListView UI. The amount is + # confirmed/updated during the drawing process. + self._left_padding = 4 + self._arrow_width = 21 + self._widths_set = False + self._indent = self.native.SmallImageList.ImageSize.Width + self._rect_right = 0 + + self.native.MouseMove += WeakrefCallable(self.winforms_mouse_move) + self.native.MouseLeave += WeakrefCallable(self.winforms_mouse_leave) + self.native.MouseDown += WeakrefCallable(self.winforms_mouse_down) + self.native.MouseClick += WeakrefCallable(self.winforms_mouse_click) + self.native.MouseUp += WeakrefCallable(self.winforms_mouse_up) + + def _subclass_proc( + self, + hWnd: int, + uMsg: int, + wParam: int, + lParam: int, + uIdSubclass: int, + dwRefData: int, + ) -> LRESULT: + """Override from Table: Same method, but also responds to NM_CUSTOMDRAW.""" + + # Remove the window subclass in the way recommended by Raymond Chen here: + # devblogs.microsoft.com/oldnewthing/20031111-00/?p=41883 + if uMsg == wc.WM_NCDESTROY: + RemoveWindowSubclass(hWnd, self.pfn_subclass, uIdSubclass) + + elif uMsg == wc.WM_REFLECT_NOTIFY: + phdr = cast(lParam, POINTER(NMHDR)).contents + code = phdr.code + if code == wc.LVN_GETDISPINFOW: + disp_info = cast(lParam, POINTER(NMLVDISPINFOW)).contents + self._lvn_getdispinfo(disp_info.item) + + elif code == wc.NM_CUSTOMDRAW: + # learn.microsoft.com/en-us/windows/win32/controls/nm-customdraw + nmlvcd = cast(lParam, POINTER(NMLVCUSTOMDRAW)).contents + return_flag = self._nm_customdraw(nmlvcd) + if return_flag is not None: + return self._nm_customdraw(nmlvcd) + + # Call the original window procedure + return DefSubclassProc(HWND(hWnd), UINT(uMsg), WPARAM(wParam), LPARAM(lParam)) + + def _new_item(self, index): + """Override from Table: Same method for leaf nodes, but new for non-leaf.""" + state_node = self.display_list[index] + node = state_node.node + if any(column.widget(node) is not None for column in self._columns): + warn( + "Winforms does not support the use of widgets in cells", + stacklevel=1, + ) + + if state_node.is_leaf: + return self._construct_new_item(node, state_node.depth) + + return self._new_branch(index, state_node) + + def _process_activation(self, x, y, list_view_item): + """Activates for double-click on an item but not on state-change arrows.""" + if self._hit_test_arrow(x, y) == -1: + self.interface.on_activate( + node=self.display_list[list_view_item.Index].node + ) + + def _process_selection_change(self): + """Overrides state-change-arrow selection events and allows the rest.""" + if self._mouse_down_hit >= 0: + # If this _selected_indices_tree_to_ui was missing, the winforms "change + # of selection" event would override the selection. + self._selected_indices_tree_to_ui() + else: + self._selected_indices_ui_to_tree() + self.interface.on_select() + + def update_data(self): + self._state_tree = StateTree(self._data) + self.native.VirtualListSize = len(self.display_list) + self._cache = [] + + def insert(self, index, item, parent=None): + state_parent = self._get_state_parent(parent) + if state_parent is None: + return + + refresh_needed = state_parent.insert(index, item) + self._update_list(refresh=refresh_needed) + + def change(self, item): + self._update_list(refresh=True) + + def remove(self, index, item, parent=None): + state_parent = self._get_state_parent(parent) + if state_parent is None: + return + + notify_select = state_parent.remove(index) + self._update_list(notify_select) + + def get_selection(self): + if len(self.selected_indices) < 1: + return None + elif not self._multiple_select: + return self.display_list[self.selected_indices[0]].node + else: + return [self.display_list[i].node for i in self.selected_indices] + + ################################################################################# + # The following methods are not from Table. + ################################################################################# + + @property + def display_list(self) -> list[StateNode]: + """The list of StateNodes which is currently being displayed by the UI. + + Note that display_list should only be modified using the methods of StateTree + and StateNode that don't have preceding underscores. + """ + return self._state_tree.display_list + + @property + def selected_indices(self) -> list[int]: + """The list of currently selected indices. + + Note that this list is modified by the StateTree and StateNode instances.""" + return self._state_tree._selected_indices + + @selected_indices.setter + def selected_indices(self, indices: list[int]): + notify_select = self._state_tree._selected_indices != indices + self._state_tree._selected_indices = indices + self._selected_indices_tree_to_ui(notify_select) + + def _hit_test_arrow(self, x: int, y: int) -> int: + """Tests whether given coordinates are over a state-change arrow. + + :param int x: The horizontal coordinate relative to the UI client area. + :param int y: The vertical coordinate relative to the UI client area. + :return: If the (x,y) position is over a state-change arrow then the test + returns the index of the StateNode. If the (x,y) position is not over + any items -2 is returned. Otherwise -1 is returned (this corresponds + to a normal click on a ListView item). + """ + item = self.native.HitTest(x, y).Item + if item is None: + return -2 + + state_node = self.display_list[item.Index] + if state_node.is_leaf: + return -1 + + x_arrow = self._left_padding + (state_node.depth + 1) * self._indent + x_arrow += self._arrow_width / 2 + y_arrow = item.Bounds.Y + item.Bounds.Height / 2 + + norm = (float(x - x_arrow)) ** 2 + (float(y - y_arrow)) ** 2 + if norm < (self._arrow_width**2) / 2: + return item.Index + + return -1 + + def _set_mouse_move_hit(self, index): + if index == self._mouse_move_hit: + return + + old_index = self._mouse_move_hit + self._mouse_move_hit = index + self._cache = [] + for i in {index, old_index}: + if i >= 0: + self.native.RedrawItems(i, i, False) + + def _selected_indices_ui_to_tree(self): + """This method updates the state tree to reflect the UI selection.""" + self._state_tree.selected_indices_from_ui(list(self.native.SelectedIndices)) + + def _selected_indices_tree_to_ui(self, notify_select: bool = False): + """Updates the UI to reflect the selected indices list from StateTree.""" + self.native.ItemSelectionChanged -= self.selection_listener_single + self.native.VirtualItemsSelectionRangeChanged -= self.selection_listener_multi + + for index in self.native.SelectedIndices: + self.native.Items[index].Selected = False + + for index in self.selected_indices: + self.native.Items[index].Selected = True + + self.native.ItemSelectionChanged += self.selection_listener_single + self.native.VirtualItemsSelectionRangeChanged += self.selection_listener_multi + + if notify_select: + self.interface.on_select() + + def winforms_mouse_move(self, sender, e): + self._set_mouse_move_hit(self._hit_test_arrow(e.X, e.Y)) + + def winforms_mouse_leave(self, sender, e): + """Resets hit test variables when mouse leaves the client area. + + This methods is needed because quick mouse movements, beginning at the state- + change arrow and ending outside the ListView client area, may not register + as MouseMove events (since the cursor is not within the client area). + + Similarly, there is a need to reset _mouse_down_hit since a click and drag + movement beginning at the state-change arrow will not register as a + MouseClick or MouseUp event. + """ + self._mouse_down_hit = -1 + self._set_mouse_move_hit(-1) + + def winforms_mouse_down(self, sender, e): + """Sets _mouse_down_hit and toggles a state change if it is at least 0.""" + hit_index = self._hit_test_arrow(e.X, e.Y) + self._mouse_down_hit = hit_index + + if hit_index >= 0: + notify_select = self._state_tree.display_list_toggle_index(hit_index) + self._update_list(notify_select) + + def winforms_mouse_click(self, sender, e): + """Corrects the UI selection based on the hit-test. + + A MouseClick event is triggered when a MouseDown and MouseUp event occurs + without the cursor moving in between. If this method where not present, the + MouseClick event over a state-change arrow clears the UI selection when the + clicked row is not selected. + """ + hit_index = self._hit_test_arrow(e.X, e.Y) + + if hit_index != -1: + self._selected_indices_tree_to_ui() + + def winforms_mouse_up(self, sender, e): + """Resets the _mouse_down_hit attribute.""" + if self._mouse_down_hit < -1: + self._process_selection_change() + + self._mouse_down_hit = -1 + + def _set_widths(self, hdc, rect): + """Determines _left_padding and _arrow_width during the first custom draw.""" + text_format = wc.DT_CALCRECT | wc.DT_NOCLIP + lengths = [] + + for arrow in ["\u25bc", "\u25b6", "\u25bd", "\u25b7"]: + rect_copy = RECT.from_buffer_copy(rect) + DrawTextW(hdc, c_wchar_p(arrow), -1, byref(rect_copy), text_format) + lengths.append(rect_copy.right - rect_copy.left) + + self._left_padding = rect.left + self._arrow_width = max(lengths) + self._widths_set = False + + def _nm_customdraw(self, nmlvcd) -> int | None: + """Paints the non-leaf node items.""" + # learn.microsoft.com/en-us/windows/win32/controls/using-custom-draw + if nmlvcd.nmcd.dwDrawStage == wc.CDDS_PREPAINT: + return wc.CDRF_NOTIFYITEMDRAW + + elif nmlvcd.nmcd.dwDrawStage == wc.CDDS_ITEMPREPAINT: + index = nmlvcd.nmcd.dwItemSpec + state_node = self.display_list[index] + if not state_node.is_leaf: + hdc = HDC(nmlvcd.nmcd.hdc) + FillRect(hdc, byref(nmlvcd.nmcd.rc), wc.COLOR_WINDOW + 1) + + self._rect_right = nmlvcd.nmcd.rc.right + + return wc.CDRF_NOTIFYSUBITEMDRAW + + elif wc.CDDS_SUBITEM | wc.CDDS_ITEMPREPAINT: + # Don't need to check state_node.is_leaf since this block is only + # accessed after CDRF_NOTIFYSUBITEMDRAW is returned. + + # Skip drawing for subitems + if nmlvcd.iSubItem > 0: + return wc.CDRF_SKIPDEFAULT + + hdc = HDC(nmlvcd.nmcd.hdc) + rect = RECT.from_buffer_copy(nmlvcd.nmcd.rc) + index = nmlvcd.nmcd.dwItemSpec + state_node = self.display_list[index] + is_selected = self.native.Items[index].Selected + + # Set the width constants + if not self._widths_set: + self._set_widths(hdc, nmlvcd.nmcd.rc) + + # Set the colors based on whether the item is selected. + if is_selected: + text_color = wc.COLOR_HIGHLIGHTTEXT + back_color = wc.COLOR_HIGHLIGHT + else: + text_color = wc.COLOR_HOTLIGHT + back_color = wc.COLOR_WINDOW + SetTextColor(hdc, GetSysColor(text_color)) + text_format = wc.DT_SINGLELINE | wc.DT_VCENTER | wc.DT_WORD_ELLIPSIS + + # Draw the icon + rect.left = rect.left + state_node.depth * self._indent + if state_node.icon >= 0: + ImageList_Draw( + HWND(int(self.native.SmallImageList.Handle.ToString())), + state_node.icon, + hdc, + rect.left, + divmod(rect.top + rect.bottom - self._indent, 2)[0], + wc.ILD_SELECTED if is_selected else wc.ILD_NORMAL, + ) + + # Draw the background (mainly for selection) + rect.left = rect.left + self._indent + rect.right = self._rect_right + FillRect(hdc, byref(rect), back_color + 1) + + # Draw the arrow, making sure the click location is in its center. + rect.right = rect.left + self._arrow_width + DrawTextW( + hdc, state_node.arrow, -1, byref(rect), text_format | wc.DT_HCENTER + ) + + # Draw the text + rect.left = rect.left + self._arrow_width + rect.right = self._rect_right + DrawTextW(hdc, state_node.text, -1, byref(rect), text_format) + + # Get the bounding rectangle of the drawn text. + DrawTextW( + hdc, state_node.text, -1, byref(rect), text_format | wc.DT_CALCRECT + ) + + rect.left = rect.right + self._indent + rect.top = ( + nmlvcd.nmcd.rc.top + + divmod(nmlvcd.nmcd.rc.bottom - nmlvcd.nmcd.rc.top, 2)[0] + ) + rect.right = self._rect_right - self._indent + rect.bottom = rect.top + 1 + + if rect.left < rect.right: + FillRect(hdc, byref(rect), text_color + 1) + + return wc.CDRF_SKIPDEFAULT + + def _new_branch(self, index: int, state_node: StateNode): + """Collects the data corresponding to a non-leaf node item. + + The state-change arrow, text and icon index are all stored on the StateTree. + A blank listview item is returned so that the ListView instance doesn't throw + errors. + """ + missing_value = self.interface.missing_value + column = self._columns[0] + node = state_node.node + + if index == self._mouse_move_hit: + arrow = "\u25bc" if state_node.is_open else "\u25b6" + else: + arrow = "\u25bd" if state_node.is_open else "\u25b7" + + # Store the c_wchar_p objects on the StateNodes to prevent them from being + # garbage collected. + state_node.arrow = c_wchar_p(arrow) + state_node.text = c_wchar_p(" " + column.text(node, missing_value)) + state_node.icon = self._icon_index(node, column) + + return ( + WinForms.ListViewItem([""] * len(self._columns)), + (-1,) * len(self._columns), + ) + + def _update_list(self, notify_select: bool = False, refresh: bool = False): + """Updates the display list and the UI. + + This method is called when toggling a StateNode, when adding/removing nodes, + and when modifying an item. + + :param bool notify_select: A bool indicating whether a change of selection + has occurred, and hence whether self.interface should be notified. + :param bool refresh: A bool indicating whether the ListView needs to be + repainted. + """ + self.native.VirtualListSize = len(self.display_list) + self._cache = [] + # This _selected_indices_tree_to_ui is needed for responsiveness. + self._selected_indices_tree_to_ui(notify_select) + + if refresh: + self.native.Refresh() + + def _get_state_parent(self, parent=None): + """Gets the StateNode/StateTree associated to parent. + + :param Node | None parent: The object for which to find the associated + StateNode/StateTree. + :return: If parent=None, then the state tree is returned. If parent is a Node + and no corresponding StateNode can be found None is returned. + Otherwise the StateNode corresponding to node is returned. + """ + if parent is None: + return self._state_tree + else: + state_parent = self._state_tree.find_state_node(parent) + if state_parent is None: + warn( + f"Could not find an object managed by {self._state_tree} that " + + f"corresponds to {parent}", + stacklevel=1, + ) + + return state_parent + + def expand_node(self, node): + state_node = self._state_tree.find_state_node(node) + if state_node is None: + return + + if not state_node.is_open: + state_node.toggle_state() + self._update_list() + + def expand_all(self): + selection = None + for state_node in self._state_tree.full_open_iter(): + if not state_node.is_open: + state_node.toggle_state() + + self._update_list(selection) + + def collapse_node(self, node): + state_node = self._state_tree.find_state_node(node) + if state_node is None: + return + + if state_node.is_open: + notify_select = state_node.toggle_state() + self._update_list(notify_select) + + def collapse_all(self): + notify_select = False + # Close the roots first using _toggle_node_state so that the selection is + # updated correctly. + for state_node in self._state_tree.roots: + if state_node.is_open: + notify_select = state_node.toggle_state() or notify_select + + self._update_list(notify_select) + + # Close the non-visible nodes. + self._state_tree.set_all_states(is_open=False) From 37b17a89d5a35718a19f489a8af813ca4c20f00f Mon Sep 17 00:00:00 2001 From: Oliver-Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:17:55 +0100 Subject: [PATCH 02/30] Added background color change functionality --- winforms/src/toga_winforms/libs/gdi32.py | 16 ++++++++- winforms/src/toga_winforms/libs/win32.py | 1 + winforms/src/toga_winforms/widgets/tree.py | 38 +++++++++++++++++----- 3 files changed, 46 insertions(+), 9 deletions(-) diff --git a/winforms/src/toga_winforms/libs/gdi32.py b/winforms/src/toga_winforms/libs/gdi32.py index a745bad2ba..7e8c9f8d31 100644 --- a/winforms/src/toga_winforms/libs/gdi32.py +++ b/winforms/src/toga_winforms/libs/gdi32.py @@ -1,9 +1,23 @@ from ctypes import windll -from ctypes.wintypes import COLORREF, HDC +from ctypes.wintypes import BOOL, COLORREF, HDC + +from .win32 import HBRUSH, HGDIOBJ gdi32 = windll.GDI32 +# https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-createsolidbrush +CreateSolidBrush = gdi32.CreateSolidBrush +CreateSolidBrush.restype = HBRUSH +CreateSolidBrush.argtypes = [COLORREF] + + +# https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-deleteobject +DeleteObject = gdi32.DeleteObject +DeleteObject.restype = BOOL +DeleteObject.argtypes = [HGDIOBJ] + + # https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-settextcolor SetTextColor = gdi32.SetTextColor SetTextColor.restype = COLORREF diff --git a/winforms/src/toga_winforms/libs/win32.py b/winforms/src/toga_winforms/libs/win32.py index b217150bb9..1d17f7431d 100644 --- a/winforms/src/toga_winforms/libs/win32.py +++ b/winforms/src/toga_winforms/libs/win32.py @@ -9,3 +9,4 @@ RECT_PTR = POINTER(RECT) HBRUSH = HWND HIMAGELIST = HWND +HGDIOBJ = HWND diff --git a/winforms/src/toga_winforms/widgets/tree.py b/winforms/src/toga_winforms/widgets/tree.py index bb4eee041e..20a8847d78 100644 --- a/winforms/src/toga_winforms/widgets/tree.py +++ b/winforms/src/toga_winforms/widgets/tree.py @@ -5,6 +5,7 @@ from warnings import warn import System.Windows.Forms as WinForms +from System.Drawing import ColorTranslator from toga.handlers import WeakrefCallable from toga.sources.tree_source import Node, TreeSourceT @@ -16,7 +17,7 @@ RemoveWindowSubclass, ) from ..libs.comctl32classes import NMHDR, NMLVCUSTOMDRAW, NMLVDISPINFOW -from ..libs.gdi32 import SetTextColor +from ..libs.gdi32 import CreateSolidBrush, DeleteObject, SetTextColor from ..libs.user32 import DrawTextW, FillRect, GetSysColor from ..libs.win32 import LRESULT from .table import Table @@ -466,6 +467,13 @@ def create(self): self._indent = self.native.SmallImageList.ImageSize.Width self._rect_right = 0 + self._hbrush_back = CreateSolidBrush( + ColorTranslator.ToWin32(self.native.BackColor) + ) + self.native.BackColorChanged += WeakrefCallable( + self.winforms_back_color_changed + ) + self.native.MouseMove += WeakrefCallable(self.winforms_mouse_move) self.native.MouseLeave += WeakrefCallable(self.winforms_mouse_leave) self.native.MouseDown += WeakrefCallable(self.winforms_mouse_down) @@ -482,10 +490,12 @@ def _subclass_proc( dwRefData: int, ) -> LRESULT: """Override from Table: Same method, but also responds to NM_CUSTOMDRAW.""" - - # Remove the window subclass in the way recommended by Raymond Chen here: - # devblogs.microsoft.com/oldnewthing/20031111-00/?p=41883 if uMsg == wc.WM_NCDESTROY: + # Delete the brushes + DeleteObject(self._hbrush_back) + + # Remove the window subclass in the way recommended by Raymond Chen here: + # devblogs.microsoft.com/oldnewthing/20031111-00/?p=41883 RemoveWindowSubclass(hWnd, self.pfn_subclass, uIdSubclass) elif uMsg == wc.WM_REFLECT_NOTIFY: @@ -701,6 +711,15 @@ def winforms_mouse_up(self, sender, e): self._mouse_down_hit = -1 + def winforms_back_color_changed(self, sender, e): + """Updates the win32 brush for the background color""" + # Delete the old brush + DeleteObject(self._hbrush_back) + # Create the new brush + self._hbrush_back = CreateSolidBrush( + ColorTranslator.ToWin32(self.native.BackColor) + ) + def _set_widths(self, hdc, rect): """Determines _left_padding and _arrow_width during the first custom draw.""" text_format = wc.DT_CALCRECT | wc.DT_NOCLIP @@ -726,7 +745,7 @@ def _nm_customdraw(self, nmlvcd) -> int | None: state_node = self.display_list[index] if not state_node.is_leaf: hdc = HDC(nmlvcd.nmcd.hdc) - FillRect(hdc, byref(nmlvcd.nmcd.rc), wc.COLOR_WINDOW + 1) + FillRect(hdc, byref(nmlvcd.nmcd.rc), self._hbrush_back) self._rect_right = nmlvcd.nmcd.rc.right @@ -751,12 +770,15 @@ def _nm_customdraw(self, nmlvcd) -> int | None: self._set_widths(hdc, nmlvcd.nmcd.rc) # Set the colors based on whether the item is selected. + # The "+1" is needed for system brushes with FillRect, documented here: + # learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-fillrect if is_selected: text_color = wc.COLOR_HIGHLIGHTTEXT - back_color = wc.COLOR_HIGHLIGHT + back_color = wc.COLOR_HIGHLIGHT + 1 else: text_color = wc.COLOR_HOTLIGHT - back_color = wc.COLOR_WINDOW + back_color = self._hbrush_back + SetTextColor(hdc, GetSysColor(text_color)) text_format = wc.DT_SINGLELINE | wc.DT_VCENTER | wc.DT_WORD_ELLIPSIS @@ -775,7 +797,7 @@ def _nm_customdraw(self, nmlvcd) -> int | None: # Draw the background (mainly for selection) rect.left = rect.left + self._indent rect.right = self._rect_right - FillRect(hdc, byref(rect), back_color + 1) + FillRect(hdc, byref(rect), back_color) # Draw the arrow, making sure the click location is in its center. rect.right = rect.left + self._arrow_width From c0a1e3d51c5adcdb8473b1e9b553fcf301a0fc7d Mon Sep 17 00:00:00 2001 From: Oliver-Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:04:09 +0100 Subject: [PATCH 03/30] Added "row path" __getitem__ to StateNode --- winforms/src/toga_winforms/widgets/tree.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/winforms/src/toga_winforms/widgets/tree.py b/winforms/src/toga_winforms/widgets/tree.py index 20a8847d78..8d3e722076 100644 --- a/winforms/src/toga_winforms/widgets/tree.py +++ b/winforms/src/toga_winforms/widgets/tree.py @@ -64,6 +64,21 @@ def __len__(self) -> int: return 0 else: return len(self.children) + + def __getitem__(self, row_path: list[int] | tuple[int]): + """Gets an item based on a row path list or tuple + + :param list[int] | tuple[int] row_path: A list or tuple of indices. The first + is an index of a child, say A, in the list of children, the next is an + index in the list of children of A, and so on... + :return: The StateNode at the end of the path. + """ + if len(row_path) < 1: + return self + elif len(row_path) == 1: + return self.children[row_path[0]] + else: + return self.children[row_path[0]][row_path[1:]] def __iter__(self) -> Iterator: return iter(self.children or []) From b0515a51c87aaee2e129a06fb36e863698c51917 Mon Sep 17 00:00:00 2001 From: Oliver-Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Wed, 11 Mar 2026 10:25:10 +0100 Subject: [PATCH 04/30] Fixed and simplified expand/collapse functionality --- winforms/src/toga_winforms/widgets/tree.py | 138 ++++++++++----------- 1 file changed, 67 insertions(+), 71 deletions(-) diff --git a/winforms/src/toga_winforms/widgets/tree.py b/winforms/src/toga_winforms/widgets/tree.py index 8d3e722076..cad289349f 100644 --- a/winforms/src/toga_winforms/widgets/tree.py +++ b/winforms/src/toga_winforms/widgets/tree.py @@ -65,15 +65,16 @@ def __len__(self) -> int: else: return len(self.children) - def __getitem__(self, row_path: list[int] | tuple[int]): + def __getitem__(self, row_path: list[int] | tuple[int] | None): """Gets an item based on a row path list or tuple :param list[int] | tuple[int] row_path: A list or tuple of indices. The first is an index of a child, say A, in the list of children, the next is an index in the list of children of A, and so on... - :return: The StateNode at the end of the path. + :return: The StateNode at the end of the path. None returns the StateNode + itself. """ - if len(row_path) < 1: + if row_path is None or len(row_path) < 1: return self elif len(row_path) == 1: return self.children[row_path[0]] @@ -96,38 +97,35 @@ def is_open(self) -> bool: """Is the node open (expanded)? Leaf nodes are always closed (contracted).""" return self._is_open - @is_open.setter - def is_open(self, value) -> None: - if self.is_leaf: - self._is_open = False - else: - self._is_open = value - - def toggle_state(self, notify_tree: bool = True) -> bool: + def toggle_state(self, update_display: bool) -> bool: """Toggles the state (open/closed) of the StateNode. - :param bool notify_tree: Whether a notify is sent to the StateTree. A toggle - command from the StateTree would have notify_tree=False, otherwise this - should be notify_tree=True. + :param bool update_display: Whether the display list should be updated. If + the StateNode is visible this should most likely be True. Otherwise this + should be False. :return: A bool indicating whether a change of selection has occurred. """ if not self.is_leaf: - self.is_open = not self.is_open - if notify_tree: + self._is_open = not self._is_open + if update_display: return self.tree._display_list_toggle(self) return False - def set_all_states(self, all_open: bool) -> None: - """Sets the state (open/closed) of all the child StateNode instances. + def set_branch_state(self, set_open: bool, is_visible: bool) -> bool: + """Sets the state (open/closed) for a StateNode and all its descendants. - :param bool all_open: Should the children be open (expanded) or closed - (contracted)? + :param bool set_open: Should the state be set to open? + :param bool is_visible: Is the state_node in the display list? + :return: A bool indicating whether a change of selection has occurred. """ - self.is_open = all_open - if not self.is_leaf: - for child in self.children: - child.set_all_states(all_open) + if self.is_open != set_open: + self.toggle_state(update_display=is_visible) + + notify_select = False + for child in self: + child_visible = self.is_open and is_visible + notify_select = child.set_branch_state(set_open, child_visible) or notify_select def insert(self, index: int, node: Node) -> bool: """Inserts a child StateNode for a given Node at a given index. @@ -154,12 +152,8 @@ def remove(self, index: int) -> bool: :return: A bool indicating whether a change of selection has occurred. """ child = self.children[index] - notify_select = False - # Close the child node if it is open. - if child.is_open: - notify_select = child.toggle_state() - notify_select = self.tree._display_list_adjust(False, child) or notify_select + notify_select = self.tree._display_list_adjust(False, child) del self.children[index] return notify_select @@ -220,7 +214,7 @@ def is_open(self) -> bool: def is_open(self, value) -> None: pass - def toggle_state(self, notify_tree: bool = False) -> bool: + def toggle_state(self, update_display: bool = False) -> bool: """A StateTree is always open (expanded).""" return False @@ -253,7 +247,7 @@ def display_list_toggle_index(self, index) -> bool: :return: A bool indicating whether a change of selection has occurred. """ state_node = self._display_list[index] - state_node.toggle_state(notify_tree=False) + state_node.toggle_state(update_display=False) insert: bool = state_node.is_open sublist: list[StateNode] = list(state_node.branch_iter(display=True)) @@ -261,7 +255,7 @@ def display_list_toggle_index(self, index) -> bool: return self._display_list_modifier(insert, sublist, start_index) - def _display_list_adjust(self, insert: bool, item: StateNode) -> bool: + def _display_list_adjust(self, insert: bool, state_node: StateNode) -> bool: """Adjusts the display list based on an item insertion or removal. :param bool insert: Is an item being inserted or removed? @@ -270,19 +264,26 @@ def _display_list_adjust(self, insert: bool, item: StateNode) -> bool: needed. For insert=False, a bool indicating whether a change of selection has occurred. """ - # Find the index of item in the display list. - start_index = -1 - for index, state_node in enumerate(self.branch_iter(display=True)): - if item == state_node: - start_index = index + # Find the index of state_node in the display list. + index = -1 + for index_loop, state_node_loop in enumerate(self.branch_iter(display=True)): + if state_node == state_node_loop: + index = index_loop - # If the item is not in the display list there's no need to modify the + # If state_node is not in the display list there's no need to modify the # display list. - if start_index == -1: + if index == -1: return False - notify_select = self._display_list_modifier(insert, [item], start_index) - if not self.insert: + # The remainder of this code block is only accessed if state_node is visible. + notify_select = False + + # If state_node is open and being removed, also remove its branch. + if state_node.is_open and not insert: + notify_select = self.display_list_toggle_index(index) or notify_select + + notify_select = self._display_list_modifier(insert, [state_node], index) or notify_select + if not insert: return notify_select return False @@ -705,6 +706,7 @@ def winforms_mouse_down(self, sender, e): if hit_index >= 0: notify_select = self._state_tree.display_list_toggle_index(hit_index) self._update_list(notify_select) + self.native.RedrawItems(hit_index, hit_index, False) def winforms_mouse_click(self, sender, e): """Corrects the UI selection based on the hit-test. @@ -910,42 +912,36 @@ def _get_state_parent(self, parent=None): ) return state_parent + + def set_branch_state(self, set_open: bool, branch: Node | None): + if branch == None: + state_node = self._state_tree + else: + state_node = self._state_tree.find_state_node(branch) - def expand_node(self, node): - state_node = self._state_tree.find_state_node(node) - if state_node is None: + if state_node is None or state_node.is_leaf: return - if not state_node.is_open: - state_node.toggle_state() - self._update_list() + if isinstance(state_node, StateTree): + is_visible = True + else: + try: + self.display_list.index(state_node) + is_visible = True + except ValueError: + is_visible = False + + notify_select = state_node.set_branch_state(set_open, is_visible) + self._update_list(notify_select) - def expand_all(self): - selection = None - for state_node in self._state_tree.full_open_iter(): - if not state_node.is_open: - state_node.toggle_state() + def expand_node(self, node): + self.set_branch_state(set_open = True, branch = node) - self._update_list(selection) + def expand_all(self): + self.set_branch_state(set_open = True, branch = None) def collapse_node(self, node): - state_node = self._state_tree.find_state_node(node) - if state_node is None: - return - - if state_node.is_open: - notify_select = state_node.toggle_state() - self._update_list(notify_select) + self.set_branch_state(set_open = False, branch = node) def collapse_all(self): - notify_select = False - # Close the roots first using _toggle_node_state so that the selection is - # updated correctly. - for state_node in self._state_tree.roots: - if state_node.is_open: - notify_select = state_node.toggle_state() or notify_select - - self._update_list(notify_select) - - # Close the non-visible nodes. - self._state_tree.set_all_states(is_open=False) + self.set_branch_state(set_open = False, branch = None) From cffad63a5192737691464c9ae1669ba3a02f1116 Mon Sep 17 00:00:00 2001 From: Oliver-Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:34:35 +0100 Subject: [PATCH 05/30] Fixed selection color when not focused. --- .../src/toga_winforms/libs/windowconstants.py | 2 + winforms/src/toga_winforms/widgets/tree.py | 42 +++++++++++-------- 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/winforms/src/toga_winforms/libs/windowconstants.py b/winforms/src/toga_winforms/libs/windowconstants.py index ea8348d933..03f043b880 100644 --- a/winforms/src/toga_winforms/libs/windowconstants.py +++ b/winforms/src/toga_winforms/libs/windowconstants.py @@ -16,6 +16,8 @@ COLOR_HIGHLIGHTTEXT = 14 COLOR_HOTLIGHT = 26 COLOR_WINDOW = 5 +COLOR_BTNFACE = 15 +COLOR_BTNTEXT = 18 # Draw Text DT_CALCRECT = 0x00000400 diff --git a/winforms/src/toga_winforms/widgets/tree.py b/winforms/src/toga_winforms/widgets/tree.py index cad289349f..d967babc41 100644 --- a/winforms/src/toga_winforms/widgets/tree.py +++ b/winforms/src/toga_winforms/widgets/tree.py @@ -64,12 +64,12 @@ def __len__(self) -> int: return 0 else: return len(self.children) - + def __getitem__(self, row_path: list[int] | tuple[int] | None): """Gets an item based on a row path list or tuple - + :param list[int] | tuple[int] row_path: A list or tuple of indices. The first - is an index of a child, say A, in the list of children, the next is an + is an index of a child, say A, in the list of children, the next is an index in the list of children of A, and so on... :return: The StateNode at the end of the path. None returns the StateNode itself. @@ -100,7 +100,7 @@ def is_open(self) -> bool: def toggle_state(self, update_display: bool) -> bool: """Toggles the state (open/closed) of the StateNode. - :param bool update_display: Whether the display list should be updated. If + :param bool update_display: Whether the display list should be updated. If the StateNode is visible this should most likely be True. Otherwise this should be False. :return: A bool indicating whether a change of selection has occurred. @@ -119,13 +119,15 @@ def set_branch_state(self, set_open: bool, is_visible: bool) -> bool: :param bool is_visible: Is the state_node in the display list? :return: A bool indicating whether a change of selection has occurred. """ - if self.is_open != set_open: + if self.is_open != set_open: self.toggle_state(update_display=is_visible) notify_select = False - for child in self: + for child in self: child_visible = self.is_open and is_visible - notify_select = child.set_branch_state(set_open, child_visible) or notify_select + notify_select = ( + child.set_branch_state(set_open, child_visible) or notify_select + ) def insert(self, index: int, node: Node) -> bool: """Inserts a child StateNode for a given Node at a given index. @@ -282,7 +284,9 @@ def _display_list_adjust(self, insert: bool, state_node: StateNode) -> bool: if state_node.is_open and not insert: notify_select = self.display_list_toggle_index(index) or notify_select - notify_select = self._display_list_modifier(insert, [state_node], index) or notify_select + notify_select = ( + self._display_list_modifier(insert, [state_node], index) or notify_select + ) if not insert: return notify_select @@ -790,8 +794,12 @@ def _nm_customdraw(self, nmlvcd) -> int | None: # The "+1" is needed for system brushes with FillRect, documented here: # learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-fillrect if is_selected: - text_color = wc.COLOR_HIGHLIGHTTEXT - back_color = wc.COLOR_HIGHLIGHT + 1 + if self.native.Focused: + text_color = wc.COLOR_HIGHLIGHTTEXT + back_color = wc.COLOR_HIGHLIGHT + 1 + else: + text_color = wc.COLOR_BTNTEXT # Color is undocumented + back_color = wc.COLOR_BTNFACE + 1 # Color is undocumented else: text_color = wc.COLOR_HOTLIGHT back_color = self._hbrush_back @@ -912,9 +920,9 @@ def _get_state_parent(self, parent=None): ) return state_parent - + def set_branch_state(self, set_open: bool, branch: Node | None): - if branch == None: + if branch is None: state_node = self._state_tree else: state_node = self._state_tree.find_state_node(branch) @@ -930,18 +938,18 @@ def set_branch_state(self, set_open: bool, branch: Node | None): is_visible = True except ValueError: is_visible = False - + notify_select = state_node.set_branch_state(set_open, is_visible) self._update_list(notify_select) def expand_node(self, node): - self.set_branch_state(set_open = True, branch = node) + self.set_branch_state(set_open=True, branch=node) def expand_all(self): - self.set_branch_state(set_open = True, branch = None) + self.set_branch_state(set_open=True, branch=None) def collapse_node(self, node): - self.set_branch_state(set_open = False, branch = node) + self.set_branch_state(set_open=False, branch=node) def collapse_all(self): - self.set_branch_state(set_open = False, branch = None) + self.set_branch_state(set_open=False, branch=None) From 4e2ee17e4083d95b59a5e34e4b090a250e875978 Mon Sep 17 00:00:00 2001 From: Oliver-Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:30:44 +0100 Subject: [PATCH 06/30] Fixed get_selection --- winforms/src/toga_winforms/widgets/tree.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/winforms/src/toga_winforms/widgets/tree.py b/winforms/src/toga_winforms/widgets/tree.py index d967babc41..c2e6cc45b7 100644 --- a/winforms/src/toga_winforms/widgets/tree.py +++ b/winforms/src/toga_winforms/widgets/tree.py @@ -592,12 +592,12 @@ def remove(self, index, item, parent=None): self._update_list(notify_select) def get_selection(self): - if len(self.selected_indices) < 1: + if self._multiple_select: + return [self.display_list[i].node for i in self.selected_indices] + elif len(self.selected_indices) == 0: return None - elif not self._multiple_select: - return self.display_list[self.selected_indices[0]].node else: - return [self.display_list[i].node for i in self.selected_indices] + return self.display_list[self.selected_indices[0]].node ################################################################################# # The following methods are not from Table. From 1670022f45700be46c9da56df7d58703b3c62a17 Mon Sep 17 00:00:00 2001 From: Oliver-Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:12:18 +0100 Subject: [PATCH 07/30] Added test probe for Tree widget --- winforms/tests_backend/widgets/tree.py | 152 +++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 winforms/tests_backend/widgets/tree.py diff --git a/winforms/tests_backend/widgets/tree.py b/winforms/tests_backend/widgets/tree.py new file mode 100644 index 0000000000..8454cecd3c --- /dev/null +++ b/winforms/tests_backend/widgets/tree.py @@ -0,0 +1,152 @@ +import asyncio, pytest + +from .table import TableProbe + +from System.Windows.Forms import ( + ColumnHeaderStyle, + ListView, + MouseButtons, + MouseEventArgs, +) + + + +class TreeProbe(TableProbe): + + def state_node(self, row_path): + self.state_tree = self.impl._state_tree + return self.state_tree[row_path] + + def display_index(self, row_path): + state_node = self.state_node(row_path) + return self.impl.display_list.index(state_node) + + + async def expand_tree(self): + self.impl.expand_all() + await asyncio.sleep(0.1) + + def is_expanded(self, node): + self.state_tree = self.impl._state_tree + state_node = self.state_tree.find_state_node(node) + + return state_node.is_open + + def child_count(self, row_path=None): + state_node = self.state_node(row_path) + return len(state_node) + + async def select_row(self, row_path, add=False): + display_index = self.display_index(row_path) + await super().select_row(row=display_index, add=add) + + async def activate_row(self, row_path): + display_index = self.display_index(row_path) + await super().select_row(row=display_index) + + bounds = self.native.Items[display_index].Bounds + self.native.OnMouseDoubleClick( + MouseEventArgs( + MouseButtons.Left, + clicks=2, + x=int((bounds.Left + bounds.Right) / 2), + y=int((bounds.Top + bounds.Bottom) / 2), + delta=0, + ) + ) + + def assert_cell_content(self, row_path, col, value=None, icon=None, widget=None): + if widget: + pytest.skip("This backend doesn't support widgets in Tables") + return + # Use the table assert_cell_content for non-leaf cells. However, the branch + # containing the node must be expanded to check that (otherwise the node is + # not in the display list). So expand that branch and then restore it after. + + state_node = self.state_node(row_path) + row_path_states = self.open_row_path(row_path) + + display_index = self.impl.display_list.index(state_node) + + if state_node.is_leaf: + super().assert_cell_content(display_index, col, value, icon, widget) + else: + # Try to access the row in the UI to make sure the row is created. + self.native.Items[display_index] + + if col==0: + text = state_node.text.value[1:] + else: + #For non-leaf nodes, only column 0 is displayed." + column = self.impl._columns[col] + node = state_node.node + text = column.text(node, self.impl.interface.missing_value) + + assert text == value + + if col==0 and icon is not None: + imagelist = self.native.SmallImageList + size = imagelist.ImageSize + assert size.Width == size.Height == 16 + + icon_index = state_node.icon_index + + actual = imagelist.Images[icon_index] + expected = Bitmap(icon._impl.bitmap, size) + for x in range(size.Width): + for y in range(size.Height): + assert actual.GetPixel(x, y) == expected.GetPixel(x, y) + + self.restore_row_path(row_path, row_path_states) + + + + def open_row_path(self, row_path): + # Expand all nodes along a row_path to make the specified node visible. + + # Keep a record of the node states along the row_path: + # True=Open/Expanded, False=Closed/Collapsed. + row_path_states = [] + + if row_path is None or len(row_path) < 2: + return row_path_states + + state_node = self.impl._state_tree + for i in row_path[:-1]: + state_node = state_node[(i,)] + + row_path_states.append(state_node.is_open) + if not state_node.is_open: + state_node.toggle_state(update_display = True) + self.impl._update_list(True) + + return row_path_states + + + def restore_row_path(self, row_path, row_path_states): + if row_path is None or len(row_path) < 2: + return + + state_tree = self.impl._state_tree + for i, _ in enumerate(row_path[:-1]): + if i == 0: + state_node = state_tree[row_path[:-1]] + else: + state_node = state_tree[row_path[:-(i+1)]] + + original_state = row_path_states[-(i+1)] + + if state_node.is_open != original_state: + state_node.toggle_state(update_display = True) + self.impl._update_list(True) + + + + + + + + + + + From 62cf0c3ac45e1d75bf15dcc2cd58322820fc0d7a Mon Sep 17 00:00:00 2001 From: Oliver-Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:17:24 +0100 Subject: [PATCH 08/30] Fixed formatting --- winforms/tests_backend/widgets/tree.py | 63 ++++++++++---------------- 1 file changed, 23 insertions(+), 40 deletions(-) diff --git a/winforms/tests_backend/widgets/tree.py b/winforms/tests_backend/widgets/tree.py index 8454cecd3c..c9586531dc 100644 --- a/winforms/tests_backend/widgets/tree.py +++ b/winforms/tests_backend/widgets/tree.py @@ -1,18 +1,16 @@ -import asyncio, pytest - -from .table import TableProbe +import asyncio +import pytest +from System.Drawing import Bitmap from System.Windows.Forms import ( - ColumnHeaderStyle, - ListView, MouseButtons, MouseEventArgs, ) +from .table import TableProbe class TreeProbe(TableProbe): - def state_node(self, row_path): self.state_tree = self.impl._state_tree return self.state_tree[row_path] @@ -20,7 +18,6 @@ def state_node(self, row_path): def display_index(self, row_path): state_node = self.state_node(row_path) return self.impl.display_list.index(state_node) - async def expand_tree(self): self.impl.expand_all() @@ -29,7 +26,7 @@ async def expand_tree(self): def is_expanded(self, node): self.state_tree = self.impl._state_tree state_node = self.state_tree.find_state_node(node) - + return state_node.is_open def child_count(self, row_path=None): @@ -60,12 +57,12 @@ def assert_cell_content(self, row_path, col, value=None, icon=None, widget=None) pytest.skip("This backend doesn't support widgets in Tables") return # Use the table assert_cell_content for non-leaf cells. However, the branch - # containing the node must be expanded to check that (otherwise the node is + # containing the node must be expanded to check that (otherwise the node is # not in the display list). So expand that branch and then restore it after. - + state_node = self.state_node(row_path) row_path_states = self.open_row_path(row_path) - + display_index = self.impl.display_list.index(state_node) if state_node.is_leaf: @@ -74,17 +71,17 @@ def assert_cell_content(self, row_path, col, value=None, icon=None, widget=None) # Try to access the row in the UI to make sure the row is created. self.native.Items[display_index] - if col==0: + if col == 0: text = state_node.text.value[1:] - else: - #For non-leaf nodes, only column 0 is displayed." + else: + # For non-leaf nodes, only column 0 is displayed." column = self.impl._columns[col] node = state_node.node text = column.text(node, self.impl.interface.missing_value) assert text == value - - if col==0 and icon is not None: + + if col == 0 and icon is not None: imagelist = self.native.SmallImageList size = imagelist.ImageSize assert size.Width == size.Height == 16 @@ -99,54 +96,40 @@ def assert_cell_content(self, row_path, col, value=None, icon=None, widget=None) self.restore_row_path(row_path, row_path_states) - - def open_row_path(self, row_path): # Expand all nodes along a row_path to make the specified node visible. # Keep a record of the node states along the row_path: # True=Open/Expanded, False=Closed/Collapsed. row_path_states = [] - + if row_path is None or len(row_path) < 2: return row_path_states - + state_node = self.impl._state_tree for i in row_path[:-1]: state_node = state_node[(i,)] row_path_states.append(state_node.is_open) - if not state_node.is_open: - state_node.toggle_state(update_display = True) + if not state_node.is_open: + state_node.toggle_state(update_display=True) self.impl._update_list(True) return row_path_states - def restore_row_path(self, row_path, row_path_states): if row_path is None or len(row_path) < 2: return - + state_tree = self.impl._state_tree for i, _ in enumerate(row_path[:-1]): if i == 0: state_node = state_tree[row_path[:-1]] else: - state_node = state_tree[row_path[:-(i+1)]] - - original_state = row_path_states[-(i+1)] - - if state_node.is_open != original_state: - state_node.toggle_state(update_display = True) - self.impl._update_list(True) - - - + state_node = state_tree[row_path[: -(i + 1)]] + original_state = row_path_states[-(i + 1)] - - - - - - + if state_node.is_open != original_state: + state_node.toggle_state(update_display=True) + self.impl._update_list(True) From f1d90c65c601972bb1885d28be8a472ef9b847fd Mon Sep 17 00:00:00 2001 From: Oliver-Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:20:14 +0100 Subject: [PATCH 09/30] Fixed regex string and enabled WinForms tree tests --- testbed/tests/widgets/test_tree.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/testbed/tests/widgets/test_tree.py b/testbed/tests/widgets/test_tree.py index c9c94025af..742117d778 100644 --- a/testbed/tests/widgets/test_tree.py +++ b/testbed/tests/widgets/test_tree.py @@ -107,7 +107,7 @@ def source(): @pytest.fixture async def widget(source, on_select_handler, on_activate_handler): - skip_on_platforms("iOS", "android", "windows") + skip_on_platforms("iOS", "android") return toga.Tree( ["A", "B", "C"], data=source, @@ -120,7 +120,7 @@ async def widget(source, on_select_handler, on_activate_handler): @pytest.fixture async def headerless_widget(source, on_select_handler): - skip_on_platforms("iOS", "android", "windows") + skip_on_platforms("iOS", "android") return toga.Tree( columns=[ AccessorColumn(None, "a"), @@ -152,7 +152,7 @@ async def headerless_probe(main_window, headerless_widget): @pytest.fixture async def multiselect_widget(source, on_select_handler): # Although Android *has* a table implementation, it needs to be rebuilt. - skip_on_platforms("iOS", "android", "windows") + skip_on_platforms("iOS", "android") return toga.Tree( ["A", "B", "C"], data=source, @@ -179,11 +179,7 @@ async def multiselect_probe(main_window, multiselect_widget): test_cleanup = build_cleanup_test( toga.Tree, kwargs={"columns": ["A", "B", "C"]}, - skip_platforms=( - "iOS", - "android", - "windows", - ), + skip_platforms=("iOS", "android"), ) @@ -913,13 +909,14 @@ async def test_cell_widget(widget, probe): warning_check = contextlib.nullcontext() else: warning_check = pytest.warns( - match=".* does not support the use of widgets in cells" + match=r".* does not support the use of widgets in cells" ) with warning_check: widget.data = data - # Qt backend doesn't know there are widgets until the row is expanded + # Qt and Windows backends don't know there are widgets until the row is + # expanded. await probe.expand_tree() await probe.redraw("Tree has data with widgets") From 8ed69032d78b08519db66b659ba991b70cc0a638 Mon Sep 17 00:00:00 2001 From: Oliver-Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:23:24 +0100 Subject: [PATCH 10/30] Enable multi-column icon tests for WinForms --- winforms/tests_backend/widgets/table.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/winforms/tests_backend/widgets/table.py b/winforms/tests_backend/widgets/table.py index 1472e664d4..b8757b2e6b 100644 --- a/winforms/tests_backend/widgets/table.py +++ b/winforms/tests_backend/widgets/table.py @@ -12,7 +12,7 @@ class TableProbe(SimpleProbe): native_class = ListView - supports_icons = 1 # First column only + supports_icons = 2 # All columns supports_keyboard_shortcuts = False supports_keyboard_boundary_shortcuts = True supports_widgets = False From 2f7c3e09a34e063736142a11b57572172fa7fa8c Mon Sep 17 00:00:00 2001 From: Oliver-Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Thu, 12 Mar 2026 15:49:28 +0100 Subject: [PATCH 11/30] Minor style change --- winforms/src/toga_winforms/widgets/tree.py | 38 +++++++++++----------- winforms/tests_backend/widgets/tree.py | 2 +- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/winforms/src/toga_winforms/widgets/tree.py b/winforms/src/toga_winforms/widgets/tree.py index c2e6cc45b7..31aca48269 100644 --- a/winforms/src/toga_winforms/widgets/tree.py +++ b/winforms/src/toga_winforms/widgets/tree.py @@ -56,7 +56,7 @@ def __init__(self, node: Node, state_tree, depth: int): self.children = [StateNode(child, self.tree, depth + 1) for child in node] self.text = c_wchar_p("") - self.arrow = c_wchar_p("") + self.mouse_hover: bool self.icon: int def __len__(self) -> int: @@ -643,7 +643,7 @@ def _hit_test_arrow(self, x: int, y: int) -> int: if state_node.is_leaf: return -1 - x_arrow = self._left_padding + (state_node.depth + 1) * self._indent + x_arrow = self._left_padding + state_node.depth * self._indent x_arrow += self._arrow_width / 2 y_arrow = item.Bounds.Y + item.Bounds.Height / 2 @@ -753,7 +753,7 @@ def _set_widths(self, hdc, rect): self._left_padding = rect.left self._arrow_width = max(lengths) - self._widths_set = False + self._widths_set = True def _nm_customdraw(self, nmlvcd) -> int | None: """Paints the non-leaf node items.""" @@ -804,11 +804,21 @@ def _nm_customdraw(self, nmlvcd) -> int | None: text_color = wc.COLOR_HOTLIGHT back_color = self._hbrush_back - SetTextColor(hdc, GetSysColor(text_color)) + # Determine the state-change arrow. + if state_node.mouse_hover: + arrow = "\u25bc" if state_node.is_open else "\u25b6" + else: + arrow = "\u25bd" if state_node.is_open else "\u25b7" + + # Draw the arrow, making sure the click location is in its center. + rect.left = rect.left + state_node.depth * self._indent + rect.right = rect.left + self._arrow_width + SetTextColor(hdc, GetSysColor(wc.COLOR_HOTLIGHT)) text_format = wc.DT_SINGLELINE | wc.DT_VCENTER | wc.DT_WORD_ELLIPSIS + DrawTextW(hdc, arrow, -1, byref(rect), text_format | wc.DT_HCENTER) # Draw the icon - rect.left = rect.left + state_node.depth * self._indent + rect.left = rect.right + 2 if state_node.icon >= 0: ImageList_Draw( HWND(int(self.native.SmallImageList.Handle.ToString())), @@ -824,15 +834,10 @@ def _nm_customdraw(self, nmlvcd) -> int | None: rect.right = self._rect_right FillRect(hdc, byref(rect), back_color) - # Draw the arrow, making sure the click location is in its center. - rect.right = rect.left + self._arrow_width - DrawTextW( - hdc, state_node.arrow, -1, byref(rect), text_format | wc.DT_HCENTER - ) - # Draw the text - rect.left = rect.left + self._arrow_width + rect.left = rect.left + 2 rect.right = self._rect_right + SetTextColor(hdc, GetSysColor(text_color)) DrawTextW(hdc, state_node.text, -1, byref(rect), text_format) # Get the bounding rectangle of the drawn text. @@ -864,15 +869,10 @@ def _new_branch(self, index: int, state_node: StateNode): column = self._columns[0] node = state_node.node - if index == self._mouse_move_hit: - arrow = "\u25bc" if state_node.is_open else "\u25b6" - else: - arrow = "\u25bd" if state_node.is_open else "\u25b7" - # Store the c_wchar_p objects on the StateNodes to prevent them from being # garbage collected. - state_node.arrow = c_wchar_p(arrow) - state_node.text = c_wchar_p(" " + column.text(node, missing_value)) + state_node.mouse_hover = index == self._mouse_move_hit + state_node.text = c_wchar_p(column.text(node, missing_value)) state_node.icon = self._icon_index(node, column) return ( diff --git a/winforms/tests_backend/widgets/tree.py b/winforms/tests_backend/widgets/tree.py index c9586531dc..4b2908bd67 100644 --- a/winforms/tests_backend/widgets/tree.py +++ b/winforms/tests_backend/widgets/tree.py @@ -72,7 +72,7 @@ def assert_cell_content(self, row_path, col, value=None, icon=None, widget=None) self.native.Items[display_index] if col == 0: - text = state_node.text.value[1:] + text = state_node.text.value else: # For non-leaf nodes, only column 0 is displayed." column = self.impl._columns[col] From 48275da336f36c7783d95d8475deb238ca77f5de Mon Sep 17 00:00:00 2001 From: Oliver-Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:15:33 +0100 Subject: [PATCH 12/30] Added change note --- changes/4235.feature.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/4235.feature.md diff --git a/changes/4235.feature.md b/changes/4235.feature.md new file mode 100644 index 0000000000..4fbd974e4a --- /dev/null +++ b/changes/4235.feature.md @@ -0,0 +1 @@ +The Tree widget is now supported in the Windows backend. From 787af463f0a1513aa003a7ec06b61d3f0f479acf Mon Sep 17 00:00:00 2001 From: Oliver-Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Fri, 13 Mar 2026 09:12:50 +0100 Subject: [PATCH 13/30] Removed types from docstring parameters --- winforms/src/toga_winforms/widgets/table.py | 2 +- winforms/src/toga_winforms/widgets/tree.py | 76 ++++++++++----------- 2 files changed, 37 insertions(+), 41 deletions(-) diff --git a/winforms/src/toga_winforms/widgets/table.py b/winforms/src/toga_winforms/widgets/table.py index 9a31bd6567..ea370d746d 100644 --- a/winforms/src/toga_winforms/widgets/table.py +++ b/winforms/src/toga_winforms/widgets/table.py @@ -164,7 +164,7 @@ def get_selection(self): return selected_indices[0] ################################################################################# - # The following methods are shared (non-overridden) with DetailedList and Tree + # The following methods are shared (non-overridden) with DetailedList and Tree ################################################################################# def __del__(self): diff --git a/winforms/src/toga_winforms/widgets/tree.py b/winforms/src/toga_winforms/widgets/tree.py index 31aca48269..4b77b52932 100644 --- a/winforms/src/toga_winforms/widgets/tree.py +++ b/winforms/src/toga_winforms/widgets/tree.py @@ -39,10 +39,9 @@ class StateNode: def __init__(self, node: Node, state_tree, depth: int): """Initializes an instance for a given node and its relation to a StateTree. - :param Node node: The underlying Node. - :param StateTree state_tree: The StateTree of which the StateNode is a part. - :param int depth: The smallest number of nodes between the StateNode and a - root. + :param node: The underlying Node. + :param state_tree: The StateTree of which the StateNode is a part. + :param depth: The smallest number of nodes between the StateNode and a root. """ self.node = node @@ -68,9 +67,9 @@ def __len__(self) -> int: def __getitem__(self, row_path: list[int] | tuple[int] | None): """Gets an item based on a row path list or tuple - :param list[int] | tuple[int] row_path: A list or tuple of indices. The first - is an index of a child, say A, in the list of children, the next is an - index in the list of children of A, and so on... + :param row_path: A list or tuple of indices. The first is an index of a child, + say A, in the list of children, the next is an index in the list of children + of A, and so on... :return: The StateNode at the end of the path. None returns the StateNode itself. """ @@ -100,9 +99,9 @@ def is_open(self) -> bool: def toggle_state(self, update_display: bool) -> bool: """Toggles the state (open/closed) of the StateNode. - :param bool update_display: Whether the display list should be updated. If - the StateNode is visible this should most likely be True. Otherwise this - should be False. + :param update_display: Whether the display list should be updated. If the + StateNode is visible this should most likely be True. Otherwise this should + be False. :return: A bool indicating whether a change of selection has occurred. """ if not self.is_leaf: @@ -115,8 +114,8 @@ def toggle_state(self, update_display: bool) -> bool: def set_branch_state(self, set_open: bool, is_visible: bool) -> bool: """Sets the state (open/closed) for a StateNode and all its descendants. - :param bool set_open: Should the state be set to open? - :param bool is_visible: Is the state_node in the display list? + :param set_open: Should the state be set to open? + :param is_visible: Is the state_node in the display list? :return: A bool indicating whether a change of selection has occurred. """ if self.is_open != set_open: @@ -132,9 +131,9 @@ def set_branch_state(self, set_open: bool, is_visible: bool) -> bool: def insert(self, index: int, node: Node) -> bool: """Inserts a child StateNode for a given Node at a given index. - :param int index: The index where the child StateNode will be placed in the - children list. - :param Node node: The Node from which to make a child StateNode. + :param index: The index where the child StateNode will be placed in the children + list. + :param node: The Node from which to make a child StateNode. :return: A bool indicating whether a refresh of the ListView is needed. """ refresh_needed = self.is_leaf @@ -150,7 +149,7 @@ def insert(self, index: int, node: Node) -> bool: def remove(self, index: int) -> bool: """Removes a child at a given index. - :param int index: The index of the StateNode to be deleted. + :param index: The index of the StateNode to be deleted. :return: A bool indicating whether a change of selection has occurred. """ child = self.children[index] @@ -189,7 +188,7 @@ class StateTree(StateNode): def __init__(self, tree_source: TreeSourceT): """Initializes the instance for a TreeSourceT. - :param TreeSourceT tree_source: The TreeSourceT used to build the StateTree. + :param tree_source: The TreeSourceT used to build the StateTree. """ self.tree_source = tree_source self.children = [ @@ -223,8 +222,7 @@ def toggle_state(self, update_display: bool = False) -> bool: def set_all_states(self, all_open: bool) -> None: """Sets the state (open/closed) of all the child StateNode instances. - :param bool all_open: Should the children be open (expanded) or closed - (contracted)? + :param all_open: Should the children be open (expanded) or closed (contracted)? """ super().set_all_states(all_open) self.display_list_refresh() @@ -245,7 +243,7 @@ def display_list_refresh(self): def display_list_toggle_index(self, index) -> bool: """Toggles the state (open/closed) of a StateNode in the display list. - :param int index: The index of the StateNode in the display list. + :param index: The index of the StateNode in the display list. :return: A bool indicating whether a change of selection has occurred. """ state_node = self._display_list[index] @@ -260,8 +258,8 @@ def display_list_toggle_index(self, index) -> bool: def _display_list_adjust(self, insert: bool, state_node: StateNode) -> bool: """Adjusts the display list based on an item insertion or removal. - :param bool insert: Is an item being inserted or removed? - :param StateNode item: The item being inserted or removed. + :param insert: Is an item being inserted or removed? + :param item: The item being inserted or removed. :return: For insert=True, a bool indicating whether a ListView refresh is needed. For insert=False, a bool indicating whether a change of selection has occurred. @@ -300,9 +298,9 @@ def _display_list_modifier( The sublist contains either a single element, or a whole branch which will be expanded/contracted during a toggle operation. - :param bool insert: Is the sublist being inserted or removed? - :param int start_index: The index in the display list where the first item - of the sublist is/will be. + :param insert: Is the sublist being inserted or removed? + :param start_index: The index in the display list where the first item of the + sublist is/will be. :return: A bool indicating whether a change of selection has occurred. """ if insert: @@ -322,7 +320,7 @@ def _display_list_modifier( def _display_list_toggle(self, state_node: StateNode) -> bool: """Updates the display list to reflect a node being toggled. - :param StateNode state_node: The StateNode being toggled. + :param state_node: The StateNode being toggled. :return: A bool indicating whether a change of selection has occurred. """ insert: bool = state_node.is_open @@ -359,10 +357,10 @@ def _selection_modifier( item in the sublist is also removed. The other indices are shifted up or down based on the size of the sublist. - :param bool insert: Is the sublist being inserted or removed? - :param int start_index: The index in the display list where the first item - of the sublist is/will be. - :param int range_size: The size of the sublist being inserted/removed. + :param insert: Is the sublist being inserted or removed? + :param start_index: The index in the display list where the first item of the + sublist is/will be. + :param range_size: The size of the sublist being inserted/removed. :return: A bool indicating whether a change of selection has occurred. """ selection_updater = extend_indices if insert else reduce_indices @@ -386,7 +384,7 @@ def non_negative(x: int) -> bool: def find_state_node(self, node: Node) -> StateNode | None: """Searches in the StateTree for a StateNode associated to a given Node. - :param Node node: The node for which to find the corresponding StateNode. + :param node: The node for which to find the corresponding StateNode. :return: If found the StateNode is returned, otherwise None is returned. """ for state_node in self.branch_iter(display=False): @@ -628,8 +626,8 @@ def selected_indices(self, indices: list[int]): def _hit_test_arrow(self, x: int, y: int) -> int: """Tests whether given coordinates are over a state-change arrow. - :param int x: The horizontal coordinate relative to the UI client area. - :param int y: The vertical coordinate relative to the UI client area. + :param x: The horizontal coordinate relative to the UI client area. + :param y: The vertical coordinate relative to the UI client area. :return: If the (x,y) position is over a state-change arrow then the test returns the index of the StateNode. If the (x,y) position is not over any items -2 is returned. Otherwise -1 is returned (this corresponds @@ -886,10 +884,9 @@ def _update_list(self, notify_select: bool = False, refresh: bool = False): This method is called when toggling a StateNode, when adding/removing nodes, and when modifying an item. - :param bool notify_select: A bool indicating whether a change of selection - has occurred, and hence whether self.interface should be notified. - :param bool refresh: A bool indicating whether the ListView needs to be - repainted. + :param notify_select: A bool indicating whether a change of selection has + occurred, and hence whether self.interface should be notified. + :param refresh: A bool indicating whether the ListView needs to be repainted. """ self.native.VirtualListSize = len(self.display_list) self._cache = [] @@ -899,11 +896,10 @@ def _update_list(self, notify_select: bool = False, refresh: bool = False): if refresh: self.native.Refresh() - def _get_state_parent(self, parent=None): + def _get_state_parent(self, parent: Node | None = None): """Gets the StateNode/StateTree associated to parent. - :param Node | None parent: The object for which to find the associated - StateNode/StateTree. + :param parent: The object for which to find the associated StateNode/StateTree. :return: If parent=None, then the state tree is returned. If parent is a Node and no corresponding StateNode can be found None is returned. Otherwise the StateNode corresponding to node is returned. From a541e4eb647e8a3890958b4579d32cd741e6e4ba Mon Sep 17 00:00:00 2001 From: Oliver-Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Fri, 13 Mar 2026 09:27:25 +0100 Subject: [PATCH 14/30] Changed formatting for long-argument functions --- winforms/src/toga_winforms/widgets/tree.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/winforms/src/toga_winforms/widgets/tree.py b/winforms/src/toga_winforms/widgets/tree.py index 4b77b52932..c74599f977 100644 --- a/winforms/src/toga_winforms/widgets/tree.py +++ b/winforms/src/toga_winforms/widgets/tree.py @@ -291,7 +291,10 @@ def _display_list_adjust(self, insert: bool, state_node: StateNode) -> bool: return False def _display_list_modifier( - self, insert: bool, sublist: list[StateNode], start_index: int + self, + insert: bool, + sublist: list[StateNode], + start_index: int, ) -> bool: """Modifies the display list by either inserting or removing a sublist. @@ -347,7 +350,10 @@ def selected_indices_from_ui(self, selected_indices: list[int]): self._selected_indices = selected_indices def _selection_modifier( - self, insert: bool, start_index: int, range_size: int + self, + insert: bool, + start_index: int, + range_size: int, ) -> bool: """Modifies the selected indices list based on changes to the display list. @@ -395,7 +401,8 @@ def find_state_node(self, node: Node) -> StateNode | None: def display_branch_iter( - roots: Iterable[StateNode | StateTree], ignore_closed: bool = False + roots: Iterable[StateNode | StateTree], + ignore_closed: bool = False, ): """An iterator for a branch of a StateTree or Node.""" for root in roots: From b3088a0653fc281f96e3771373a51220579ff72c Mon Sep 17 00:00:00 2001 From: Oliver-Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Tue, 17 Mar 2026 13:17:39 +0100 Subject: [PATCH 15/30] Style change and added all columns for non-leaf --- winforms/src/toga_winforms/libs/comctl32.py | 10 +- winforms/src/toga_winforms/libs/gdi32.py | 24 -- winforms/src/toga_winforms/libs/user32.py | 14 +- winforms/src/toga_winforms/libs/win32.py | 8 +- .../src/toga_winforms/libs/windowconstants.py | 19 -- winforms/src/toga_winforms/widgets/table.py | 12 +- winforms/src/toga_winforms/widgets/tree.py | 247 ++++++------------ 7 files changed, 87 insertions(+), 247 deletions(-) delete mode 100644 winforms/src/toga_winforms/libs/gdi32.py diff --git a/winforms/src/toga_winforms/libs/comctl32.py b/winforms/src/toga_winforms/libs/comctl32.py index 14eec84866..d41db24e1e 100644 --- a/winforms/src/toga_winforms/libs/comctl32.py +++ b/winforms/src/toga_winforms/libs/comctl32.py @@ -1,8 +1,8 @@ from ctypes import windll -from ctypes.wintypes import BOOL, HDC, HWND, INT, LPARAM, UINT, WPARAM +from ctypes.wintypes import BOOL, HWND, LPARAM, UINT, WPARAM from .comctl32classes import SUBCLASSPROC -from .win32 import DWORD_PTR, HIMAGELIST, LRESULT, UINT_PTR +from .win32 import DWORD_PTR, LRESULT, UINT_PTR comctl32 = windll.comctl32 @@ -13,12 +13,6 @@ DefSubclassProc.argtypes = [HWND, UINT, WPARAM, LPARAM] -# https://learn.microsoft.com/en-us/windows/win32/api/commctrl/nf-commctrl-imagelist_draw -ImageList_Draw = comctl32.ImageList_Draw -ImageList_Draw.restype = BOOL -ImageList_Draw.argtypes = [HIMAGELIST, INT, HDC, INT, INT, UINT] - - # https://learn.microsoft.com/en-us/windows/win32/api/commctrl/nf-commctrl-setwindowsubclass RemoveWindowSubclass = comctl32.RemoveWindowSubclass RemoveWindowSubclass.restype = BOOL diff --git a/winforms/src/toga_winforms/libs/gdi32.py b/winforms/src/toga_winforms/libs/gdi32.py deleted file mode 100644 index 7e8c9f8d31..0000000000 --- a/winforms/src/toga_winforms/libs/gdi32.py +++ /dev/null @@ -1,24 +0,0 @@ -from ctypes import windll -from ctypes.wintypes import BOOL, COLORREF, HDC - -from .win32 import HBRUSH, HGDIOBJ - -gdi32 = windll.GDI32 - - -# https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-createsolidbrush -CreateSolidBrush = gdi32.CreateSolidBrush -CreateSolidBrush.restype = HBRUSH -CreateSolidBrush.argtypes = [COLORREF] - - -# https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-deleteobject -DeleteObject = gdi32.DeleteObject -DeleteObject.restype = BOOL -DeleteObject.argtypes = [HGDIOBJ] - - -# https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-settextcolor -SetTextColor = gdi32.SetTextColor -SetTextColor.restype = COLORREF -SetTextColor.argtypes = [HDC, COLORREF] diff --git a/winforms/src/toga_winforms/libs/user32.py b/winforms/src/toga_winforms/libs/user32.py index 32d14b58fa..66d706c60f 100644 --- a/winforms/src/toga_winforms/libs/user32.py +++ b/winforms/src/toga_winforms/libs/user32.py @@ -15,7 +15,7 @@ from System import Environment -from .win32 import HBRUSH, LRESULT, RECT_PTR +from .win32 import LRESULT user32 = windll.user32 @@ -31,18 +31,6 @@ DrawTextW.argtypes = [HDC, LPCWSTR, INT, LPRECT, UINT] -# https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-fillrect -FillRect = user32.FillRect -FillRect.restype = INT -FillRect.argtypes = [HDC, RECT_PTR, HBRUSH] - - -# https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getsyscolor -GetSysColor = user32.GetSysColor -GetSysColor.restype = DWORD -GetSysColor.argtypes = [INT] - - # https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-monitorfromrect MONITOR_DEFAULTTONEAREST = 2 diff --git a/winforms/src/toga_winforms/libs/win32.py b/winforms/src/toga_winforms/libs/win32.py index 1d17f7431d..59ab01c7ac 100644 --- a/winforms/src/toga_winforms/libs/win32.py +++ b/winforms/src/toga_winforms/libs/win32.py @@ -1,12 +1,8 @@ -from ctypes import POINTER, c_size_t -from ctypes.wintypes import HWND, LPARAM, RECT +from ctypes import c_size_t +from ctypes.wintypes import LPARAM LRESULT = LPARAM # LPARAM is essentially equivalent to LRESULT UINT_PTR = c_size_t DWORD_PTR = c_size_t PUINT = c_size_t INT_PTR = c_size_t -RECT_PTR = POINTER(RECT) -HBRUSH = HWND -HIMAGELIST = HWND -HGDIOBJ = HWND diff --git a/winforms/src/toga_winforms/libs/windowconstants.py b/winforms/src/toga_winforms/libs/windowconstants.py index 03f043b880..56024eb25d 100644 --- a/winforms/src/toga_winforms/libs/windowconstants.py +++ b/winforms/src/toga_winforms/libs/windowconstants.py @@ -6,18 +6,8 @@ CDDS_SUBITEM = 0x00020000 # Custom Draw Response Flag -CDRF_NEWFONT = 0x00000002 CDRF_NOTIFYITEMDRAW = 0x00000020 CDRF_NOTIFYSUBITEMDRAW = CDRF_NOTIFYITEMDRAW -CDRF_SKIPDEFAULT = 0x00000004 - -# Color -COLOR_HIGHLIGHT = 13 -COLOR_HIGHLIGHTTEXT = 14 -COLOR_HOTLIGHT = 26 -COLOR_WINDOW = 5 -COLOR_BTNFACE = 15 -COLOR_BTNTEXT = 18 # Draw Text DT_CALCRECT = 0x00000400 @@ -30,18 +20,11 @@ # Edit Messages EM_SETCUEBANNER = 0x1501 -# Image List Draw -ILD_NORMAL = 0x0000 -ILD_SELECTED = 0x0004 - # List-View Item Flag LVIF_TEXT = 0x0001 LVIF_IMAGE = 0x0002 LVIF_STATE = 0x0008 -# List-View Item State -LVIS_SELECTED = 0x0002 - # List-View Management LVM_GETEXTENDEDLISTVIEWSTYLE = 0x1037 LVM_SETEXTENDEDLISTVIEWSTYLE = 0x1036 @@ -56,7 +39,5 @@ NM_CUSTOMDRAW = 0xFFFFFFF4 # Window Message -WM_GETFONT = 0x0031 WM_NCDESTROY = 0x0082 -WM_NOTIFY = 0x004E WM_REFLECT_NOTIFY = 0x204E diff --git a/winforms/src/toga_winforms/widgets/table.py b/winforms/src/toga_winforms/widgets/table.py index ea370d746d..8f14afbd18 100644 --- a/winforms/src/toga_winforms/widgets/table.py +++ b/winforms/src/toga_winforms/widgets/table.py @@ -347,15 +347,15 @@ def _image_index(self, icon): images.Add(key, icon.bitmap) return index - def _construct_new_item(self, raw_item, indent: None | int = None): - missing_value = self.interface.missing_value + def _construct_new_item(self, raw_item, use_missing_value: bool = True): + if use_missing_value: + missing_value = self.interface.missing_value + else: + missing_value = "" + lvi = WinForms.ListViewItem( [column.text(raw_item, missing_value) for column in self._columns], ) - - if indent is not None: - lvi.IndentCount = indent - icon_indices = tuple( self._icon_index(raw_item, column) for column in self._columns ) diff --git a/winforms/src/toga_winforms/widgets/tree.py b/winforms/src/toga_winforms/widgets/tree.py index c74599f977..6a1b73168d 100644 --- a/winforms/src/toga_winforms/widgets/tree.py +++ b/winforms/src/toga_winforms/widgets/tree.py @@ -4,21 +4,13 @@ from functools import partial from warnings import warn -import System.Windows.Forms as WinForms -from System.Drawing import ColorTranslator - from toga.handlers import WeakrefCallable from toga.sources.tree_source import Node, TreeSourceT from ..libs import windowconstants as wc -from ..libs.comctl32 import ( - DefSubclassProc, - ImageList_Draw, - RemoveWindowSubclass, -) +from ..libs.comctl32 import DefSubclassProc, RemoveWindowSubclass from ..libs.comctl32classes import NMHDR, NMLVCUSTOMDRAW, NMLVDISPINFOW -from ..libs.gdi32 import CreateSolidBrush, DeleteObject, SetTextColor -from ..libs.user32 import DrawTextW, FillRect, GetSysColor +from ..libs.user32 import DrawTextW from ..libs.win32 import LRESULT from .table import Table @@ -31,9 +23,10 @@ class StateNode: tree: The StateTree which the StateNode is a part of. depth: The smallest number of nodes between the StateNode and a root. children: A list of child StateNode instances. This is None for leaf nodes. - text (non-leaf only): The display text of the Node. - arrow (non-leaf only): The expansion/contraction arrow of the StateNode. - icon (non-leaf only): The icon of the Node. + arrow_center_x (non-leaf only): The horizontal coordinate (relative to the + client area) of the expansion/contraction arrow of the StateNode. + mouse_hover (non-leaf only): A bool indicating whether the mouse is hovering + over the state-change arrow. """ def __init__(self, node: Node, state_tree, depth: int): @@ -54,9 +47,8 @@ def __init__(self, node: Node, state_tree, depth: int): if self.node.can_have_children(): self.children = [StateNode(child, self.tree, depth + 1) for child in node] - self.text = c_wchar_p("") + self.arrow_center_x: float = 0.0 self.mouse_hover: bool - self.icon: int def __len__(self) -> int: if self.children is None: @@ -476,28 +468,21 @@ def create(self): # _mouse_move_hit is a record of the latest hit-test for a MouseMove event. # This is used in the _new_branch method to assign the correct state-change # arrow to the non-leaf node display rows. - self._mouse_move_hit = -1 + self._mouse_move_hit: int = -1 # _mouse_down_hit is a record of the latest hit-test for a MouseDown event. # This is used by the _process_selection_change method to determine whether # a MouseDown event should trigger a change of selection in the UI. - self._mouse_down_hit = -1 + self._mouse_down_hit: int = -1 - # These are widths that are used in the painting of the non-leaf node rows. - # The 4 here is undocumented left padding of the ListView UI. The amount is - # confirmed/updated during the drawing process. - self._left_padding = 4 - self._arrow_width = 21 - self._widths_set = False - self._indent = self.native.SmallImageList.ImageSize.Width - self._rect_right = 0 - - self._hbrush_back = CreateSolidBrush( - ColorTranslator.ToWin32(self.native.BackColor) - ) - self.native.BackColorChanged += WeakrefCallable( - self.winforms_back_color_changed - ) + # This is the size of the "indent" used when constructing items in _new_item + self._indent: int = self.native.SmallImageList.ImageSize.Width + + # These measurements deal with the arrow size. They are checked/updated during + # the drawing process. They need to be constantly monitored since a change in + # screen scaling will produce a different sized arrow. + self._arrow_indent: int = 2 + self._arrow_width: float = 21.0 self.native.MouseMove += WeakrefCallable(self.winforms_mouse_move) self.native.MouseLeave += WeakrefCallable(self.winforms_mouse_leave) @@ -516,9 +501,6 @@ def _subclass_proc( ) -> LRESULT: """Override from Table: Same method, but also responds to NM_CUSTOMDRAW.""" if uMsg == wc.WM_NCDESTROY: - # Delete the brushes - DeleteObject(self._hbrush_back) - # Remove the window subclass in the way recommended by Raymond Chen here: # devblogs.microsoft.com/oldnewthing/20031111-00/?p=41883 RemoveWindowSubclass(hWnd, self.pfn_subclass, uIdSubclass) @@ -535,7 +517,7 @@ def _subclass_proc( nmlvcd = cast(lParam, POINTER(NMLVCUSTOMDRAW)).contents return_flag = self._nm_customdraw(nmlvcd) if return_flag is not None: - return self._nm_customdraw(nmlvcd) + return return_flag # Call the original window procedure return DefSubclassProc(HWND(hWnd), UINT(uMsg), WPARAM(wParam), LPARAM(lParam)) @@ -551,9 +533,14 @@ def _new_item(self, index): ) if state_node.is_leaf: - return self._construct_new_item(node, state_node.depth) + lvi, icon_indices = self._construct_new_item(node) + else: + lvi, icon_indices = self._construct_new_item(node, use_missing_value=False) + state_node.mouse_hover = index == self._mouse_move_hit - return self._new_branch(index, state_node) + lvi.IndentCount = state_node.depth + self._arrow_indent + + return (lvi, icon_indices) def _process_activation(self, x, y, list_view_item): """Activates for double-click on an item but not on state-change arrows.""" @@ -648,12 +635,11 @@ def _hit_test_arrow(self, x: int, y: int) -> int: if state_node.is_leaf: return -1 - x_arrow = self._left_padding + state_node.depth * self._indent - x_arrow += self._arrow_width / 2 + x_arrow = state_node.arrow_center_x y_arrow = item.Bounds.Y + item.Bounds.Height / 2 norm = (float(x - x_arrow)) ** 2 + (float(y - y_arrow)) ** 2 - if norm < (self._arrow_width**2) / 2: + if norm * 2 < float(self._arrow_width) ** 2: return item.Index return -1 @@ -667,7 +653,7 @@ def _set_mouse_move_hit(self, index): self._cache = [] for i in {index, old_index}: if i >= 0: - self.native.RedrawItems(i, i, False) + self.native.Invalidate(self.native.Items[i].Bounds, False) def _selected_indices_ui_to_tree(self): """This method updates the state tree to reflect the UI selection.""" @@ -715,7 +701,7 @@ def winforms_mouse_down(self, sender, e): if hit_index >= 0: notify_select = self._state_tree.display_list_toggle_index(hit_index) self._update_list(notify_select) - self.native.RedrawItems(hit_index, hit_index, False) + self.native.Invalidate(self.native.Items[hit_index].Bounds, False) def winforms_mouse_click(self, sender, e): """Corrects the UI selection based on the hit-test. @@ -737,153 +723,72 @@ def winforms_mouse_up(self, sender, e): self._mouse_down_hit = -1 - def winforms_back_color_changed(self, sender, e): - """Updates the win32 brush for the background color""" - # Delete the old brush - DeleteObject(self._hbrush_back) - # Create the new brush - self._hbrush_back = CreateSolidBrush( - ColorTranslator.ToWin32(self.native.BackColor) - ) - - def _set_widths(self, hdc, rect): - """Determines _left_padding and _arrow_width during the first custom draw.""" + def _check_measurments(self, hdc, rect): + """Checks/updates arrow width and that there are enough indents.""" text_format = wc.DT_CALCRECT | wc.DT_NOCLIP lengths = [] for arrow in ["\u25bc", "\u25b6", "\u25bd", "\u25b7"]: - rect_copy = RECT.from_buffer_copy(rect) - DrawTextW(hdc, c_wchar_p(arrow), -1, byref(rect_copy), text_format) - lengths.append(rect_copy.right - rect_copy.left) + DrawTextW(hdc, c_wchar_p(arrow), -1, byref(rect), text_format) + lengths.append(rect.right - rect.left) - self._left_padding = rect.left self._arrow_width = max(lengths) - self._widths_set = True - - def _nm_customdraw(self, nmlvcd) -> int | None: - """Paints the non-leaf node items.""" - # learn.microsoft.com/en-us/windows/win32/controls/using-custom-draw - if nmlvcd.nmcd.dwDrawStage == wc.CDDS_PREPAINT: - return wc.CDRF_NOTIFYITEMDRAW - - elif nmlvcd.nmcd.dwDrawStage == wc.CDDS_ITEMPREPAINT: - index = nmlvcd.nmcd.dwItemSpec - state_node = self.display_list[index] - if not state_node.is_leaf: - hdc = HDC(nmlvcd.nmcd.hdc) - FillRect(hdc, byref(nmlvcd.nmcd.rc), self._hbrush_back) - - self._rect_right = nmlvcd.nmcd.rc.right + quotient, remaider = divmod(self._arrow_width, self._indent) - return wc.CDRF_NOTIFYSUBITEMDRAW + if remaider == 0 and self._arrow_indent != quotient: + self._arrow_indent = quotient + self._update_list(refresh=True) + elif remaider != 0 and self._arrow_indent != quotient + 1: + self._arrow_indent = quotient + 1 + self._update_list(refresh=True) - elif wc.CDDS_SUBITEM | wc.CDDS_ITEMPREPAINT: - # Don't need to check state_node.is_leaf since this block is only - # accessed after CDRF_NOTIFYSUBITEMDRAW is returned. + def _draw_state_change_arrow(self, hdc, rect, index: int): + state_node: StateNode = self.display_list[index] - # Skip drawing for subitems - if nmlvcd.iSubItem > 0: - return wc.CDRF_SKIPDEFAULT - - hdc = HDC(nmlvcd.nmcd.hdc) - rect = RECT.from_buffer_copy(nmlvcd.nmcd.rc) - index = nmlvcd.nmcd.dwItemSpec - state_node = self.display_list[index] - is_selected = self.native.Items[index].Selected - - # Set the width constants - if not self._widths_set: - self._set_widths(hdc, nmlvcd.nmcd.rc) - - # Set the colors based on whether the item is selected. - # The "+1" is needed for system brushes with FillRect, documented here: - # learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-fillrect - if is_selected: - if self.native.Focused: - text_color = wc.COLOR_HIGHLIGHTTEXT - back_color = wc.COLOR_HIGHLIGHT + 1 - else: - text_color = wc.COLOR_BTNTEXT # Color is undocumented - back_color = wc.COLOR_BTNFACE + 1 # Color is undocumented - else: - text_color = wc.COLOR_HOTLIGHT - back_color = self._hbrush_back - - # Determine the state-change arrow. - if state_node.mouse_hover: - arrow = "\u25bc" if state_node.is_open else "\u25b6" - else: - arrow = "\u25bd" if state_node.is_open else "\u25b7" - - # Draw the arrow, making sure the click location is in its center. - rect.left = rect.left + state_node.depth * self._indent - rect.right = rect.left + self._arrow_width - SetTextColor(hdc, GetSysColor(wc.COLOR_HOTLIGHT)) - text_format = wc.DT_SINGLELINE | wc.DT_VCENTER | wc.DT_WORD_ELLIPSIS - DrawTextW(hdc, arrow, -1, byref(rect), text_format | wc.DT_HCENTER) - - # Draw the icon - rect.left = rect.right + 2 - if state_node.icon >= 0: - ImageList_Draw( - HWND(int(self.native.SmallImageList.Handle.ToString())), - state_node.icon, - hdc, - rect.left, - divmod(rect.top + rect.bottom - self._indent, 2)[0], - wc.ILD_SELECTED if is_selected else wc.ILD_NORMAL, - ) + # Determine the state-change arrow. + if state_node.mouse_hover: + arrow = "\u25bc" if state_node.is_open else "\u25b6" + else: + arrow = "\u25bd" if state_node.is_open else "\u25b7" - # Draw the background (mainly for selection) - rect.left = rect.left + self._indent - rect.right = self._rect_right - FillRect(hdc, byref(rect), back_color) + rect.right = rect.left + rect.left = rect.right - self._indent * self._arrow_indent - # Draw the text - rect.left = rect.left + 2 - rect.right = self._rect_right - SetTextColor(hdc, GetSysColor(text_color)) - DrawTextW(hdc, state_node.text, -1, byref(rect), text_format) + state_node.arrow_center_x = (rect.right + rect.left) / 2 - # Get the bounding rectangle of the drawn text. - DrawTextW( - hdc, state_node.text, -1, byref(rect), text_format | wc.DT_CALCRECT - ) + text_format = ( + wc.DT_SINGLELINE | wc.DT_VCENTER | wc.DT_WORD_ELLIPSIS | wc.DT_HCENTER + ) + DrawTextW(hdc, c_wchar_p(arrow), -1, byref(rect), text_format) - rect.left = rect.right + self._indent - rect.top = ( - nmlvcd.nmcd.rc.top - + divmod(nmlvcd.nmcd.rc.bottom - nmlvcd.nmcd.rc.top, 2)[0] - ) - rect.right = self._rect_right - self._indent - rect.bottom = rect.top + 1 + def _nm_customdraw(self, nmlvcd) -> int | None: + """Paints the non-leaf node items.""" + # learn.microsoft.com/en-us/windows/win32/controls/using-custom-draw + draw_stage = nmlvcd.nmcd.dwDrawStage - if rect.left < rect.right: - FillRect(hdc, byref(rect), text_color + 1) + if draw_stage == wc.CDDS_PREPAINT: + return wc.CDRF_NOTIFYITEMDRAW - return wc.CDRF_SKIPDEFAULT + elif draw_stage == wc.CDDS_ITEMPREPAINT: + index = nmlvcd.nmcd.dwItemSpec + rect = nmlvcd.nmcd.rc - def _new_branch(self, index: int, state_node: StateNode): - """Collects the data corresponding to a non-leaf node item. + # Account for known bugs in the custom draw process. + if index < 0 or index >= len(self.display_list) or rect.top == rect.bottom: + return - The state-change arrow, text and icon index are all stored on the StateTree. - A blank listview item is returned so that the ListView instance doesn't throw - errors. - """ - missing_value = self.interface.missing_value - column = self._columns[0] - node = state_node.node + # Check/update the current measurements. + self._check_measurments(nmlvcd.nmcd.hdc, RECT.from_buffer_copy(rect)) - # Store the c_wchar_p objects on the StateNodes to prevent them from being - # garbage collected. - state_node.mouse_hover = index == self._mouse_move_hit - state_node.text = c_wchar_p(column.text(node, missing_value)) - state_node.icon = self._icon_index(node, column) + # If the item is not a leaf node, proceed to the next draw stage. + if not self.display_list[index].is_leaf: + return wc.CDRF_NOTIFYSUBITEMDRAW - return ( - WinForms.ListViewItem([""] * len(self._columns)), - (-1,) * len(self._columns), - ) + elif draw_stage == wc.CDDS_SUBITEM | wc.CDDS_ITEMPREPAINT: + if nmlvcd.iSubItem == 0: + rect = RECT.from_buffer_copy(nmlvcd.nmcd.rc) + hdc = HDC(nmlvcd.nmcd.hdc) + self._draw_state_change_arrow(hdc, rect, nmlvcd.nmcd.dwItemSpec) def _update_list(self, notify_select: bool = False, refresh: bool = False): """Updates the display list and the UI. From 8db21cfd8f0029ff193d22bde3436acd54e904b9 Mon Sep 17 00:00:00 2001 From: Oliver-Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Tue, 17 Mar 2026 13:24:47 +0100 Subject: [PATCH 16/30] Updated testbed for previous changes --- winforms/tests_backend/widgets/tree.py | 30 +------------------------- 1 file changed, 1 insertion(+), 29 deletions(-) diff --git a/winforms/tests_backend/widgets/tree.py b/winforms/tests_backend/widgets/tree.py index 4b2908bd67..903e350696 100644 --- a/winforms/tests_backend/widgets/tree.py +++ b/winforms/tests_backend/widgets/tree.py @@ -1,7 +1,6 @@ import asyncio import pytest -from System.Drawing import Bitmap from System.Windows.Forms import ( MouseButtons, MouseEventArgs, @@ -65,34 +64,7 @@ def assert_cell_content(self, row_path, col, value=None, icon=None, widget=None) display_index = self.impl.display_list.index(state_node) - if state_node.is_leaf: - super().assert_cell_content(display_index, col, value, icon, widget) - else: - # Try to access the row in the UI to make sure the row is created. - self.native.Items[display_index] - - if col == 0: - text = state_node.text.value - else: - # For non-leaf nodes, only column 0 is displayed." - column = self.impl._columns[col] - node = state_node.node - text = column.text(node, self.impl.interface.missing_value) - - assert text == value - - if col == 0 and icon is not None: - imagelist = self.native.SmallImageList - size = imagelist.ImageSize - assert size.Width == size.Height == 16 - - icon_index = state_node.icon_index - - actual = imagelist.Images[icon_index] - expected = Bitmap(icon._impl.bitmap, size) - for x in range(size.Width): - for y in range(size.Height): - assert actual.GetPixel(x, y) == expected.GetPixel(x, y) + super().assert_cell_content(display_index, col, value, icon, widget) self.restore_row_path(row_path, row_path_states) From 55bd42159db6fcf955fd4de69869640436fafb8b Mon Sep 17 00:00:00 2001 From: Oliver-Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:29:55 +0100 Subject: [PATCH 17/30] Disabled focus rectangle --- .../src/toga_winforms/libs/windowconstants.py | 1 + winforms/src/toga_winforms/widgets/tree.py | 15 +++++++++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/winforms/src/toga_winforms/libs/windowconstants.py b/winforms/src/toga_winforms/libs/windowconstants.py index 56024eb25d..6f77628629 100644 --- a/winforms/src/toga_winforms/libs/windowconstants.py +++ b/winforms/src/toga_winforms/libs/windowconstants.py @@ -8,6 +8,7 @@ # Custom Draw Response Flag CDRF_NOTIFYITEMDRAW = 0x00000020 CDRF_NOTIFYSUBITEMDRAW = CDRF_NOTIFYITEMDRAW +CDRF_SKIPPOSTPAINT = 0x00000100 # Draw Text DT_CALCRECT = 0x00000400 diff --git a/winforms/src/toga_winforms/widgets/tree.py b/winforms/src/toga_winforms/widgets/tree.py index 6a1b73168d..0fe7127673 100644 --- a/winforms/src/toga_winforms/widgets/tree.py +++ b/winforms/src/toga_winforms/widgets/tree.py @@ -780,15 +780,18 @@ def _nm_customdraw(self, nmlvcd) -> int | None: # Check/update the current measurements. self._check_measurments(nmlvcd.nmcd.hdc, RECT.from_buffer_copy(rect)) - # If the item is not a leaf node, proceed to the next draw stage. - if not self.display_list[index].is_leaf: - return wc.CDRF_NOTIFYSUBITEMDRAW + return wc.CDRF_NOTIFYSUBITEMDRAW elif draw_stage == wc.CDDS_SUBITEM | wc.CDDS_ITEMPREPAINT: if nmlvcd.iSubItem == 0: - rect = RECT.from_buffer_copy(nmlvcd.nmcd.rc) - hdc = HDC(nmlvcd.nmcd.hdc) - self._draw_state_change_arrow(hdc, rect, nmlvcd.nmcd.dwItemSpec) + index = nmlvcd.nmcd.dwItemSpec + if not self.display_list[index].is_leaf: + rect = RECT.from_buffer_copy(nmlvcd.nmcd.rc) + hdc = HDC(nmlvcd.nmcd.hdc) + self._draw_state_change_arrow(hdc, rect, index) + + # CDRF_SKIPPOSTPAINT means that the focus rectangle is not drawn. + return wc.CDRF_SKIPPOSTPAINT def _update_list(self, notify_select: bool = False, refresh: bool = False): """Updates the display list and the UI. From 20d7b0ca23198610d8aa267eba1908a81de7621f Mon Sep 17 00:00:00 2001 From: Oliver-Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:48:26 +0100 Subject: [PATCH 18/30] Added image for docs --- docs/en/reference/images/tree-winforms.png | Bin 0 -> 18035 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/en/reference/images/tree-winforms.png diff --git a/docs/en/reference/images/tree-winforms.png b/docs/en/reference/images/tree-winforms.png new file mode 100644 index 0000000000000000000000000000000000000000..7560742ffd52f8dc7693ecfcf3ceeb7e966f5e9a GIT binary patch literal 18035 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 Date: Wed, 18 Mar 2026 11:27:54 +0800 Subject: [PATCH 19/30] Add a widget registration for Winforms Tree. --- docs/en/reference/data/apis_by_platform.yaml | 1 - winforms/pyproject.toml | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/en/reference/data/apis_by_platform.yaml b/docs/en/reference/data/apis_by_platform.yaml index 909443655e..c7b83e206e 100644 --- a/docs/en/reference/data/apis_by_platform.yaml +++ b/docs/en/reference/data/apis_by_platform.yaml @@ -187,7 +187,6 @@ Widgets: Tree: description: A hierarchical tree of tabular data. unsupported: - - winforms - iOS - android - web diff --git a/winforms/pyproject.toml b/winforms/pyproject.toml index 31287ef3af..3ecd6027a9 100644 --- a/winforms/pyproject.toml +++ b/winforms/pyproject.toml @@ -103,7 +103,7 @@ Switch = "toga_winforms.widgets.switch:Switch" Table = "toga_winforms.widgets.table:Table" TextInput = "toga_winforms.widgets.textinput:TextInput" TimeInput = "toga_winforms.widgets.timeinput:TimeInput" -# Tree = "toga_winforms.widgets.tree:Tree" +Tree = "toga_winforms.widgets.tree:Tree" WebView = "toga_winforms.widgets.webview:WebView" # Windows From aed4f3fc589fab46e31cdf6f388ea49089f05c5d Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Wed, 18 Mar 2026 11:28:32 +0800 Subject: [PATCH 20/30] Add a second level for the tree data. --- examples/tree/tree/app.py | 50 ++++++++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/examples/tree/tree/app.py b/examples/tree/tree/app.py index 3e63c4cc0b..466232382e 100644 --- a/examples/tree/tree/app.py +++ b/examples/tree/tree/app.py @@ -16,7 +16,12 @@ "rating": "6.1", "genre": "Animation, Adventure, Comedy", }, - {"year": 1998, "title": "Bees", "rating": "6.3", "genre": "Horror"}, + { + "year": 1998, + "title": "Bees", + "rating": "6.3", + "genre": "Horror", + }, { "year": 2007, "title": "The Girl Who Swallowed Bees", @@ -64,7 +69,9 @@ def on_select_handler(self, widget): # Button callback functions def insert_handler(self, widget, **kwargs): item = choice(bee_movies) - if (year := item["year"]) >= 2000: + if (year := item["year"]) >= 2010: + root = self.decade_2010s + elif year >= 2000: root = self.decade_2000s elif year >= 1990: root = self.decade_1990s @@ -100,26 +107,37 @@ def startup(self): missing_value="?", ) - self.decade_1940s = self.tree.data.append( - {"year": "1940s", "title": "", "rating": "", "genre": ""} + century_1900s = self.tree.data.append( + {"year": "2000s", "title": "20th Century", "rating": "", "genre": ""} ) - self.decade_1950s = self.tree.data.append( - {"year": "1950s", "title": "", "rating": "", "genre": ""} + century_2000s = self.tree.data.append( + {"year": "2000s", "title": "21st Century", "rating": "", "genre": ""} ) - self.decade_1960s = self.tree.data.append( - {"year": "1960s", "title": "", "rating": "", "genre": ""} + + self.decade_1940s = century_1900s.append( + {"year": "1940s", "title": "Roaring 40s", "rating": "", "genre": ""} ) - self.decade_1970s = self.tree.data.append( - {"year": "1970s", "title": "", "rating": "", "genre": ""} + self.decade_1950s = century_1900s.append( + {"year": "1950s", "title": "Golden 50s", "rating": "", "genre": ""} ) - self.decade_1980s = self.tree.data.append( - {"year": "1980s", "title": "", "rating": "", "genre": ""} + self.decade_1960s = century_1900s.append( + {"year": "1960s", "title": "Swinging 60s", "rating": "", "genre": ""} ) - self.decade_1990s = self.tree.data.append( - {"year": "1990s", "title": "", "rating": "", "genre": ""} + self.decade_1970s = century_1900s.append( + {"year": "1970s", "title": "Groovy 70s", "rating": "", "genre": ""} + ) + self.decade_1980s = century_1900s.append( + {"year": "1980s", "title": "Radical 80s", "rating": "", "genre": ""} + ) + self.decade_1990s = century_1900s.append( + {"year": "1990s", "title": "Grunge 90s", "rating": "", "genre": ""} + ) + + self.decade_2000s = century_2000s.append( + {"year": "2000s", "title": "Naughty 00s", "rating": "", "genre": ""} ) - self.decade_2000s = self.tree.data.append( - {"year": "2000s", "title": "", "rating": "", "genre": ""} + self.decade_2010s = century_2000s.append( + {"year": "2010s", "title": "Modern 10s", "rating": "", "genre": ""} ) # Buttons From 708afb066077d1195b7ad8601c932527cf6f1966 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Wed, 18 Mar 2026 11:38:07 +0800 Subject: [PATCH 21/30] Minor code format tweak. --- winforms/src/toga_winforms/widgets/tree.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/winforms/src/toga_winforms/widgets/tree.py b/winforms/src/toga_winforms/widgets/tree.py index 0fe7127673..e43f46d8a8 100644 --- a/winforms/src/toga_winforms/widgets/tree.py +++ b/winforms/src/toga_winforms/widgets/tree.py @@ -430,7 +430,10 @@ def extend_indices(extension_start_index: int, extension_size: int, index: int) def index_modifier( - indices: list[int], insert: bool, start_index: int, range_size: int + indices: list[int], + insert: bool, + start_index: int, + range_size: int, ) -> list[int]: selection_updater = extend_indices if insert else reduce_indices selection_updater = partial(selection_updater, start_index, range_size) From 1d53f144b467f05f7519138d3d7f49fed79f7018 Mon Sep 17 00:00:00 2001 From: Oliver-Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:45:43 +0100 Subject: [PATCH 22/30] Removed unused methods & added 4 pragma: no cover --- winforms/src/toga_winforms/widgets/tree.py | 70 +++++++--------------- 1 file changed, 23 insertions(+), 47 deletions(-) diff --git a/winforms/src/toga_winforms/widgets/tree.py b/winforms/src/toga_winforms/widgets/tree.py index 4b35c39b0e..588027a9c8 100644 --- a/winforms/src/toga_winforms/widgets/tree.py +++ b/winforms/src/toga_winforms/widgets/tree.py @@ -203,22 +203,10 @@ def is_open(self) -> bool: """A StateTree is always open (expanded).""" return True - @is_open.setter - def is_open(self, value) -> None: - pass - def toggle_state(self, update_display: bool = False) -> bool: """A StateTree is always open (expanded).""" return False - def set_all_states(self, all_open: bool) -> None: - """Sets the state (open/closed) of all the child StateNode instances. - - :param all_open: Should the children be open (expanded) or closed (contracted)? - """ - super().set_all_states(all_open) - self.display_list_refresh() - ################################################################################# # Display list methods ################################################################################# @@ -361,14 +349,14 @@ def _selection_modifier( :param range_size: The size of the sublist being inserted/removed. :return: A bool indicating whether a change of selection has occurred. """ - selection_updater = extend_indices if insert else reduce_indices - selection_updater = partial(selection_updater, start_index, range_size) + index_modifier = extend_indices if insert else reduce_indices + index_modifier = partial(index_modifier, start_index, range_size) def non_negative(x: int) -> bool: return x >= 0 modified_indices = list( - filter(non_negative, map(selection_updater, self._selected_indices)) + filter(non_negative, map(index_modifier, self._selected_indices)) ) notify_select = len(modified_indices) < len(self._selected_indices) @@ -389,7 +377,9 @@ def find_state_node(self, node: Node) -> StateNode | None: if state_node.node == node: return state_node - return None + # This method is only called by _get_state_parent, where a StateNode should + # always be found. + return None # pragma: no cover def display_branch_iter( @@ -429,21 +419,6 @@ def extend_indices(extension_start_index: int, extension_size: int, index: int) return index + extension_size -def index_modifier( - indices: list[int], - insert: bool, - start_index: int, - range_size: int, -) -> list[int]: - selection_updater = extend_indices if insert else reduce_indices - selection_updater = partial(selection_updater, start_index, range_size) - - def non_negative(x: int) -> bool: - return x >= 0 - - return list(filter(non_negative, map(selection_updater, indices))) - - class Tree(Table): """The Tree widget works by storing the tree structure in a StateNode instance. This instance creates and updates a list of StateNodes which is then displayed by @@ -483,7 +458,8 @@ def create(self): # These measurements deal with the arrow size. They are checked/updated during # the drawing process. They need to be constantly monitored since a change in - # screen scaling will produce a different sized arrow. + # screen scaling will produce a different sized arrow. These values are the + # expected values for 200% scaling. self._arrow_indent: int = 2 self._arrow_width: float = 21.0 @@ -582,8 +558,6 @@ def insert(self, index, item, parent=None): def source_insert(self, *, index, item, parent=None): state_parent = self._get_state_parent(parent) - if state_parent is None: - return refresh_needed = state_parent.insert(index, item) self._update_list(refresh=refresh_needed) @@ -618,8 +592,6 @@ def remove(self, index, item, parent=None): def source_remove(self, *, index, item, parent=None): state_parent = self._get_state_parent(parent) - if state_parent is None: - return notify_select = state_parent.remove(index) self._update_list(notify_select) @@ -652,12 +624,6 @@ def selected_indices(self) -> list[int]: Note that this list is modified by the StateTree and StateNode instances.""" return self._state_tree._selected_indices - @selected_indices.setter - def selected_indices(self, indices: list[int]): - notify_select = self._state_tree._selected_indices != indices - self._state_tree._selected_indices = indices - self._selected_indices_tree_to_ui(notify_select) - def _hit_test_arrow(self, x: int, y: int) -> int: """Tests whether given coordinates are over a state-change arrow. @@ -776,7 +742,11 @@ def _check_measurments(self, hdc, rect): self._arrow_width = max(lengths) quotient, remaider = divmod(self._arrow_width, self._indent) - if remaider == 0 and self._arrow_indent != quotient: + if remaider == 0 and self._arrow_indent != quotient: # pragma: no cover + # The arrow widths should be 10,21 and 31 pixels for 100%, 200% and 300% + # scaling. Also, rect.right - rect.left should be of the form 4 + 16 * n, + # which is not satisfied by the above widths. So under normal usage this + # block is not accessed. self._arrow_indent = quotient self._update_list(refresh=True) elif remaider != 0 and self._arrow_indent != quotient + 1: @@ -834,6 +804,12 @@ def _nm_customdraw(self, nmlvcd) -> int | None: # CDRF_SKIPPOSTPAINT means that the focus rectangle is not drawn. return wc.CDRF_SKIPPOSTPAINT + else: # pragma: no cover + # The draw stage messages after CDDS_PREPAINT of custom draw should only be + # received if they are requested using appropriate return flags. However, + # custom draw is known to occasionally send incorrect messages. + pass + def _update_list(self, notify_select: bool = False, refresh: bool = False): """Updates the display list and the UI. @@ -864,11 +840,11 @@ def _get_state_parent(self, parent: Node | None = None): return self._state_tree else: state_parent = self._state_tree.find_state_node(parent) - if state_parent is None: - warn( + if state_parent is None: # pragma: no cover + # A StateNode associated to a Node should always be found. + raise NameError( f"Could not find an object managed by {self._state_tree} that " - + f"corresponds to {parent}", - stacklevel=1, + + f"corresponds to {parent}" ) return state_parent From f9811786242e1befcf59159b348d73b9a2446809 Mon Sep 17 00:00:00 2001 From: Oliver-Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:53:23 +0100 Subject: [PATCH 23/30] Test selection during toggle and non-visible nodes --- testbed/tests/widgets/test_tree.py | 111 +++++++++++++++++++++++++ winforms/tests_backend/widgets/tree.py | 4 + 2 files changed, 115 insertions(+) diff --git a/testbed/tests/widgets/test_tree.py b/testbed/tests/widgets/test_tree.py index 8a956f9724..75d01a3afe 100644 --- a/testbed/tests/widgets/test_tree.py +++ b/testbed/tests/widgets/test_tree.py @@ -224,6 +224,64 @@ async def test_select(widget, probe, source, on_select_handler): await probe.redraw("A non-shortcut key was pressed") assert widget.selection == source[1][2][0] + # Test selection remains after node with lower index is expanded + widget.collapse() + assert not probe.is_expanded(source[3]) + await probe.select_row((1,)) + await probe.redraw("A row that is below a row to be expanded is selected") + widget.expand(source[3]) + assert widget.selection == source[1] + widget.collapse(source[3]) + assert not probe.is_expanded(source[3]) + + # Test selection remains after the selected node is expanded + assert not probe.is_expanded(source[3]) + await probe.select_row((3,)) + await probe.redraw("A row that is to be expanded is selected") + widget.expand(source[3]) + assert widget.selection == source[3] + widget.collapse(source[3]) + assert not probe.is_expanded(source[3]) + + # Test selection remains after node with higher index is expanded + assert not probe.is_expanded(source[3]) + await probe.select_row((4,)) + await probe.redraw("A row that is to be expanded is selected") + widget.expand(source[3]) + assert widget.selection == source[4] + widget.collapse(source[3]) + assert not probe.is_expanded(source[3]) + + # Test selection remains after node with lower index is collapsed + widget.expand(source[3]) + assert probe.is_expanded(source[3]) + await probe.select_row((1,)) + await probe.redraw("A node is expanded and a row with lower index is selected") + widget.collapse(source[3]) + assert widget.selection == source[1] + assert not probe.is_expanded(source[3]) + + # Test selection remains after root node with higher index is collapsed + widget.expand(source[3]) + assert probe.is_expanded(source[3]) + await probe.select_row((4,)) + await probe.redraw( + "A node is expanded and a root-row with higher index is selected" + ) + widget.collapse(source[3]) + assert widget.selection == source[4] + assert not probe.is_expanded(source[3]) + + if toga.platform.current_platform in {"macOS", "windows"}: + # Test that selection is lost after a node containing selection is collapsed. + widget.expand(source[3]) + assert probe.is_expanded(source[3]) + await probe.select_row((3, 1)) + await probe.redraw("A node is expanded and a child is selected") + widget.collapse(source[3]) + assert widget.selection is None + assert not probe.is_expanded(source[3]) + async def test_expand_collapse(widget, probe, source): """Nodes can be expanded and collapsed""" @@ -457,6 +515,59 @@ async def test_expand_collapse(widget, probe, source): assert not probe.is_expanded(source[2]) assert not probe.is_expanded(source[2][2]) + # Expand non-visible node. + widget.expand(source[0][2]) + await probe.redraw("A non-visible node is expanded.") + assert not probe.is_expanded(source[0]) + assert probe.is_expanded(source[0][2]) + assert not probe.is_expanded(source[1]) + assert not probe.is_expanded(source[1][2]) + assert not probe.is_expanded(source[2]) + assert not probe.is_expanded(source[2][2]) + + # Collapse non-visible node. + widget.collapse(source[0][2]) + await probe.redraw("A non-visible node is collapsed.") + assert not probe.is_expanded(source[0]) + assert not probe.is_expanded(source[0][2]) + assert not probe.is_expanded(source[1]) + assert not probe.is_expanded(source[1][2]) + assert not probe.is_expanded(source[2]) + assert not probe.is_expanded(source[2][2]) + + # Test WinForms node toggle functionality. + if toga.platform.current_platform == "windows": + # Toggle non-visible node to open. + widget.collapse() + probe.toggle_node( + ( + 0, + 2, + ) + ) + await probe.redraw("A non-visible node is toggled to open.") + assert not probe.is_expanded(source[0]) + assert probe.is_expanded(source[0][2]) + assert not probe.is_expanded(source[1]) + assert not probe.is_expanded(source[1][2]) + assert not probe.is_expanded(source[2]) + assert not probe.is_expanded(source[2][2]) + + # Toggle non-visible node to closed. + probe.toggle_node( + ( + 0, + 2, + ) + ) + await probe.redraw("A non-visible node is toggled to closed.") + assert not probe.is_expanded(source[0]) + assert not probe.is_expanded(source[0][2]) + assert not probe.is_expanded(source[1]) + assert not probe.is_expanded(source[1][2]) + assert not probe.is_expanded(source[2]) + assert not probe.is_expanded(source[2][2]) + async def test_activate( widget, diff --git a/winforms/tests_backend/widgets/tree.py b/winforms/tests_backend/widgets/tree.py index 903e350696..ef53b60717 100644 --- a/winforms/tests_backend/widgets/tree.py +++ b/winforms/tests_backend/widgets/tree.py @@ -18,6 +18,10 @@ def display_index(self, row_path): state_node = self.state_node(row_path) return self.impl.display_list.index(state_node) + def toggle_node(self, row_path): + state_node = self.state_node(row_path) + state_node.toggle_state(True) + async def expand_tree(self): self.impl.expand_all() await asyncio.sleep(0.1) From d1679054fa28ec90351ad72b112633b643cef130 Mon Sep 17 00:00:00 2001 From: Oliver-Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:40:27 +0100 Subject: [PATCH 24/30] Added tests for mouse events --- testbed/tests/widgets/test_tree.py | 49 ++++++ winforms/src/toga_winforms/widgets/tree.py | 3 +- winforms/tests_backend/widgets/tree.py | 168 ++++++++++++++++++++- 3 files changed, 211 insertions(+), 9 deletions(-) diff --git a/testbed/tests/widgets/test_tree.py b/testbed/tests/widgets/test_tree.py index 75d01a3afe..2f97f60bee 100644 --- a/testbed/tests/widgets/test_tree.py +++ b/testbed/tests/widgets/test_tree.py @@ -1096,3 +1096,52 @@ def test_deprecated_methods(widget, method_name, args, expected_args): method(**args) mock_method.assert_called_once_with(**expected_args) + + +async def test_mouse_events(widget, probe, on_activate_handler): + skip_on_platforms("android", "iOS", "linux", "macOS") + """Does the widget implement mouse events correctly?""" + + # Use the small data + small_data = [ + ( + {"a": "A0", "b": "", "c": ""}, + [({"a": f"A{i}", "b": i, "c": "C"}, None) for i in range(2)], + ) + ] + + widget.data = small_data + widget.collapse() + await probe.redraw("Tree is collapsed and awaiting hover of state-change arrow") + assert not probe.is_expanded(widget.data[0]) + await probe.assert_item_mouse_hover((0,)) + + widget.expand(widget.data[0]) + await probe.redraw("A node is expanded and awaiting hover of state-change arrow") + assert probe.is_expanded(widget.data[0]) + await probe.assert_item_mouse_hover((0,)) + + # Simulate clicks on the state change arrow. + widget.collapse() + await probe.redraw("Tree is collapsed and awaiting toggle by mouse click") + await probe.single_click((0,), toggle=True, on_item=True) + assert widget.selection is None + await probe.single_click((0,), toggle=True, on_item=True) + assert widget.selection is None + + # Simulate a normal item selection click. + await probe.redraw("Tree is collapsed and awaiting an item selection mouse click") + await probe.single_click((0,), toggle=False, on_item=True) + assert widget.selection == widget.data[0] + + # Simulate a normal selection click in the client area, but away from items. + await probe.redraw("Tree is collapsed and awaiting an item selection mouse click") + await probe.single_click((0,), toggle=False, on_item=False) + assert widget.selection is None + + # Double clicking on a state-change arrow doesn't activate the row.") + await probe.double_click_state_change_arrow((0,)) + on_activate_handler.assert_not_called() + + # Test the mouse cursor leaving the client area + await probe.assert_mouse_leave() diff --git a/winforms/src/toga_winforms/widgets/tree.py b/winforms/src/toga_winforms/widgets/tree.py index 588027a9c8..8ef3ceaf36 100644 --- a/winforms/src/toga_winforms/widgets/tree.py +++ b/winforms/src/toga_winforms/widgets/tree.py @@ -622,7 +622,7 @@ def selected_indices(self) -> list[int]: """The list of currently selected indices. Note that this list is modified by the StateTree and StateNode instances.""" - return self._state_tree._selected_indices + return self._state_tree.selected_indices def _hit_test_arrow(self, x: int, y: int) -> int: """Tests whether given coordinates are over a state-change arrow. @@ -697,6 +697,7 @@ def winforms_mouse_leave(self, sender, e): movement beginning at the state-change arrow will not register as a MouseClick or MouseUp event. """ + print("here") self._mouse_down_hit = -1 self._set_mouse_move_hit(-1) diff --git a/winforms/tests_backend/widgets/tree.py b/winforms/tests_backend/widgets/tree.py index ef53b60717..247837c83f 100644 --- a/winforms/tests_backend/widgets/tree.py +++ b/winforms/tests_backend/widgets/tree.py @@ -45,14 +45,9 @@ async def activate_row(self, row_path): await super().select_row(row=display_index) bounds = self.native.Items[display_index].Bounds - self.native.OnMouseDoubleClick( - MouseEventArgs( - MouseButtons.Left, - clicks=2, - x=int((bounds.Left + bounds.Right) / 2), - y=int((bounds.Top + bounds.Bottom) / 2), - delta=0, - ) + self.mouse_double_click_event( + x=int((bounds.Left + bounds.Right) / 2), + y=int((bounds.Top + bounds.Bottom) / 2), ) def assert_cell_content(self, row_path, col, value=None, icon=None, widget=None): @@ -109,3 +104,160 @@ def restore_row_path(self, row_path, row_path_states): if state_node.is_open != original_state: state_node.toggle_state(update_display=True) self.impl._update_list(True) + + #################################################################################### + # The following are mouse events used for testing + #################################################################################### + + def mouse_move_event(self, x: int, y: int): + self.native.OnMouseMove( + MouseEventArgs( + getattr(MouseButtons, "None"), + clicks=0, + x=x, + y=y, + delta=0, + ) + ) + + def mouse_double_click_event(self, x: int, y: int): + self.native.OnMouseDoubleClick( + MouseEventArgs( + MouseButtons.Left, + clicks=2, + x=x, + y=y, + delta=0, + ) + ) + + def full_mouse_click_event(self, x: int, y: int): + # According to the documentation the "standard Click event behavior" is the + # sequence MouseDown, Click, MouseClick, MouseUp. + # https://learn.microsoft.com/en-us/dotnet/desktop/winforms/input-mouse/events + + mouse_event_args = MouseEventArgs( + MouseButtons.Left, + clicks=1, + x=x, + y=y, + delta=0, + ) + + self.native.OnMouseDown(mouse_event_args) + + # A simulated click doesn't change selection. Note: This basic implementation is + # not compatible with multiple_select=True. + lvi = self.native.HitTest(x, y).Item + if lvi is None: + for index in self.native.SelectedIndices: + self.native.Items[index].Selected = False + else: + lvi.Selected = True + + self.native.OnClick(mouse_event_args) + self.native.OnMouseClick(mouse_event_args) + self.native.OnMouseUp(mouse_event_args) + + def mouse_leave_event(self): + self.native.OnMouseLeave( + MouseEventArgs( + getattr(MouseButtons, "None"), + clicks=0, + x=0, + y=0, + delta=0, + ) + ) + + #################################################################################### + # The following are tests based on mouse events + #################################################################################### + + async def assert_item_mouse_hover(self, row_path): + state_node = self.state_node(row_path) + display_index = self.impl.display_list.index(state_node) + lvi = self.native.Items[display_index] + bounds = lvi.Bounds + + assert not state_node.mouse_hover + assert self.impl._mouse_move_hit in {-1, -2} + + # Move mouse over state-change arrow + self.mouse_move_event( + x=int(state_node.arrow_center_x), + y=int((bounds.Top + bounds.Bottom) / 2), + ) + await asyncio.sleep(0.1) + assert state_node.mouse_hover + assert self.impl._mouse_move_hit == 0 + + # Move mouse away from state-change arrow, but still on the item. + self.mouse_move_event( + x=int(state_node.arrow_center_x + 40), + y=int((bounds.Top + bounds.Bottom) / 2), + ) + await asyncio.sleep(0.1) + assert not state_node.mouse_hover + assert self.impl._mouse_move_hit == -1 + + # Move mouse away within item range but not on arrow. + self.mouse_move_event( + x=int(state_node.arrow_center_x + 80), + y=int((bounds.Top + bounds.Bottom) / 2), + ) + await asyncio.sleep(0.1) + assert not state_node.mouse_hover + assert self.impl._mouse_move_hit == -1 + + # Move mouse to client area with no items + self.mouse_move_event( + x=int(state_node.arrow_center_x), + y=int((bounds.Top + bounds.Bottom) / 2 + 3 * (bounds.Bottom - bounds.Top)), + ) + await asyncio.sleep(0.1) + assert not state_node.mouse_hover + assert self.impl._mouse_move_hit == -2 + + async def single_click(self, row_path, toggle: bool, on_item: bool): + # Item must be visible and drawing finished. + state_node = self.state_node(row_path) + display_index = self.impl.display_list.index(state_node) + bounds = self.native.Items[display_index].Bounds + + old_is_open = state_node.is_open + + if toggle: + x = int(state_node.arrow_center_x) + else: + x = int(state_node.arrow_center_x + 40) + + if on_item: + y = int((bounds.Top + bounds.Bottom) / 2) + else: + y = int((bounds.Top + bounds.Bottom) / 2 + 3 * (bounds.Bottom - bounds.Top)) + self.full_mouse_click_event(x=x, y=y) + + await asyncio.sleep(0.1) + if toggle and on_item: + assert old_is_open != state_node.is_open + else: + assert old_is_open == state_node.is_open + + async def double_click_state_change_arrow(self, row_path): + # Note that item drawing must be finished for state_node.arrow_center_x to be + # correct. + state_node = self.state_node(row_path) + display_index = self.impl.display_list.index(state_node) + bounds = self.native.Items[display_index].Bounds + + self.mouse_double_click_event( + x=int(state_node.arrow_center_x), + y=int((bounds.Top + bounds.Bottom) / 2), + ) + + async def assert_mouse_leave(self): + self.mouse_leave_event() + + assert self.impl._mouse_move_hit == -1 + assert self.impl._mouse_down_hit == -1 From 401284ce820e63c4836343b73c9d4c0672dba8aa Mon Sep 17 00:00:00 2001 From: Oliver-Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:08:48 +0100 Subject: [PATCH 25/30] Removed new tests for macOS and Linux --- testbed/tests/widgets/test_tree.py | 55 ++++++++++++++---------------- 1 file changed, 25 insertions(+), 30 deletions(-) diff --git a/testbed/tests/widgets/test_tree.py b/testbed/tests/widgets/test_tree.py index 2f97f60bee..de65f080d1 100644 --- a/testbed/tests/widgets/test_tree.py +++ b/testbed/tests/widgets/test_tree.py @@ -515,36 +515,33 @@ async def test_expand_collapse(widget, probe, source): assert not probe.is_expanded(source[2]) assert not probe.is_expanded(source[2][2]) - # Expand non-visible node. - widget.expand(source[0][2]) - await probe.redraw("A non-visible node is expanded.") - assert not probe.is_expanded(source[0]) - assert probe.is_expanded(source[0][2]) - assert not probe.is_expanded(source[1]) - assert not probe.is_expanded(source[1][2]) - assert not probe.is_expanded(source[2]) - assert not probe.is_expanded(source[2][2]) + # Test WinForms expand/collapse for non-visible nodes. + if toga.platform.current_platform == "windows": + # Expand non-visible node. + widget.expand(source[0][2]) + await probe.redraw("A non-visible node is expanded.") + assert not probe.is_expanded(source[0]) + assert probe.is_expanded(source[0][2]) + assert not probe.is_expanded(source[1]) + assert not probe.is_expanded(source[1][2]) + assert not probe.is_expanded(source[2]) + assert not probe.is_expanded(source[2][2]) - # Collapse non-visible node. - widget.collapse(source[0][2]) - await probe.redraw("A non-visible node is collapsed.") - assert not probe.is_expanded(source[0]) - assert not probe.is_expanded(source[0][2]) - assert not probe.is_expanded(source[1]) - assert not probe.is_expanded(source[1][2]) - assert not probe.is_expanded(source[2]) - assert not probe.is_expanded(source[2][2]) + # Collapse non-visible node. + widget.collapse(source[0][2]) + await probe.redraw("A non-visible node is collapsed.") + assert not probe.is_expanded(source[0]) + assert not probe.is_expanded(source[0][2]) + assert not probe.is_expanded(source[1]) + assert not probe.is_expanded(source[1][2]) + assert not probe.is_expanded(source[2]) + assert not probe.is_expanded(source[2][2]) # Test WinForms node toggle functionality. if toga.platform.current_platform == "windows": # Toggle non-visible node to open. widget.collapse() - probe.toggle_node( - ( - 0, - 2, - ) - ) + probe.toggle_node((0, 2)) await probe.redraw("A non-visible node is toggled to open.") assert not probe.is_expanded(source[0]) assert probe.is_expanded(source[0][2]) @@ -554,12 +551,7 @@ async def test_expand_collapse(widget, probe, source): assert not probe.is_expanded(source[2][2]) # Toggle non-visible node to closed. - probe.toggle_node( - ( - 0, - 2, - ) - ) + probe.toggle_node((0, 2)) await probe.redraw("A non-visible node is toggled to closed.") assert not probe.is_expanded(source[0]) assert not probe.is_expanded(source[0][2]) @@ -1101,6 +1093,9 @@ def test_deprecated_methods(widget, method_name, args, expected_args): async def test_mouse_events(widget, probe, on_activate_handler): skip_on_platforms("android", "iOS", "linux", "macOS") """Does the widget implement mouse events correctly?""" + # These tests are WinForms specific + if toga.platform.current_platform != "windows": + return # Use the small data small_data = [ From 8c1ce235f444b9dd9d2fa19e2d395ea1a41e770d Mon Sep 17 00:00:00 2001 From: Oliver-Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Sat, 21 Mar 2026 12:55:11 +0100 Subject: [PATCH 26/30] Implemented focused item functionality --- .../src/toga_winforms/libs/comctl32classes.py | 15 ++++ .../src/toga_winforms/libs/windowconstants.py | 4 + winforms/src/toga_winforms/widgets/tree.py | 89 ++++++++++++++++--- 3 files changed, 97 insertions(+), 11 deletions(-) diff --git a/winforms/src/toga_winforms/libs/comctl32classes.py b/winforms/src/toga_winforms/libs/comctl32classes.py index 6893892aa1..3f357b6496 100644 --- a/winforms/src/toga_winforms/libs/comctl32classes.py +++ b/winforms/src/toga_winforms/libs/comctl32classes.py @@ -10,6 +10,7 @@ INT, LPARAM, LPWSTR, + POINT, RECT, UINT, WPARAM, @@ -61,6 +62,20 @@ class NMCUSTOMDRAW(c_Structure): ] +# https://learn.microsoft.com/en-us/windows/win32/api/commctrl/ns-commctrl-nmlistview +class NMLISTVIEW(c_Structure): + _fields_ = [ + ("hdr", NMHDR), + ("iItem", INT), + ("iSubItem", INT), + ("uNewState", UINT), + ("uOldState", UINT), + ("uChanged", UINT), + ("ptAction", POINT), + ("lParam", LPARAM), + ] + + # https://learn.microsoft.com/en-us/windows/win32/api/commctrl/ns-commctrl-nmlvcustomdraw class NMLVCUSTOMDRAW(c_Structure): _fields_ = [ diff --git a/winforms/src/toga_winforms/libs/windowconstants.py b/winforms/src/toga_winforms/libs/windowconstants.py index 6f77628629..0f928e22f4 100644 --- a/winforms/src/toga_winforms/libs/windowconstants.py +++ b/winforms/src/toga_winforms/libs/windowconstants.py @@ -26,12 +26,16 @@ LVIF_IMAGE = 0x0002 LVIF_STATE = 0x0008 +# List-View Item State +LVIS_FOCUSED = 0x0001 + # List-View Management LVM_GETEXTENDEDLISTVIEWSTYLE = 0x1037 LVM_SETEXTENDEDLISTVIEWSTYLE = 0x1036 # List-View Notification LVN_GETDISPINFOW = 0xFFFFFF4F +LVN_ITEMCHANGED = 0xFFFFFF9B # List-View Styles (Extended) LVS_EX_SUBITEMIMAGES = 0x2 diff --git a/winforms/src/toga_winforms/widgets/tree.py b/winforms/src/toga_winforms/widgets/tree.py index 8ef3ceaf36..4a544d5b08 100644 --- a/winforms/src/toga_winforms/widgets/tree.py +++ b/winforms/src/toga_winforms/widgets/tree.py @@ -9,7 +9,7 @@ from ..libs import windowconstants as wc from ..libs.comctl32 import DefSubclassProc, RemoveWindowSubclass -from ..libs.comctl32classes import NMHDR, NMLVCUSTOMDRAW, NMLVDISPINFOW +from ..libs.comctl32classes import NMHDR, NMLISTVIEW, NMLVCUSTOMDRAW, NMLVDISPINFOW from ..libs.user32 import DrawTextW from ..libs.win32 import LRESULT from .table import Table @@ -192,6 +192,7 @@ def __init__(self, tree_source: TreeSourceT): self._display_list: list[StateNode] = [] self.display_list_refresh() self._selected_indices: list[int] = [] + self._focused_index: int | None = None @property def is_leaf(self) -> bool: @@ -298,7 +299,7 @@ def _display_list_modifier( + self._display_list[start_index + len(sublist) :] ) - return self._selection_modifier(insert, start_index, len(sublist)) + return self._selection_and_focus_modifier(insert, start_index, len(sublist)) def _display_list_toggle(self, state_node: StateNode) -> bool: """Updates the display list to reflect a node being toggled. @@ -317,7 +318,7 @@ def _display_list_toggle(self, state_node: StateNode) -> bool: return self._display_list_modifier(insert, sublist, start_index) ################################################################################# - # Selected Indices list methods + # Selected indices list and focused item methods ################################################################################# @property @@ -329,7 +330,16 @@ def selected_indices_from_ui(self, selected_indices: list[int]): """Updates the selected indices list of the StateNode to match the UI.""" self._selected_indices = selected_indices - def _selection_modifier( + @property + def focused_index(self) -> int | None: + """Index of the display list that corresponds to the focused item.""" + return self._focused_index + + def focused_index_from_ui(self, focused_index: int | None): + """Updates the focused index of the StateTree to match the UI.""" + self._focused_index = focused_index + + def _selection_and_focus_modifier( self, insert: bool, start_index: int, @@ -359,8 +369,12 @@ def non_negative(x: int) -> bool: filter(non_negative, map(index_modifier, self._selected_indices)) ) notify_select = len(modified_indices) < len(self._selected_indices) - self._selected_indices = modified_indices + + if self._focused_index is not None: + modified_focus = index_modifier(self._focused_index) + self._focused_index = None if modified_focus < 0 else modified_focus + return notify_select ################################################################################# @@ -498,6 +512,10 @@ def _subclass_proc( if return_flag is not None: return return_flag + elif code == wc.LVN_ITEMCHANGED: + nmlv = cast(lParam, POINTER(NMLISTVIEW)).contents + self._lvn_item_changed(nmlv) + # Call the original window procedure return DefSubclassProc(HWND(hWnd), UINT(uMsg), WPARAM(wParam), LPARAM(lParam)) @@ -624,6 +642,13 @@ def selected_indices(self) -> list[int]: Note that this list is modified by the StateTree and StateNode instances.""" return self._state_tree.selected_indices + @property + def focused_index(self) -> int | None: + """The index of the currently focused item. + + Note that this is modified by the StateTree and StateNode instances.""" + return self._state_tree.focused_index + def _hit_test_arrow(self, x: int, y: int) -> int: """Tests whether given coordinates are over a state-change arrow. @@ -683,6 +708,26 @@ def _selected_indices_tree_to_ui(self, notify_select: bool = False): if notify_select: self.interface.on_select() + def _process_focus_change(self, focused_index: int): + """Overrides state-change-arrow focus-change events and allows the rest.""" + if self._mouse_down_hit >= 0: + self._focused_index_tree_to_ui() + else: + self._focused_index_ui_to_tree(focused_index) + + def _focused_index_ui_to_tree(self, focused_index): + if focused_index < 0: + focused_index = None + + self._state_tree.focused_index_from_ui(focused_index) + + def _focused_index_tree_to_ui(self): + focus_index = self.focused_index + if focus_index is not None: + self.native.Items[focus_index].Focused = True + elif self.native.FocusedItem is not None: + self.native.FocusedItem.Focused = False + def winforms_mouse_move(self, sender, e): self._set_mouse_move_hit(self._hit_test_arrow(e.X, e.Y)) @@ -697,7 +742,6 @@ def winforms_mouse_leave(self, sender, e): movement beginning at the state-change arrow will not register as a MouseClick or MouseUp event. """ - print("here") self._mouse_down_hit = -1 self._set_mouse_move_hit(-1) @@ -773,6 +817,28 @@ def _draw_state_change_arrow(self, hdc, rect, index: int): ) DrawTextW(hdc, c_wchar_p(arrow), -1, byref(rect), text_format) + def _lvn_item_changed(self, nmlv): + """Processes List-View item changes to listen for a change of focused item.""" + # learn.microsoft.com/en-us/windows/win32/controls/lvn-itemchanged + # learn.microsoft.com/en-us/windows/win32/api/commctrl/ns-commctrl-nmlistview + # + # There is no WinForms focused-item change event and the using the WinForms + # selection change events with ListView.FocusedItem gives unreliable results + # when deselecting items. So, the change of focus is retrieved directly from the + # Win32 messages. + # + # According to the documentation, a change of focused index is recorded in + # nmlv.uChanged. This has values coming from the uiMask attribute of the LVITEMW + # structure, and a change of focused index is recorded in LVIF_STATE. + if nmlv.uChanged & wc.LVIF_STATE != 0: + # uNewState and uOldState have values determined by List-View Item States + # learn.microsoft.com/en-us/windows/win32/controls/list-view-item-states + is_focused_old = nmlv.uOldState & wc.LVIS_FOCUSED != 0 + is_focused = nmlv.uNewState & wc.LVIS_FOCUSED != 0 + + if is_focused and is_focused != is_focused_old: + self._process_focus_change(nmlv.iItem) + def _nm_customdraw(self, nmlvcd) -> int | None: """Paints the non-leaf node items.""" # learn.microsoft.com/en-us/windows/win32/controls/using-custom-draw @@ -792,19 +858,19 @@ def _nm_customdraw(self, nmlvcd) -> int | None: # Check/update the current measurements. self._check_measurments(nmlvcd.nmcd.hdc, RECT.from_buffer_copy(rect)) - return wc.CDRF_NOTIFYSUBITEMDRAW + # Progress to next subitem draw stage for non-leaf nodes to draw arrow. + if not self.display_list[index].is_leaf: + return wc.CDRF_NOTIFYSUBITEMDRAW elif draw_stage == wc.CDDS_SUBITEM | wc.CDDS_ITEMPREPAINT: + index = nmlvcd.nmcd.dwItemSpec + if nmlvcd.iSubItem == 0: - index = nmlvcd.nmcd.dwItemSpec if not self.display_list[index].is_leaf: rect = RECT.from_buffer_copy(nmlvcd.nmcd.rc) hdc = HDC(nmlvcd.nmcd.hdc) self._draw_state_change_arrow(hdc, rect, index) - # CDRF_SKIPPOSTPAINT means that the focus rectangle is not drawn. - return wc.CDRF_SKIPPOSTPAINT - else: # pragma: no cover # The draw stage messages after CDDS_PREPAINT of custom draw should only be # received if they are requested using appropriate return flags. However, @@ -825,6 +891,7 @@ def _update_list(self, notify_select: bool = False, refresh: bool = False): self._cache = [] # This _selected_indices_tree_to_ui is needed for responsiveness. self._selected_indices_tree_to_ui(notify_select) + self._focused_index_tree_to_ui() if refresh: self.native.Refresh() From de665efa9bbaacbe7fb79c7216dfd4b1e272ba08 Mon Sep 17 00:00:00 2001 From: Oliver-Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:47:28 +0100 Subject: [PATCH 27/30] Minor changes and added 1 pragma no cover --- winforms/src/toga_winforms/widgets/tree.py | 34 ++++++++++++---------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/winforms/src/toga_winforms/widgets/tree.py b/winforms/src/toga_winforms/widgets/tree.py index 4a544d5b08..7239de460a 100644 --- a/winforms/src/toga_winforms/widgets/tree.py +++ b/winforms/src/toga_winforms/widgets/tree.py @@ -713,13 +713,7 @@ def _process_focus_change(self, focused_index: int): if self._mouse_down_hit >= 0: self._focused_index_tree_to_ui() else: - self._focused_index_ui_to_tree(focused_index) - - def _focused_index_ui_to_tree(self, focused_index): - if focused_index < 0: - focused_index = None - - self._state_tree.focused_index_from_ui(focused_index) + self._state_tree.focused_index_from_ui(focused_index) def _focused_index_tree_to_ui(self): focus_index = self.focused_index @@ -827,9 +821,9 @@ def _lvn_item_changed(self, nmlv): # when deselecting items. So, the change of focus is retrieved directly from the # Win32 messages. # - # According to the documentation, a change of focused index is recorded in - # nmlv.uChanged. This has values coming from the uiMask attribute of the LVITEMW - # structure, and a change of focused index is recorded in LVIF_STATE. + # nmlv.uChanged contains flags for the attributes have been changed. These flag + # values come from the uiMask attribute of the LVITEMW structure, and a change + # of focused index is recorded in LVIF_STATE. if nmlv.uChanged & wc.LVIF_STATE != 0: # uNewState and uOldState have values determined by List-View Item States # learn.microsoft.com/en-us/windows/win32/controls/list-view-item-states @@ -839,6 +833,13 @@ def _lvn_item_changed(self, nmlv): if is_focused and is_focused != is_focused_old: self._process_focus_change(nmlv.iItem) + else: # pragma: no cover + # The List-View UI is in virtual mode and changes to the data occur in + # python. At which point the cache is deleted and rebuilt. This means that + # the only changes which trigger LVN_ITEMCHANGED are state changes. So + # this block should never be accessed. + pass + def _nm_customdraw(self, nmlvcd) -> int | None: """Paints the non-leaf node items.""" # learn.microsoft.com/en-us/windows/win32/controls/using-custom-draw @@ -865,11 +866,14 @@ def _nm_customdraw(self, nmlvcd) -> int | None: elif draw_stage == wc.CDDS_SUBITEM | wc.CDDS_ITEMPREPAINT: index = nmlvcd.nmcd.dwItemSpec - if nmlvcd.iSubItem == 0: - if not self.display_list[index].is_leaf: - rect = RECT.from_buffer_copy(nmlvcd.nmcd.rc) - hdc = HDC(nmlvcd.nmcd.hdc) - self._draw_state_change_arrow(hdc, rect, index) + # This draw state is only accessed when CDRF_NOTIFYSUBITEMDRAW is returned + # during CDDS_ITEMPREPAINT. Hence this block should only be accessed when + # self.display_list[index].is_leaf is not true. However due to the existence + # of occasional incorrect custom draw messages, this is checked again here. + if nmlvcd.iSubItem == 0 and not self.display_list[index].is_leaf: + rect = RECT.from_buffer_copy(nmlvcd.nmcd.rc) + hdc = HDC(nmlvcd.nmcd.hdc) + self._draw_state_change_arrow(hdc, rect, index) else: # pragma: no cover # The draw stage messages after CDDS_PREPAINT of custom draw should only be From 8c2d8e54f569e5b5f6d12618cc95310baef67997 Mon Sep 17 00:00:00 2001 From: Oliver-Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Sun, 22 Mar 2026 12:58:25 +0100 Subject: [PATCH 28/30] Updated tests to cover focus rectangle code --- testbed/tests/widgets/test_tree.py | 17 +++++++++++++---- winforms/tests_backend/widgets/tree.py | 8 +++++--- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/testbed/tests/widgets/test_tree.py b/testbed/tests/widgets/test_tree.py index de65f080d1..29c68f49cd 100644 --- a/testbed/tests/widgets/test_tree.py +++ b/testbed/tests/widgets/test_tree.py @@ -1097,12 +1097,16 @@ async def test_mouse_events(widget, probe, on_activate_handler): if toga.platform.current_platform != "windows": return - # Use the small data + # Use some small data small_data = [ ( {"a": "A0", "b": "", "c": ""}, - [({"a": f"A{i}", "b": i, "c": "C"}, None) for i in range(2)], - ) + [({"a": f"A0{i}", "b": i, "c": "C"}, None) for i in range(2)], + ), + ( + {"a": "A1", "b": "", "c": ""}, + [({"a": f"A1{i}", "b": i, "c": "C"}, None) for i in range(2)], + ), ] widget.data = small_data @@ -1129,9 +1133,14 @@ async def test_mouse_events(widget, probe, on_activate_handler): await probe.single_click((0,), toggle=False, on_item=True) assert widget.selection == widget.data[0] + # Simulate a normal item selection click on a different item. + await probe.redraw("Tree is awaiting a different item selection mouse click") + await probe.single_click((1,), toggle=False, on_item=True) + assert widget.selection == widget.data[1] + # Simulate a normal selection click in the client area, but away from items. await probe.redraw("Tree is collapsed and awaiting an item selection mouse click") - await probe.single_click((0,), toggle=False, on_item=False) + await probe.single_click((1,), toggle=False, on_item=False) assert widget.selection is None # Double clicking on a state-change arrow doesn't activate the row.") diff --git a/winforms/tests_backend/widgets/tree.py b/winforms/tests_backend/widgets/tree.py index 247837c83f..a1ce44209e 100644 --- a/winforms/tests_backend/widgets/tree.py +++ b/winforms/tests_backend/widgets/tree.py @@ -39,6 +39,7 @@ def child_count(self, row_path=None): async def select_row(self, row_path, add=False): display_index = self.display_index(row_path) await super().select_row(row=display_index, add=add) + self.native.Items[display_index].Focused = True async def activate_row(self, row_path): display_index = self.display_index(row_path) @@ -146,14 +147,15 @@ def full_mouse_click_event(self, x: int, y: int): self.native.OnMouseDown(mouse_event_args) - # A simulated click doesn't change selection. Note: This basic implementation is - # not compatible with multiple_select=True. + # A simulated click doesn't change selection or focused index. Note: This basic + # implementation is not compatible with multiple_select=True. lvi = self.native.HitTest(x, y).Item if lvi is None: for index in self.native.SelectedIndices: self.native.Items[index].Selected = False else: lvi.Selected = True + lvi.Focused = True self.native.OnClick(mouse_event_args) self.native.OnMouseClick(mouse_event_args) @@ -213,7 +215,7 @@ async def assert_item_mouse_hover(self, row_path): # Move mouse to client area with no items self.mouse_move_event( x=int(state_node.arrow_center_x), - y=int((bounds.Top + bounds.Bottom) / 2 + 3 * (bounds.Bottom - bounds.Top)), + y=int((bounds.Top + bounds.Bottom) / 2 + 5 * (bounds.Bottom - bounds.Top)), ) await asyncio.sleep(0.1) assert not state_node.mouse_hover From fb15fb75d273838617b4623f57d52e4423011e2f Mon Sep 17 00:00:00 2001 From: Oliver-Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:50:17 +0100 Subject: [PATCH 29/30] Remove expand/collapse platform specific assert.s --- testbed/tests/widgets/test_tree.py | 67 +++++----------------- winforms/src/toga_winforms/widgets/tree.py | 5 +- winforms/tests_backend/widgets/tree.py | 12 ++-- 3 files changed, 21 insertions(+), 63 deletions(-) diff --git a/testbed/tests/widgets/test_tree.py b/testbed/tests/widgets/test_tree.py index 29c68f49cd..50f17c722e 100644 --- a/testbed/tests/widgets/test_tree.py +++ b/testbed/tests/widgets/test_tree.py @@ -272,15 +272,14 @@ async def test_select(widget, probe, source, on_select_handler): assert widget.selection == source[4] assert not probe.is_expanded(source[3]) - if toga.platform.current_platform in {"macOS", "windows"}: - # Test that selection is lost after a node containing selection is collapsed. - widget.expand(source[3]) - assert probe.is_expanded(source[3]) - await probe.select_row((3, 1)) - await probe.redraw("A node is expanded and a child is selected") - widget.collapse(source[3]) - assert widget.selection is None - assert not probe.is_expanded(source[3]) + # Exercise code for collapsing a node whose child is selected. Note that this + # behavior is inconsistent between platforms. + widget.expand(source[3]) + assert probe.is_expanded(source[3]) + await probe.select_row((3, 1)) + await probe.redraw("A node is expanded and a child is selected") + widget.collapse(source[3]) + assert not probe.is_expanded(source[3]) async def test_expand_collapse(widget, probe, source): @@ -515,50 +514,12 @@ async def test_expand_collapse(widget, probe, source): assert not probe.is_expanded(source[2]) assert not probe.is_expanded(source[2][2]) - # Test WinForms expand/collapse for non-visible nodes. - if toga.platform.current_platform == "windows": - # Expand non-visible node. - widget.expand(source[0][2]) - await probe.redraw("A non-visible node is expanded.") - assert not probe.is_expanded(source[0]) - assert probe.is_expanded(source[0][2]) - assert not probe.is_expanded(source[1]) - assert not probe.is_expanded(source[1][2]) - assert not probe.is_expanded(source[2]) - assert not probe.is_expanded(source[2][2]) - - # Collapse non-visible node. - widget.collapse(source[0][2]) - await probe.redraw("A non-visible node is collapsed.") - assert not probe.is_expanded(source[0]) - assert not probe.is_expanded(source[0][2]) - assert not probe.is_expanded(source[1]) - assert not probe.is_expanded(source[1][2]) - assert not probe.is_expanded(source[2]) - assert not probe.is_expanded(source[2][2]) - - # Test WinForms node toggle functionality. - if toga.platform.current_platform == "windows": - # Toggle non-visible node to open. - widget.collapse() - probe.toggle_node((0, 2)) - await probe.redraw("A non-visible node is toggled to open.") - assert not probe.is_expanded(source[0]) - assert probe.is_expanded(source[0][2]) - assert not probe.is_expanded(source[1]) - assert not probe.is_expanded(source[1][2]) - assert not probe.is_expanded(source[2]) - assert not probe.is_expanded(source[2][2]) - - # Toggle non-visible node to closed. - probe.toggle_node((0, 2)) - await probe.redraw("A non-visible node is toggled to closed.") - assert not probe.is_expanded(source[0]) - assert not probe.is_expanded(source[0][2]) - assert not probe.is_expanded(source[1]) - assert not probe.is_expanded(source[1][2]) - assert not probe.is_expanded(source[2]) - assert not probe.is_expanded(source[2][2]) + # Exercise code for expanding/collapsing non-visible nodes. Note that this + # behavior is inconsistent between platforms. + widget.expand(source[0][2]) + await probe.redraw("A non-visible node is expanded.") + widget.collapse(source[0][2]) + await probe.redraw("A non-visible node is collapsed.") async def test_activate( diff --git a/winforms/src/toga_winforms/widgets/tree.py b/winforms/src/toga_winforms/widgets/tree.py index 7239de460a..bb48f7d4a3 100644 --- a/winforms/src/toga_winforms/widgets/tree.py +++ b/winforms/src/toga_winforms/widgets/tree.py @@ -307,14 +307,15 @@ def _display_list_toggle(self, state_node: StateNode) -> bool: :param state_node: The StateNode being toggled. :return: A bool indicating whether a change of selection has occurred. """ - insert: bool = state_node.is_open - sublist: list[StateNode] = list(state_node.branch_iter(display=True)) try: start_index: int = self._display_list.index(state_node) + 1 except ValueError: # state_node is not in the display list, so no need to change it. return False + insert: bool = state_node.is_open + sublist: list[StateNode] = list(state_node.branch_iter(display=True)) + return self._display_list_modifier(insert, sublist, start_index) ################################################################################# diff --git a/winforms/tests_backend/widgets/tree.py b/winforms/tests_backend/widgets/tree.py index a1ce44209e..a5c3445adb 100644 --- a/winforms/tests_backend/widgets/tree.py +++ b/winforms/tests_backend/widgets/tree.py @@ -93,15 +93,11 @@ def restore_row_path(self, row_path, row_path_states): if row_path is None or len(row_path) < 2: return - state_tree = self.impl._state_tree - for i, _ in enumerate(row_path[:-1]): - if i == 0: - state_node = state_tree[row_path[:-1]] - else: - state_node = state_tree[row_path[: -(i + 1)]] - - original_state = row_path_states[-(i + 1)] + state_node = self.impl._state_tree + for j, i in enumerate(row_path[:-1]): + state_node = state_node[(i,)] + original_state = row_path_states[j] if state_node.is_open != original_state: state_node.toggle_state(update_display=True) self.impl._update_list(True) From 8cc2dd2299942413c073a9a3c6fc32a0de4670a8 Mon Sep 17 00:00:00 2001 From: Oliver-Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:58:04 +0100 Subject: [PATCH 30/30] test_mouse_events as pytest.skip on non-Windows --- cocoa/tests_backend/widgets/tree.py | 3 +++ gtk/tests_backend/widgets/tree.py | 3 +++ qt/tests_backend/widgets/tree.py | 5 ++++- testbed/tests/widgets/test_tree.py | 6 ++---- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/cocoa/tests_backend/widgets/tree.py b/cocoa/tests_backend/widgets/tree.py index 7bb972c939..cc6fedfee6 100644 --- a/cocoa/tests_backend/widgets/tree.py +++ b/cocoa/tests_backend/widgets/tree.py @@ -184,3 +184,6 @@ async def activate_row(self, row_path): delay=0.1, clickCount=2, ) + + async def assert_item_mouse_hover(self, row_path): + skip("Test not implemented for this platform") diff --git a/gtk/tests_backend/widgets/tree.py b/gtk/tests_backend/widgets/tree.py index ec9afcac4c..737f7fa5b4 100644 --- a/gtk/tests_backend/widgets/tree.py +++ b/gtk/tests_backend/widgets/tree.py @@ -104,3 +104,6 @@ async def activate_row(self, row_path): Gtk.TreePath(row_path), self.native_tree.get_columns()[0], ) + + async def assert_item_mouse_hover(self, row_path): + pytest.skip("Test not implemented for this platform") diff --git a/qt/tests_backend/widgets/tree.py b/qt/tests_backend/widgets/tree.py index 267fc1251a..94659b3ab8 100644 --- a/qt/tests_backend/widgets/tree.py +++ b/qt/tests_backend/widgets/tree.py @@ -127,4 +127,7 @@ async def activate_row(self, row_path): self.native.activated.emit(index) async def select_first_row_keyboard(self): - pytest.skip("test not implemented for this platform") + pytest.skip("Test not implemented for this platform") + + async def assert_item_mouse_hover(self, row_path): + pytest.skip("Test not implemented for this platform") diff --git a/testbed/tests/widgets/test_tree.py b/testbed/tests/widgets/test_tree.py index 50f17c722e..d0c8bee8d6 100644 --- a/testbed/tests/widgets/test_tree.py +++ b/testbed/tests/widgets/test_tree.py @@ -1052,11 +1052,9 @@ def test_deprecated_methods(widget, method_name, args, expected_args): async def test_mouse_events(widget, probe, on_activate_handler): - skip_on_platforms("android", "iOS", "linux", "macOS") """Does the widget implement mouse events correctly?""" - # These tests are WinForms specific - if toga.platform.current_platform != "windows": - return + # These tests are needed on the Windows platform and are implemented as + # pytest.skip() on the platforms macOS, GTK and QT. # Use some small data small_data = [