Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 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
354a74e
Minor changes and fixes to lib files
Oliver-Leigh Apr 23, 2026
baa4225
Updated manifest file
Oliver-Leigh Apr 23, 2026
eb81e7a
Minor fixes and changes to libs and probe
Oliver-Leigh Apr 23, 2026
556269d
Minor changes to probe
Oliver-Leigh Apr 23, 2026
0f772fc
Use a context manager for the activation context
Oliver-Leigh Apr 23, 2026
0bcc506
Restructure win32 lib files
Oliver-Leigh Apr 23, 2026
d4c45c6
Merge branch 'main' into winforms-detailed-list-revamp
freakboy3742 Apr 23, 2026
903322a
Fix coverage line that can't be hit in CI.
freakboy3742 Apr 23, 2026
332cd7c
Reduce number of rows in Tree widget test
Oliver-Leigh Apr 24, 2026
3e405bb
Minor change to get test coverage
Oliver-Leigh Apr 24, 2026
5433566
Font change support
Oliver-Leigh Apr 27, 2026
b889337
Merge branch 'main' into winforms-detailed-list-revamp
freakboy3742 Apr 28, 2026
ecf170f
Revert test data size change as an experiment.
freakboy3742 Apr 28, 2026
da2f1a6
Revert to size 10 list for cell tests.
freakboy3742 Apr 28, 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
3 changes: 3 additions & 0 deletions android/tests_backend/widgets/detailedlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ async def wait_for_scroll_completion(self):
async def select_row(self, row, add=False):
self._row_layout(row).performClick()

async def deselect_all(self):
self.impl._clear_selection()

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

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 primary and secondary actions via a context menu.
1 change: 1 addition & 0 deletions changes/2110.feature.2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The DetailedList widget on Windows now supports the refresh action via a context menu.
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.
5 changes: 5 additions & 0 deletions cocoa/tests_backend/widgets/detailedlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,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
8 changes: 3 additions & 5 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.
* On **Windows**, a right click displays a context menu with a refresh option.

## 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.
3 changes: 3 additions & 0 deletions gtk/tests_backend/widgets/detailedlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,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
19 changes: 17 additions & 2 deletions iOS/tests_backend/widgets/detailedlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,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
3 changes: 3 additions & 0 deletions qt/tests_backend/widgets/detailedlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,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
26 changes: 26 additions & 0 deletions testbed/tests/widgets/test_detailedlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,32 @@ 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")

# 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
4 changes: 2 additions & 2 deletions testbed/tests/widgets/test_tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -924,7 +924,7 @@ async def test_cell_icon(widget, probe):
},
None,
)
for i in range(50)
for i in range(10)
],
)
]
Expand Down Expand Up @@ -965,7 +965,7 @@ async def test_cell_widget(widget, probe):
},
None,
)
for i in range(50)
for i in range(10)
],
),
]
Expand Down
6 changes: 2 additions & 4 deletions winforms/src/toga_winforms/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,8 @@
import clr
import travertino

from .libs.user32 import (
DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,
SetProcessDpiAwarenessContext,
)
from .libs.user32 import SetProcessDpiAwarenessContext
from .libs.win32constants 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.win32constants import DPI_AWARENESS_CONTEXT_UNAWARE


class BaseDialog:
Expand Down
30 changes: 25 additions & 5 deletions winforms/src/toga_winforms/libs/comctl32.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,30 @@
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 .win32misc import activation_context
from .win32structures import (
DWORD_PTR,
HIMAGELIST,
INITCOMMONCONTROLSEX,
LRESULT,
SUBCLASSPROC,
UINT_PTR,
)

comctl32 = windll.comctl32
with activation_context:
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
Expand Down
116 changes: 0 additions & 116 deletions winforms/src/toga_winforms/libs/comctl32classes.py

This file was deleted.

25 changes: 25 additions & 0 deletions winforms/src/toga_winforms/libs/fonts.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
from ctypes.wintypes import HDC, HFONT

import System.Windows.Forms as WinForms
from System.Drawing import ContentAlignment

from toga.constants import CENTER, JUSTIFY, LEFT, RIGHT

from .gdi32 import DeleteObject, SelectObject


def TextAlignment(value):
return {
Expand All @@ -21,3 +25,24 @@ def HorizontalTextAlignment(value):
CENTER: WinForms.HorizontalAlignment.Center,
JUSTIFY: WinForms.HorizontalAlignment.Left,
}[value]


class FontDeviceContext:
"""A context manager for drawing with a given font in a given device context."""

def __init__(
self,
handle_device_context: HDC,
handle_font: HFONT,
):
self._hdc = handle_device_context
self._hfont = handle_font

def __enter__(self):
self._hfont_old = SelectObject(self._hdc, self._hfont)

def __exit__(self, exc_type, exc_value, traceback):
SelectObject(self._hdc, self._hfont_old)

def __del__(self):
DeleteObject(self._hfont)
22 changes: 22 additions & 0 deletions winforms/src/toga_winforms/libs/gdi32.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from ctypes import windll
from ctypes.wintypes import BOOL, COLORREF, HDC, HGDIOBJ

gdi32 = windll.GDI32


# https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-deleteobject
DeleteObject = gdi32.DeleteObject
DeleteObject.restype = BOOL
DeleteObject.argtypes = [HGDIOBJ]


# https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-selectobject
SelectObject = gdi32.SelectObject
SelectObject.restype = HGDIOBJ
SelectObject.argtypes = [HDC, HGDIOBJ]


# 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