diff --git a/changes/4164.feature.md b/changes/4164.feature.md new file mode 100644 index 0000000000..61b9280468 --- /dev/null +++ b/changes/4164.feature.md @@ -0,0 +1 @@ +The Table widget on WinForms can now display icons in all columns. Previously, icons were only supported in the first column. diff --git a/docs/en/reference/api/widgets/table.md b/docs/en/reference/api/widgets/table.md index 618920e03b..38c9027c10 100644 --- a/docs/en/reference/api/widgets/table.md +++ b/docs/en/reference/api/widgets/table.md @@ -74,8 +74,7 @@ The set of known accessors and their order for creating rows from lists and tupl - Widgets in cells is a beta API which may change in future, and is currently only supported on macOS. - macOS does not support changing the font used to render table content. -- On Winforms, icons are only supported in the first column. On Android, icons are not supported at all. -- The Android implementation is [not scalable](https://github.com/beeware/toga/issues/1392) beyond about 1,000 cells. +- Icons in tables are not supported on Android, and the implementation is [not scalable](https://github.com/beeware/toga/issues/1392) beyond about 1,000 cells. ## Reference diff --git a/winforms/src/toga_winforms/libs/comctl32.py b/winforms/src/toga_winforms/libs/comctl32.py new file mode 100644 index 0000000000..d41db24e1e --- /dev/null +++ b/winforms/src/toga_winforms/libs/comctl32.py @@ -0,0 +1,25 @@ +from ctypes import windll +from ctypes.wintypes import BOOL, HWND, LPARAM, UINT, WPARAM + +from .comctl32classes import SUBCLASSPROC +from .win32 import DWORD_PTR, LRESULT, UINT_PTR + +comctl32 = windll.comctl32 + + +# https://learn.microsoft.com/en-us/windows/win32/api/commctrl/nf-commctrl-defsubclassproc +DefSubclassProc = comctl32.DefSubclassProc +DefSubclassProc.restype = LRESULT +DefSubclassProc.argtypes = [HWND, UINT, WPARAM, LPARAM] + + +# https://learn.microsoft.com/en-us/windows/win32/api/commctrl/nf-commctrl-setwindowsubclass +RemoveWindowSubclass = comctl32.RemoveWindowSubclass +RemoveWindowSubclass.restype = BOOL +RemoveWindowSubclass.argtypes = [HWND, SUBCLASSPROC, UINT_PTR] + + +# https://learn.microsoft.com/en-us/windows/win32/api/commctrl/nf-commctrl-setwindowsubclass +SetWindowSubclass = comctl32.SetWindowSubclass +SetWindowSubclass.restype = BOOL +SetWindowSubclass.argtypes = [HWND, SUBCLASSPROC, UINT_PTR, DWORD_PTR] diff --git a/winforms/src/toga_winforms/libs/comctl32classes.py b/winforms/src/toga_winforms/libs/comctl32classes.py new file mode 100644 index 0000000000..95a279eaf9 --- /dev/null +++ b/winforms/src/toga_winforms/libs/comctl32classes.py @@ -0,0 +1,59 @@ +from ctypes import ( + WINFUNCTYPE, + Structure as c_Structure, +) +from ctypes.wintypes import HWND, INT, LPARAM, LPWSTR, UINT, WPARAM + +from .win32 import DWORD_PTR, INT_PTR, LRESULT, PUINT, UINT_PTR + + +# https://learn.microsoft.com/en-us/windows/win32/api/commctrl/ns-commctrl-lvitemw +class LVITEMW(c_Structure): + _fields_ = [ + ("uiMask", UINT), + ("iItem", INT), + ("iSubItem", INT), + ("state", UINT), + ("stateMask", UINT), + ("pszText", LPWSTR), + ("cchTextMax", INT), + ("iImage", INT), + ("lParam", LPARAM), + ("iIndent", INT), + ("iGroupId", INT), + ("cColumns", UINT), + ("puColumns", PUINT), + ("piColFmt", INT_PTR), + ("iGroup", INT), + ] + + +# https://learn.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-nmhdr +class NMHDR(c_Structure): + _fields_ = [ + ("hwndFrom", HWND), + ("idFrom", UINT_PTR), + ("code", UINT), + ] + + +# https://learn.microsoft.com/en-us/windows/win32/api/commctrl/ns-commctrl-nmlvdispinfow +class NMLVDISPINFOW(c_Structure): + _fields_ = [ + ("hdr", NMHDR), + ("item", LVITEMW), + ] + + +# https://learn.microsoft.com/en-us/windows/win32/api/commctrl/nc-commctrl-subclassproc +SUBCLASSPROC = WINFUNCTYPE( + # Return type: + LRESULT, + # Argument types: + HWND, + UINT, + WPARAM, + LPARAM, + UINT_PTR, + DWORD_PTR, +) diff --git a/winforms/src/toga_winforms/libs/user32.py b/winforms/src/toga_winforms/libs/user32.py index 32d2698df2..693e163510 100644 --- a/winforms/src/toga_winforms/libs/user32.py +++ b/winforms/src/toga_winforms/libs/user32.py @@ -1,7 +1,10 @@ -from ctypes import c_void_p, windll, wintypes +from ctypes import c_void_p, windll +from ctypes.wintypes import BOOL, DWORD, HMONITOR, HWND, LPARAM, LPRECT, UINT, WPARAM from System import Environment +from .win32 import LRESULT + user32 = windll.user32 @@ -13,7 +16,7 @@ win_version = Environment.OSVersion.Version if (win_version.Major, win_version.Minor, win_version.Build) >= (10, 0, 15063): SetProcessDpiAwarenessContext = user32.SetProcessDpiAwarenessContext - SetProcessDpiAwarenessContext.restype = wintypes.BOOL + SetProcessDpiAwarenessContext.restype = BOOL SetProcessDpiAwarenessContext.argtypes = [c_void_p] SetThreadDpiAwarenessContext = user32.SetThreadDpiAwarenessContext @@ -32,5 +35,11 @@ MONITOR_DEFAULTTONEAREST = 2 MonitorFromRect = user32.MonitorFromRect -MonitorFromRect.restype = wintypes.HMONITOR -MonitorFromRect.argtypes = [wintypes.LPRECT, wintypes.DWORD] +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 new file mode 100644 index 0000000000..59ab01c7ac --- /dev/null +++ b/winforms/src/toga_winforms/libs/win32.py @@ -0,0 +1,8 @@ +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 diff --git a/winforms/src/toga_winforms/libs/windowconstants.py b/winforms/src/toga_winforms/libs/windowconstants.py new file mode 100644 index 0000000000..d15a6ba50b --- /dev/null +++ b/winforms/src/toga_winforms/libs/windowconstants.py @@ -0,0 +1,23 @@ +# Window constants + +# Edit Messages +EM_SETCUEBANNER = 0x1501 + +# List-View Item Format +LVIF_TEXT = 0x0001 +LVIF_IMAGE = 0x0002 +LVIF_STATE = 0x0008 + +# List-View Management +LVM_GETEXTENDEDLISTVIEWSTYLE = 0x1037 +LVM_SETEXTENDEDLISTVIEWSTYLE = 0x1036 + +# List-View Notification +LVN_GETDISPINFOW = 0xFFFFFF4F + +# List-View Styles (Extended) +LVS_EX_SUBITEMIMAGES = 0x2 + +# Window Message +WM_NCDESTROY = 0x0082 +WM_REFLECT_NOTIFY = 0x204E diff --git a/winforms/src/toga_winforms/widgets/multilinetextinput.py b/winforms/src/toga_winforms/widgets/multilinetextinput.py index 7ac7fa8dd2..06e4b39a6d 100644 --- a/winforms/src/toga_winforms/widgets/multilinetextinput.py +++ b/winforms/src/toga_winforms/widgets/multilinetextinput.py @@ -44,6 +44,9 @@ def winforms_lost_focus(self, sender, event): if self.native.Text == "": self._set_placeholder_visible(True) + def get_placeholder(self): + return self._placeholder + def set_placeholder(self, value): self._placeholder = value if self._placeholder_visible: diff --git a/winforms/src/toga_winforms/widgets/table.py b/winforms/src/toga_winforms/widgets/table.py index 468e612ac8..fbcbaed8f3 100644 --- a/winforms/src/toga_winforms/widgets/table.py +++ b/winforms/src/toga_winforms/widgets/table.py @@ -1,9 +1,16 @@ +from ctypes import POINTER, cast +from ctypes.wintypes import HWND, LPARAM, UINT, WPARAM from warnings import warn import System.Windows.Forms as WinForms from toga.handlers import WeakrefCallable +from ..libs import windowconstants as wc +from ..libs.comctl32 import DefSubclassProc, RemoveWindowSubclass, SetWindowSubclass +from ..libs.comctl32classes import LVITEMW, NMHDR, NMLVDISPINFOW, SUBCLASSPROC +from ..libs.user32 import SendMessageW +from ..libs.win32 import LRESULT from .base import Widget @@ -25,9 +32,24 @@ 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 create(self): + self.pfn_subclass = SUBCLASSPROC(self._subclass_proc) self.native = WinForms.ListView() + self._set_subclass() + + self.native.HandleCreated += WeakrefCallable(self.handle_created) + self.native.HandleDestroyed += WeakrefCallable(self.handle_destroyed) + self.native.View = WinForms.View.Details + self._enable_multi_icon_style() self._cache = [] self._first_item = 0 self._pending_resize = True @@ -67,6 +89,68 @@ def create(self): ) 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 + ) + 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) + + def _remove_subclass(self): + RemoveWindowSubclass(int(self.native.Handle.ToString()), self.pfn_subclass, 0) + + def _subclass_proc( + self, + hWnd: int, + uMsg: int, + wParam: int, + lParam: int, + uIdSubclass: int, + dwRefData: int, + ) -> LRESULT: + # Remove the window subclass in the way recommended by Raymond Chen here: + # https://devblogs.microsoft.com/oldnewthing/20031111-00/?p=41883 + if uMsg == wc.WM_NCDESTROY: + RemoveWindowSubclass(hWnd, self.pfn_subclass, uIdSubclass) + + if 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) + + # Call the original window procedure + return DefSubclassProc(HWND(hWnd), UINT(uMsg), WPARAM(wParam), LPARAM(lParam)) + + def _set_subitem_icon(self, lvitem: LVITEMW): + row_index = lvitem.iItem + column_index = lvitem.iSubItem + + _, icon_indices = self._toga_retrieve_virtual_item(row_index) + + # Add the icon property if it doesn't exist. + if lvitem.uiMask == (wc.LVIF_TEXT | wc.LVIF_STATE): + lvitem.uiMask = wc.LVIF_TEXT | wc.LVIF_STATE | wc.LVIF_IMAGE + + 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) @@ -80,14 +164,17 @@ 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 - and e.ItemIndex >= self._first_item - and e.ItemIndex < self._first_item + len(self._cache) + and item_index >= self._first_item + and item_index < self._first_item + len(self._cache) ): - e.Item = self._cache[e.ItemIndex - self._first_item] + return self._cache[item_index - self._first_item] else: - e.Item = self._new_item(e.ItemIndex) + return self._new_item(item_index) def winforms_cache_virtual_items(self, sender, e): if ( @@ -194,28 +281,26 @@ def _resize_columns(self): 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): - item = self._data[index] + row = self._data[index] missing_value = self.interface.missing_value lvi = WinForms.ListViewItem( - [column.text(item, missing_value) for column in self._columns], + [column.text(row, missing_value) for column in self._columns], ) - if any(column.widget(item) is not None 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, ) - # If the table has accessors, populate the icons for the table. - if self._columns: - # TODO: ListView only has built-in support for one icon per row. One - # possible workaround is in https://stackoverflow.com/a/46128593. - icon = self._columns[0].icon(item) - if icon is not None: - lvi.ImageIndex = self._image_index(icon._impl) + icon_indices = tuple(self._icon_index(row, column) for column in self._columns) - return lvi + return (lvi, icon_indices) def _image_index(self, icon): images = self.native.SmallImageList.Images diff --git a/winforms/src/toga_winforms/widgets/textinput.py b/winforms/src/toga_winforms/widgets/textinput.py index 67b88c7896..291b76d133 100644 --- a/winforms/src/toga_winforms/widgets/textinput.py +++ b/winforms/src/toga_winforms/widgets/textinput.py @@ -1,5 +1,5 @@ -from ctypes import c_uint, windll -from ctypes.wintypes import HWND, WPARAM +from ctypes import c_void_p, c_wchar_p, cast +from ctypes.wintypes import WPARAM from decimal import ROUND_UP import System.Windows.Forms as WinForms @@ -8,6 +8,8 @@ from toga_winforms.colors import native_color from toga_winforms.libs.fonts import HorizontalTextAlignment +from ..libs.user32 import SendMessageW +from ..libs.windowconstants import EM_SETCUEBANNER from .base import Widget @@ -20,7 +22,7 @@ def create(self): self.native.GotFocus += WeakrefCallable(self.winforms_got_focus) self.native.LostFocus += WeakrefCallable(self.winforms_lost_focus) - self._placeholder = "" + self._placeholder = c_wchar_p("") self.error_provider = WinForms.ErrorProvider() self.error_provider.SetIconAlignment( @@ -36,21 +38,21 @@ def set_readonly(self, value): self.native.ReadOnly = value def get_placeholder(self): - return self._placeholder + return self._placeholder.value - def set_placeholder(self, value): - self._placeholder = value + def set_placeholder(self, value: str): + self._placeholder = c_wchar_p(value) # This solution is based on https://stackoverflow.com/questions/4902565. - EM_SETCUEBANNER = c_uint(0x1501) # value 0 means placeholder is hidden as soon the input gets focus # value 1 means placeholder is hidden only after something is typed into input show_placeholder_on_focus = WPARAM(1) - window_handle = HWND(self.native.Handle.ToInt32()) - windll.user32.SendMessageW( + window_handle = int(self.native.Handle.ToString()) + placeholder_address = cast(self._placeholder, c_void_p).value + SendMessageW( window_handle, EM_SETCUEBANNER, show_placeholder_on_focus, - self._placeholder, + placeholder_address, ) def get_value(self): diff --git a/winforms/tests_backend/widgets/table.py b/winforms/tests_backend/widgets/table.py index 98d4246afc..5e05cdfcb7 100644 --- a/winforms/tests_backend/widgets/table.py +++ b/winforms/tests_backend/widgets/table.py @@ -30,25 +30,26 @@ def assert_cell_content(self, row, col, value=None, icon=None, widget=None): pytest.skip("This backend doesn't support widgets in Tables") else: lvi = self.native.Items[row] + + _, icon_indices = self.impl._toga_retrieve_virtual_item(row) + icon_index = icon_indices[col] + assert lvi.SubItems[col].Text == value - if col == 0: - if icon is None: - assert lvi.ImageIndex == -1 - assert lvi.ImageKey == "" - else: - imagelist = self.native.SmallImageList - size = imagelist.ImageSize - assert size.Width == size.Height == 16 - - # The image is resized and copied, so we need to compare the actual - # pixels. - actual = imagelist.Images[lvi.ImageIndex] - 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) - else: - assert icon is None + assert lvi.ImageIndex == -1 + assert lvi.ImageKey == "" + + if icon is not None: + imagelist = self.native.SmallImageList + size = imagelist.ImageSize + assert size.Width == size.Height == 16 + + # The image is resized and copied, so we need to compare the actual + # pixels. + 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) @property def max_scroll_position(self): diff --git a/winforms/tests_backend/widgets/textinput.py b/winforms/tests_backend/widgets/textinput.py index 770104eee6..81280a63bc 100644 --- a/winforms/tests_backend/widgets/textinput.py +++ b/winforms/tests_backend/widgets/textinput.py @@ -24,10 +24,11 @@ def value_hidden(self): @property def _placeholder(self): buffer = ctypes.create_unicode_buffer(1024) + buffer_address = ctypes.cast(buffer, ctypes.c_void_p).value result = ctypes.windll.user32.SendMessageW( HWND(self.native.Handle.ToInt32()), c_uint(0x1502), # EM_GETCUEBANNER - buffer, + buffer_address, LPARAM(ctypes.sizeof(buffer)), ) if not result: