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. 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/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/docs/en/reference/images/tree-winforms.png b/docs/en/reference/images/tree-winforms.png new file mode 100644 index 0000000000..7560742ffd Binary files /dev/null and b/docs/en/reference/images/tree-winforms.png differ 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 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 1aff557385..d0c8bee8d6 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"), ) @@ -228,6 +224,63 @@ 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]) + + # 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): """Nodes can be expanded and collapsed""" @@ -461,6 +514,13 @@ async def test_expand_collapse(widget, probe, source): 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( widget, @@ -913,13 +973,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") @@ -988,3 +1049,62 @@ 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): + """Does the widget implement mouse events correctly?""" + # 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 = [ + ( + {"a": "A0", "b": "", "c": ""}, + [({"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 + 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 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((1,), 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/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 diff --git a/winforms/src/toga_winforms/factory.py b/winforms/src/toga_winforms/factory.py index b2d0d4236c..09bad744d6 100644 --- a/winforms/src/toga_winforms/factory.py +++ b/winforms/src/toga_winforms/factory.py @@ -33,6 +33,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 @@ -85,6 +86,7 @@ def not_implemented(feature): # pragma: no cover "Table", "TextInput", "TimeInput", + "Tree", "WebView", # Windows "Window", diff --git a/winforms/src/toga_winforms/libs/comctl32classes.py b/winforms/src/toga_winforms/libs/comctl32classes.py index 95a279eaf9..3f357b6496 100644 --- a/winforms/src/toga_winforms/libs/comctl32classes.py +++ b/winforms/src/toga_winforms/libs/comctl32classes.py @@ -2,7 +2,19 @@ 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, + POINT, + RECT, + UINT, + WPARAM, +) from .win32 import DWORD_PTR, INT_PTR, LRESULT, PUINT, UINT_PTR @@ -37,6 +49,51 @@ 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-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_ = [ + ("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/user32.py b/winforms/src/toga_winforms/libs/user32.py index 693e163510..66d706c60f 100644 --- a/winforms/src/toga_winforms/libs/user32.py +++ b/winforms/src/toga_winforms/libs/user32.py @@ -1,5 +1,17 @@ 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 @@ -12,6 +24,27 @@ 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-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 +62,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/windowconstants.py b/winforms/src/toga_winforms/libs/windowconstants.py index d15a6ba50b..0f928e22f4 100644 --- a/winforms/src/toga_winforms/libs/windowconstants.py +++ b/winforms/src/toga_winforms/libs/windowconstants.py @@ -1,23 +1,48 @@ # Window constants +# Custom Draw Draw Stage +CDDS_PREPAINT = 0x00000001 +CDDS_ITEMPREPAINT = 0x00010000 | 0x00000001 +CDDS_SUBITEM = 0x00020000 + +# Custom Draw Response Flag +CDRF_NOTIFYITEMDRAW = 0x00000020 +CDRF_NOTIFYSUBITEMDRAW = CDRF_NOTIFYITEMDRAW +CDRF_SKIPPOSTPAINT = 0x00000100 + +# 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 +# List-View Item Flag LVIF_TEXT = 0x0001 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 +# Notification Message +NM_CUSTOMDRAW = 0xFFFFFFF4 + # Window Message WM_NCDESTROY = 0x0082 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 2617775bf8..fa35951677 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,118 @@ 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 = [] + + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' + def insert(self, index, item): + import warnings + + warnings.warn( + "The insert() method is deprecated. Use source_insert() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_insert(index=index, item=item) + + def source_insert(self, *, index, item): + self.update_data() + + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' + def change(self, item): + import warnings + + warnings.warn( + "The change() method is deprecated. Use source_change() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_change(item=item) + + def source_change(self, *, item): + self.update_data() + + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' + def remove(self, index, item): + import warnings + + warnings.warn( + "The remove() method is deprecated. Use source_remove() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_remove(index=index, item=item) + + def source_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 +239,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 +249,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 +324,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 +337,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 +371,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,56 +384,22 @@ def _image_index(self, icon): images.Add(key, icon.bitmap) return index - def update_data(self): - self.native.VirtualListSize = len(self._data) - self._cache = [] - - # Alias for backwards compatibility: - # March 2026: In 0.5.3 and earlier, notification methods - # didn't start with 'source_' - def insert(self, index, item): - import warnings + 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 = "" - warnings.warn( - "The insert() method is deprecated. Use source_insert() instead.", - DeprecationWarning, - stacklevel=1, + lvi = WinForms.ListViewItem( + [column.text(raw_item, missing_value) for column in self._columns], ) - self.source_insert(index=index, item=item) - - def source_insert(self, *, index, item): - self.update_data() - - # Alias for backwards compatibility: - # March 2026: In 0.5.3 and earlier, notification methods - # didn't start with 'source_' - def change(self, item): - import warnings - - warnings.warn( - "The change() method is deprecated. Use source_change() instead.", - DeprecationWarning, - stacklevel=1, + icon_indices = tuple( + self._icon_index(raw_item, column) for column in self._columns ) - self.source_change(item=item) - - def source_change(self, *, item): - self.update_data() - - # Alias for backwards compatibility: - # March 2026: In 0.5.3 and earlier, notification methods - # didn't start with 'source_' - def remove(self, index, item): - import warnings - warnings.warn( - "The remove() method is deprecated. Use source_remove() instead.", - DeprecationWarning, - stacklevel=1, - ) - self.source_remove(index=index, item=item) + return (lvi, icon_indices) - def source_remove(self, *, index, item): + def change_source(self, source): self.update_data() # Alias for backwards compatibility: @@ -379,15 +418,6 @@ def clear(self): def source_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..bb48f7d4a3 --- /dev/null +++ b/winforms/src/toga_winforms/widgets/tree.py @@ -0,0 +1,956 @@ +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 + +from toga.handlers import WeakrefCallable +from toga.sources.tree_source import Node, TreeSourceT + +from ..libs import windowconstants as wc +from ..libs.comctl32 import DefSubclassProc, RemoveWindowSubclass +from ..libs.comctl32classes import NMHDR, NMLISTVIEW, NMLVCUSTOMDRAW, NMLVDISPINFOW +from ..libs.user32 import DrawTextW +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. + 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): + """Initializes an instance for a given node and its relation to a StateTree. + + :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 + 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.arrow_center_x: float = 0.0 + self.mouse_hover: bool + + def __len__(self) -> int: + if self.children is None: + 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 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. + """ + if row_path is None or 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 []) + + 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 + + def toggle_state(self, update_display: bool) -> bool: + """Toggles the state (open/closed) of the StateNode. + + :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: + self._is_open = not self._is_open + if update_display: + return self.tree._display_list_toggle(self) + + return False + + 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 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: + 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. + + :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 + 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 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 = self.tree._display_list_adjust(False, child) + + 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 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] = [] + self._focused_index: int | None = None + + @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 + + def toggle_state(self, update_display: bool = False) -> bool: + """A StateTree is always open (expanded).""" + return False + + ################################################################################# + # 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 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(update_display=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, state_node: StateNode) -> bool: + """Adjusts the display list based on an item insertion or removal. + + :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. + """ + # 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 state_node is not in the display list there's no need to modify the + # display list. + if index == -1: + return False + + # 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 + + 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 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: + 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_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. + + :param state_node: The StateNode being toggled. + :return: A bool indicating whether a change of selection has occurred. + """ + 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) + + ################################################################################# + # Selected indices list and focused item 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 + + @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, + 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 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. + """ + 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(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 + + ################################################################################# + # 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: 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 + + # 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( + 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 + + +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: 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: int = -1 + + # 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. These values are the + # expected values for 200% scaling. + 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) + 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.""" + if uMsg == wc.WM_NCDESTROY: + # 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: + 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 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)) + + 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: + 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 + + 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.""" + 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 = [] + + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' + def insert(self, index, item, parent=None): + import warnings + + warnings.warn( + "The insert() method is deprecated. Use source_insert() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_insert(index=index, item=item, parent=parent) + + def source_insert(self, *, index, item, parent=None): + state_parent = self._get_state_parent(parent) + + refresh_needed = state_parent.insert(index, item) + self._update_list(refresh=refresh_needed) + + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' + def change(self, item): + import warnings + + warnings.warn( + "The change() method is deprecated. Use source_change() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_change(item=item) + + def source_change(self, *, item): + self._update_list(refresh=True) + + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' + def remove(self, index, item, parent=None): + import warnings + + warnings.warn( + "The remove() method is deprecated. Use source_remove() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_remove(index=index, item=item, parent=parent) + + def source_remove(self, *, index, item, parent=None): + state_parent = self._get_state_parent(parent) + + notify_select = state_parent.remove(index) + self._update_list(notify_select) + + def get_selection(self): + if self._multiple_select: + return [self.display_list[i].node for i in self.selected_indices] + elif len(self.selected_indices) == 0: + return None + else: + return self.display_list[self.selected_indices[0]].node + + ################################################################################# + # 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 + + @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. + + :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 + 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 = 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 * 2 < float(self._arrow_width) ** 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.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.""" + 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 _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._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)) + + 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) + 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. + + 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 _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"]: + DrawTextW(hdc, c_wchar_p(arrow), -1, byref(rect), text_format) + lengths.append(rect.right - rect.left) + + self._arrow_width = max(lengths) + quotient, remaider = divmod(self._arrow_width, self._indent) + + 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: + self._arrow_indent = quotient + 1 + self._update_list(refresh=True) + + def _draw_state_change_arrow(self, hdc, rect, index: int): + state_node: StateNode = self.display_list[index] + + # 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" + + rect.right = rect.left + rect.left = rect.right - self._indent * self._arrow_indent + + state_node.arrow_center_x = (rect.right + rect.left) / 2 + + 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) + + 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. + # + # 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 + 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) + + 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 + draw_stage = nmlvcd.nmcd.dwDrawStage + + if draw_stage == wc.CDDS_PREPAINT: + return wc.CDRF_NOTIFYITEMDRAW + + elif draw_stage == wc.CDDS_ITEMPREPAINT: + index = nmlvcd.nmcd.dwItemSpec + rect = nmlvcd.nmcd.rc + + # Account for known bugs in the custom draw process. + if index < 0 or index >= len(self.display_list) or rect.top == rect.bottom: + return + + # Check/update the current measurements. + self._check_measurments(nmlvcd.nmcd.hdc, RECT.from_buffer_copy(rect)) + + # 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 + + # 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 + # 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. + + This method is called when toggling a StateNode, when adding/removing nodes, + and when modifying an item. + + :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 = [] + # 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() + + def _get_state_parent(self, parent: Node | None = None): + """Gets the StateNode/StateTree associated to parent. + + :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. + """ + if parent is None: + return self._state_tree + else: + state_parent = self._state_tree.find_state_node(parent) + 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}" + ) + + return state_parent + + def set_branch_state(self, set_open: bool, branch: Node | None): + if branch is None: + state_node = self._state_tree + else: + state_node = self._state_tree.find_state_node(branch) + + if state_node is None or state_node.is_leaf: + return + + 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_node(self, node): + self.set_branch_state(set_open=True, branch=node) + + def expand_all(self): + self.set_branch_state(set_open=True, branch=None) + + def collapse_node(self, node): + self.set_branch_state(set_open=False, branch=node) + + def collapse_all(self): + self.set_branch_state(set_open=False, branch=None) 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 diff --git a/winforms/tests_backend/widgets/tree.py b/winforms/tests_backend/widgets/tree.py new file mode 100644 index 0000000000..a5c3445adb --- /dev/null +++ b/winforms/tests_backend/widgets/tree.py @@ -0,0 +1,261 @@ +import asyncio + +import pytest +from System.Windows.Forms import ( + 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] + + 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) + + 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) + self.native.Items[display_index].Focused = True + + 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.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): + 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) + + super().assert_cell_content(display_index, col, value, icon, widget) + + 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_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) + + #################################################################################### + # 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 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) + 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 + 5 * (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