Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d8d83cf
Windows DetailedList widget revamp
Oliver-Leigh Apr 9, 2026
4dfc544
Move some constants from user32 to windowconstants
Oliver-Leigh Apr 9, 2026
c78a935
Removed DetailedList references from Table
Oliver-Leigh Apr 9, 2026
9449409
Add probe methods for existing tests
Oliver-Leigh Apr 9, 2026
6288581
Added deselect test for code coverage
Oliver-Leigh Apr 9, 2026
5488dea
Updated the documentation
Oliver-Leigh Apr 10, 2026
1c8dd38
Added change notes
Oliver-Leigh Apr 10, 2026
cb8516d
Fixed TextInput test placeholder type
Oliver-Leigh Apr 10, 2026
00faba2
Updated iOS test probe
Oliver-Leigh Apr 10, 2026
2eb4beb
Updated iOS test probe, II
Oliver-Leigh Apr 10, 2026
a0cc900
Updated iOS test probe, III
Oliver-Leigh Apr 10, 2026
ab9374d
Changed/simplified WinForms test probe "click"
Oliver-Leigh Apr 10, 2026
c363887
Bug fix - Context menu over no item
Oliver-Leigh Apr 10, 2026
4ad5af3
Changes to WinForms test probe "click"
Oliver-Leigh Apr 10, 2026
de0df59
Add SetForegroundWindow to WinForms probe "click"
Oliver-Leigh Apr 10, 2026
7eb8d19
Add deselect test to Android probe
Oliver-Leigh Apr 13, 2026
29915c9
Added refresh to the context menu
Oliver-Leigh Apr 13, 2026
a02059f
Added WinForms probe refresh functionality
Oliver-Leigh Apr 14, 2026
6ae2d51
More robust click handling
Oliver-Leigh Apr 15, 2026
7514d3b
Updated WinForms probe
Oliver-Leigh Apr 15, 2026
1b7b203
Add common control v6 using activation context
Oliver-Leigh Apr 20, 2026
b94ed30
Minor changes to docs, probe and comments
Oliver-Leigh Apr 21, 2026
50add47
Minor changes to change log, probe and comments
Oliver-Leigh Apr 21, 2026
245ba48
Removed extra dpi activation from manifest
Oliver-Leigh Apr 22, 2026
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
2 changes: 2 additions & 0 deletions android/tests_backend/widgets/detailedlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ class DetailedListProbe(SimpleProbe):
supports_actions = True
supports_refresh = True

supports_deselect = False

def __init__(self, widget):
super().__init__(widget)
self.refresh_layout = self.native
Expand Down
1 change: 1 addition & 0 deletions changes/2110.feature.1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The DetailedList widget on Windows now supports actions.
1 change: 1 addition & 0 deletions changes/4319.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The style of the DetailedList widget on Windows now matches the other platforms.
7 changes: 7 additions & 0 deletions cocoa/tests_backend/widgets/detailedlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ class DetailedListProbe(SimpleProbe):
supports_actions = True
supports_refresh = True

supports_deselect = True

def __init__(self, widget):
super().__init__(widget)
self.native_detailedlist = widget._impl.native_detailedlist
Expand Down Expand Up @@ -118,6 +120,11 @@ async def select_row(self, row, add=False):
modifierFlags=NSEventModifierFlagCommand if add else 0,
)

async def deselect_all(self):
# Assume there is blank space at the bottom of the client area.
row = self.row_count
await self.select_row(row)

async def _refresh_action(self, offset):
# Create a scroll event where event phase = Began, Momenum scroll phase = Begin,
# and the pixel value is equal to the requested offset.
Expand Down
6 changes: 2 additions & 4 deletions docs/en/reference/api/widgets/detailedlist.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,23 +64,21 @@ Items in a DetailedList will respond to a primary and secondary action if the `o
* On **Android**, a long press displays a menu with the primary and secondary actions.
* On **iOS**, swiping left triggers the primary action, and swiping right triggers the secondary action.
* On **GTK**, a right click displays buttons for the primary and secondary actions.
* On **macOS**, a right click displays a context menu with the primary and secondary actions.
* On **macOS** and **Windows**, a right click displays a context menu with the primary and secondary actions.
* On **Qt**, the primary and secondary actions are displayed as standalone buttons.
* On **Windows**, the implementation does not currently support primary or secondary actions.

By default, the primary and secondary action will be labeled as "Delete" and "Action", respectively. These names can be overridden by providing a `primary_action` and `secondary_action` argument when constructing the DetailedList. Although the primary action is labeled "Delete" by default, the DetailedList will not perform any data deletion as part of the UI interaction. It is the responsibility of the application to implement any data deletion behavior as part of the `on_primary_action` handler.

The DetailedList as a whole will also respond to a refresh UI action if an `on_refresh` handler is set:

* On **macOS**, **iOS** and **Android**, pulling down at the top of the list triggers a refresh.
* On **Android**, **iOS** and **macOS**, pulling down at the top of the list triggers a refresh.
* On **Qt**, a button bar displays a refresh button.
* On **GTK**, a floating refresh button is displayed when scrolled to the top.
* On **Windows**, the implementation does not currently support refresh.

## Notes

* The iOS Human Interface Guidelines differentiate between "Normal" and "Destructive" actions on a row. Toga will interpret any action with a name of "Delete" or "Remove" as destructive, and will render the action appropriately.
* The WinForms implementation currently uses a column layout similar to [`Table`][toga.Table], and does not support the primary, secondary or refresh actions.
* Using DetailedList on Android requires the AndroidX SwipeRefreshLayout widget in your project's Gradle dependencies. Ensure your app declares a dependency on `androidx.swiperefreshlayout:swiperefreshlayout:1.1.0` or later.

## Reference
Expand Down
Binary file modified docs/en/reference/images/detailedlist-winforms.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions gtk/tests_backend/widgets/detailedlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ class DetailedListProbe(SimpleProbe):
supports_actions = True
supports_refresh = True

supports_deselect = True

def __init__(self, widget):
super().__init__(widget)
self.native_detailedlist = widget._impl.native_detailedlist
Expand Down Expand Up @@ -54,6 +56,9 @@ async def wait_for_scroll_completion(self):
async def select_row(self, row, add=False):
self.native_detailedlist.select_row(self.impl.store[row])

async def deselect_all(self):
self.native_detailedlist.unselect_all()

def refresh_available(self):
return self.impl.native_revealer.get_child_revealed()

Expand Down
21 changes: 19 additions & 2 deletions iOS/tests_backend/widgets/detailedlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ class DetailedListProbe(SimpleProbe):
supports_actions = True
supports_refresh = True

supports_deselect = True

def __init__(self, widget):
super().__init__(widget)
self.native_controller = widget._impl.native_controller
Expand Down Expand Up @@ -79,13 +81,28 @@ async def wait_for_scroll_completion(self):
await asyncio.sleep(0.05)
current = self.scroll_position

async def select_row(self, row, add=False):
async def change_selection(self, row, add=False, deselect=False):
path = NSIndexPath.indexPathForRow(row, inSection=0)
self.native.selectRowAtIndexPath(path, animated=False, scrollPosition=0)
if deselect:
self.native.deselectRowAtIndexPath(path, animated=False)
else:
self.native.selectRowAtIndexPath(path, animated=False, scrollPosition=0)

# Need to use the long form of this method because the first argument when used
# as a selector is ambiguous with a property of the same name on the object.
self.native.delegate.tableView_didSelectRowAtIndexPath_(self.native, path)

async def select_row(self, row, add=False):
await self.change_selection(row, add)

async def deselect_all(self):
row = self.widget.selection
if row is None:
return

row_index = self.widget.data.index(row)
await self.change_selection(row_index, deselect=True)

def refresh_available(self):
return self.scroll_position <= 0

Expand Down
5 changes: 5 additions & 0 deletions qt/tests_backend/widgets/detailedlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ class DetailedListProbe(SimpleProbe):
supports_actions = True
supports_refresh = True

supports_deselect = True

def _row_to_index(self, row):
model = self.native.model()
return model.index(row)
Expand Down Expand Up @@ -61,6 +63,9 @@ async def select_row(self, row, add=False):
index = self._row_to_index(row)
self.native.selectionModel().select(index, self.native.selectionCommand(index))

async def deselect_all(self):
self.native.clearSelection()

def refresh_available(self):
# need scroll position 0 to be comptaible with tests for pull-down scroll
return (
Expand Down
29 changes: 29 additions & 0 deletions testbed/tests/widgets/test_detailedlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,35 @@ async def test_select(widget, probe, source, on_select_handler):
on_select_handler.reset_mock()


async def test_deselect(widget, probe):
"""Test for deselection"""
red = toga.Icon("resources/icons/red")
green = toga.Icon("resources/icons/green")

# Change the data source for something smaller
widget.data = [
{
"a": MyData(i),
"b": i,
"c": {0: green, 1: red}[i % 2],
}
for i in range(5)
]
await probe.redraw("Data source has been changed")

if not probe.supports_deselect:
pytest.skip("The probe for this backend doesn't support deselection.")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This works; however, when there's a clear method we can call that wraps the "unsupported" behavior, we usually wrap the skip logic into that method. In this case, adding a deselect_all() method to all backends and raising the skip/xfail in that method means one less probe attribute; plus we can differentiate between "is not implemented on this platform yet" and "cannot be implemented on this platform"

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.

This is fixed now. I went through the Android code again, and the solution blindingly obvious.


# Select a single row
await probe.select_row(2)
await probe.redraw("Third row is selected")
assert widget.selection == widget.data[2]
# Deselect all
await probe.deselect_all()
await probe.redraw("Row is deselected")
assert widget.selection is None


class MyData:
def __init__(self, text):
self.text = text
Expand Down
6 changes: 2 additions & 4 deletions winforms/src/toga_winforms/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import clr
import travertino

from .libs.user32 import (
DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,
SetProcessDpiAwarenessContext,
)
from .libs.user32 import SetProcessDpiAwarenessContext
from .libs.windowconstants import DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2

# Add a reference to the Winforms assembly
clr.AddReference("System.Windows.Forms")
Expand Down
3 changes: 2 additions & 1 deletion winforms/src/toga_winforms/dialogs.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@

from toga.handlers import WeakrefCallable

from .libs.user32 import DPI_AWARENESS_CONTEXT_UNAWARE, SetThreadDpiAwarenessContext
from .libs.user32 import SetThreadDpiAwarenessContext
from .libs.windowconstants import DPI_AWARENESS_CONTEXT_UNAWARE


class BaseDialog:
Expand Down
20 changes: 16 additions & 4 deletions winforms/src/toga_winforms/libs/comctl32.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
from ctypes import windll
from ctypes.wintypes import BOOL, HWND, LPARAM, UINT, WPARAM
from ctypes import POINTER, windll
from ctypes.wintypes import BOOL, HDC, HWND, INT, LPARAM, UINT, WPARAM

from .comctl32classes import SUBCLASSPROC
from .win32 import DWORD_PTR, LRESULT, UINT_PTR
from .comctl32classes import INITCOMMONCONTROLSEX, SUBCLASSPROC
from .win32 import DWORD_PTR, HIMAGELIST, LRESULT, UINT_PTR

comctl32 = windll.comctl32


# https://learn.microsoft.com/en-us/windows/win32/api/commctrl/nf-commctrl-initcommoncontrolsex
InitCommonControlsEx = comctl32.InitCommonControlsEx
InitCommonControlsEx.restype = BOOL
InitCommonControlsEx.argtypes = [POINTER(INITCOMMONCONTROLSEX)]


# https://learn.microsoft.com/en-us/windows/win32/api/commctrl/nf-commctrl-imagelist_draw
ImageList_Draw = comctl32.ImageList_Draw
ImageList_Draw.restype = BOOL
ImageList_Draw.argtypes = [HIMAGELIST, INT, HDC, INT, INT, UINT]


# https://learn.microsoft.com/en-us/windows/win32/api/commctrl/nf-commctrl-defsubclassproc
DefSubclassProc = comctl32.DefSubclassProc
DefSubclassProc.restype = LRESULT
Expand Down
79 changes: 74 additions & 5 deletions winforms/src/toga_winforms/libs/comctl32classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,22 @@
LPWSTR,
POINT,
RECT,
SIZE,
UINT,
WPARAM,
)

from .win32 import DWORD_PTR, INT_PTR, LRESULT, PUINT, UINT_PTR


# learn.microsoft.com/en-us/windows/win32/api/commctrl/ns-commctrl-initcommoncontrolsex
class INITCOMMONCONTROLSEX(c_Structure):
_fields_ = [
("dwSize", DWORD),
("dwICC", DWORD),
]


# https://learn.microsoft.com/en-us/windows/win32/api/commctrl/ns-commctrl-lvitemw
class LVITEMW(c_Structure):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Just noting that the first field here is uiMask which is (a) inconsistent with the documented structure name, and (b) inconsistent with other classes in this file.

_fields_ = [
Expand All @@ -40,15 +49,51 @@ class LVITEMW(c_Structure):
]


# https://learn.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-nmhdr
class NMHDR(c_Structure):
# learn.microsoft.com/en-us/windows/win32/api/commctrl/ns-commctrl-lvcolumnw
class LVCOLUMNW(c_Structure):
_fields_ = [
("mask", UINT),
("fmt", INT),
("cx", INT),
("pszText", LPWSTR),
("cchTextMax", INT),
("iSubItem", INT),
("cchTextMax", INT),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The duplicated entry here seems odd... not sure why it isn't causing problems...

Suggested change
("cchTextMax", INT),

("iImage", INT),
("iOrder", INT),
("cxMin", INT),
("cxDefault", INT),
("cxIdeal", INT),
]


# learn.microsoft.com/en-us/windows/win32/api/commctrl/ns-commctrl-lvhittestinfo
class LVHITTESTINFO(c_Structure):
_fields_ = [
("pt", POINT),
("flags", UINT),
("iItem", INT),
("iSubItem", INT),
("iGroup", INT),
]


# learn.microsoft.com/en-us/windows/win32/api/commctrl/ns-commctrl-lvtileviewinfo
class LVTILEVIEWINFO(c_Structure):
_fields_ = [
("hwndFrom", HWND),
("idFrom", UINT_PTR),
("code", UINT),
("cbSize", UINT),
("dwMask", DWORD),
("dwFlags", DWORD),
("sizeTile", SIZE),
("cLines", INT),
("rcLabelMargin", RECT),
]


# Import .user32classes here to avoid circular reference.
from .user32classes import NMHDR # noqa
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'd rather do some re-org of the imports to avoid circular references, rather than rely on specific import order side effects. Could we pull all the structures into a structures.py (or maybe even win32.py?), and then have clear comment blocks for "comctl32 structures", "user32 structures" etc?



# https://learn.microsoft.com/en-us/windows/win32/api/commctrl/ns-commctrl-nmcustomdraw
class NMCUSTOMDRAW(c_Structure):
_fields_ = [
Expand All @@ -62,6 +107,21 @@ class NMCUSTOMDRAW(c_Structure):
]


# learn.microsoft.com/en-us/windows/win32/api/commctrl/ns-commctrl-nmitemactivate
class NMITEMACTIVATE(c_Structure):
_fields_ = [
("hdr", NMHDR),
("iItem", INT),
("iSubItem", INT),
("uNewState", UINT),
("uOldState", UINT),
("uChanged", UINT),
("ptAction", POINT),
("lParam", LPARAM),
("uKeyFlags", UINT),
]


# https://learn.microsoft.com/en-us/windows/win32/api/commctrl/ns-commctrl-nmlistview
class NMLISTVIEW(c_Structure):
_fields_ = [
Expand All @@ -76,6 +136,15 @@ class NMLISTVIEW(c_Structure):
]


# https://learn.microsoft.com/en-us/windows/win32/api/commctrl/ns-commctrl-nmlvcachehint
class NMLVCACHEHINT(c_Structure):
_fields_ = [
("hdr", NMHDR),
("iFrom", INT),
("iTo", INT),
]


# https://learn.microsoft.com/en-us/windows/win32/api/commctrl/ns-commctrl-nmlvcustomdraw
class NMLVCUSTOMDRAW(c_Structure):
_fields_ = [
Expand Down
10 changes: 10 additions & 0 deletions winforms/src/toga_winforms/libs/gdi32.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from ctypes import windll
from ctypes.wintypes import COLORREF, HDC

gdi32 = windll.GDI32


# https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-settextcolor
SetTextColor = gdi32.SetTextColor
SetTextColor.restype = COLORREF
SetTextColor.argtypes = [HDC, COLORREF]
Loading
Loading