Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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 @@
Allows the WinForms Table widget to display icons in all columns. Previously, icons were only supported in the first column. Changes to the tests have also been made to test the new structure.
Comment thread
Oliver-Leigh marked this conversation as resolved.
Outdated
183 changes: 168 additions & 15 deletions winforms/src/toga_winforms/widgets/table.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
from ctypes import (
POINTER,
WINFUNCTYPE,
Structure as c_Structure,
c_size_t,
cast,
windll,
)
from ctypes.wintypes import HWND, INT, LPARAM, LPWSTR, UINT, WPARAM
from warnings import warn

import System.Windows.Forms as WinForms
Expand All @@ -6,6 +15,89 @@

from .base import Widget

################################################
Comment thread
Oliver-Leigh marked this conversation as resolved.
Outdated
# C classes used with Windows shell functions.
################################################
LRESULT = LPARAM # LPARAM is essentially equivalent to LRESULT
UINT_PTR = c_size_t
DWORD_PTR = c_size_t


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", POINTER(UINT)),
("piColFmt", INT),
("iGroup", INT),
]


class NMHDR(c_Structure):
_fields_ = [
("hwndFrom", HWND),
("idFrom", UINT_PTR),
("code", UINT),
]


class NMLVDISPINFOW(c_Structure):
_fields_ = [
("hdr", NMHDR),
("item", LVITEMW),
]


SUBCLASSPROC = WINFUNCTYPE(
# Return type:
LRESULT,
# Argument types:
HWND,
UINT,
WPARAM,
LPARAM,
UINT_PTR,
DWORD_PTR,
)

################################################
# Windows shell functions.
################################################
PostMessageW = windll.user32.PostMessageW

DefSubclassProc = windll.comctl32.DefSubclassProc
SetWindowSubclass = windll.comctl32.SetWindowSubclass
RemoveWindowSubclass = windll.comctl32.RemoveWindowSubclass

################################################
# Windows message hex codes.
################################################
LVIF_TEXT = 0x0001
LVIF_IMAGE = 0x0002
LVIF_STATE = 0x0008

LVM_GETEXTENDEDLISTVIEWSTYLE = 0x1037
LVM_SETEXTENDEDLISTVIEWSTYLE = 0x1036

LVN_GETDISPINFOW = 0xFFFFFF4F

LVS_EX_SUBITEMIMAGES = 0x2

WM_NCDESTROY = 0x0082
WM_REFLECT_NOTIFY = 0x204E

################################################


class Table(Widget):
# The following methods are overridden in DetailedList.
Expand All @@ -25,9 +117,18 @@ def _multiple_select(self):
def _data(self):
return self.interface.data

def __del__(self):
Comment thread
Oliver-Leigh marked this conversation as resolved.
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.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 +168,57 @@ 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())

old_style = PostMessageW(list_view_handle, LVM_GETEXTENDEDLISTVIEWSTYLE, 0, 0)
new_style = old_style | LVS_EX_SUBITEMIMAGES

PostMessageW(list_view_handle, LVM_SETEXTENDEDLISTVIEWSTYLE, 0, new_style)

def handle_created(self, sender, e):
self._set_subclass()

def _set_subclass(self):
SetWindowSubclass(int(self.native.Handle.ToString()), self.pfn_subclass, 0, 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 == WM_NCDESTROY:
RemoveWindowSubclass(hWnd, self.pfn_subclass, uIdSubclass)

if uMsg == WM_REFLECT_NOTIFY:
phdr = cast(lParam, POINTER(NMHDR)).contents
code = phdr.code
if hex(code) == hex(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), LPARAM(wParam), LPARAM(lParam))
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.

As suggested on line 18, this may be worth moving to the libs module you create if manually doing this helper is warranted.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think it's better to leave this here. I think that window procedure callback functions are usually defined in the classes that they are used. Also, here the messages LVN_GETDISPINFOW are specific to a ListView object in virtual mode.


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 == (LVIF_TEXT | LVIF_STATE):
lvitem.uiMask = LVIF_TEXT | LVIF_STATE | LVIF_IMAGE

if lvitem.uiMask & 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 +232,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 +349,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
37 changes: 19 additions & 18 deletions winforms/tests_backend/widgets/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down