Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/4164.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The Table widget on WinForms can now display icons in all columns. Previously, icons were only supported in the first column.
3 changes: 1 addition & 2 deletions docs/en/reference/api/widgets/table.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
25 changes: 25 additions & 0 deletions winforms/src/toga_winforms/libs/comctl32.py
Original file line number Diff line number Diff line change
@@ -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]
59 changes: 59 additions & 0 deletions winforms/src/toga_winforms/libs/comctl32classes.py
Original file line number Diff line number Diff line change
@@ -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,
)
17 changes: 13 additions & 4 deletions winforms/src/toga_winforms/libs/user32.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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
Expand All @@ -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]
8 changes: 8 additions & 0 deletions winforms/src/toga_winforms/libs/win32.py
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions winforms/src/toga_winforms/libs/windowconstants.py
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions winforms/src/toga_winforms/widgets/multilinetextinput.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
115 changes: 100 additions & 15 deletions winforms/src/toga_winforms/widgets/table.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -25,9 +32,24 @@ def _multiple_select(self):
def _data(self):
return self.interface.data

def __del__(self):
Comment thread
Oliver-Leigh marked this conversation as resolved.
# 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
Expand Down Expand Up @@ -67,6 +89,68 @@ def create(self):
)
self.add_action_events()

def _enable_multi_icon_style(self):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function is only called once. Would it be too tedious to inline it into where this is called?

P.S. same applies for _set_subitem_icon

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)

Expand All @@ -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 (
Expand Down Expand Up @@ -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
Expand Down
Loading