Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Windows - implement modern File/Folder dialogs with comtypes #2786

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
1 change: 1 addition & 0 deletions changes/2786.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Introduced `IFileOpenDialog`, replacing `SHBrowseForFolder` for improved file selection dialogs.
1 change: 1 addition & 0 deletions testbed/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ test_sources = [
]
requires = [
"../winforms",
"comtypes",
Copy link
Member

Choose a reason for hiding this comment

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

While this fixes the immediate problem, it's not the right fix. You're proposing adding code to toga-winforms that uses comtypes; that means that comtypes is a dependency of toga-winforms, not the testbed. Testbed should be picking up comtypes transitively because of the dependency on toga-winforms.

Copy link
Author

Choose a reason for hiding this comment

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

Oops, you're absolutely correct.

]

# Mobile deployments
Expand Down
185 changes: 138 additions & 47 deletions winforms/src/toga_winforms/dialogs.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,23 @@
import asyncio
import os
from ctypes import (
HRESULT,
POINTER,
byref,
c_void_p,
c_wchar_p,
cast as cast_with_ctypes,
windll,
)
from ctypes.wintypes import HWND
from pathlib import Path
from typing import List, Optional, Tuple, Union

import comtypes
import comtypes.client
import System.Windows.Forms as WinForms
from comtypes import GUID
from comtypes.hresult import S_OK
from System.Drawing import (
ContentAlignment,
Font as WinFont,
Expand All @@ -11,6 +27,18 @@
)
from System.Windows.Forms import DialogResult, MessageBoxButtons, MessageBoxIcon

from toga_winforms.libs.com.constants import COMDLG_FILTERSPEC, FileOpenOptions
from toga_winforms.libs.com.identifiers import (
CLSID_FileOpenDialog,
CLSID_FileSaveDialog,
)
from toga_winforms.libs.com.interfaces import (
IFileOpenDialog,
IFileSaveDialog,
IShellItem,
IShellItemArray,
)

from .libs.wrapper import WeakrefCallable


Expand Down Expand Up @@ -190,108 +218,171 @@ def winforms_Click_accept(self, sender, event):
class FileDialog(BaseDialog):
def __init__(
self,
native,
title,
initial_directory,
native: Union[IFileOpenDialog, IFileSaveDialog],
title: str,
initial_directory: Union[os.PathLike, str],
*,
filename=None,
file_types=None,
filename: Optional[str] = None,
file_types: Optional[List[str]] = None,
):
super().__init__()
self.native = native
self.native: Union[IFileOpenDialog, IFileSaveDialog] = native

self._set_title(title)
if filename is not None:
native.FileName = filename
self.native.SetFileName(filename)

if initial_directory is not None:
self._set_initial_directory(str(initial_directory))

if file_types is not None:
filters = [f"{ext} files (*.{ext})|*.{ext}" for ext in file_types] + [
"All files (*.*)|*.*"
filters: List[Tuple[str, str]] = [
(f"{ext.upper()} files", f"*.{ext}") for ext in file_types
]

if len(file_types) > 1:
pattern = ";".join([f"*.{ext}" for ext in file_types])
filters.insert(0, f"All matching files ({pattern})|{pattern}")

native.Filter = "|".join(filters)
filterspec = (COMDLG_FILTERSPEC * len(file_types))(
*[(c_wchar_p(name), c_wchar_p(spec)) for name, spec in filters]
)
self.native.SetFileTypes(
len(filterspec), cast_with_ctypes(filterspec, POINTER(c_void_p))
)

def _show(self):
response = self.native.ShowDialog()
if response == DialogResult.OK:
hwnd = HWND(0)
hr: int = self.native.Show(hwnd)
if hr == S_OK:
assert isinstance(
self, (SaveFileDialog, OpenFileDialog, SelectFolderDialog)
)
self.future.set_result(self._get_filenames())
else:
self.future.set_result(None)

def _set_title(self, title):
self.native.Title = title
self.native.SetTitle(title)

def _set_initial_directory(self, initial_directory):
self.native.InitialDirectory = initial_directory
if initial_directory is None:
return
folder_path: Path = Path(initial_directory).resolve()
if folder_path.is_dir(): # sourcery skip: extract-method
SHCreateItemFromParsingName = windll.shell32.SHCreateItemFromParsingName
SHCreateItemFromParsingName.argtypes = [
c_wchar_p, # LPCWSTR (wide string, null-terminated)
POINTER(
comtypes.IUnknown
), # IBindCtx* (can be NULL, hence POINTER(IUnknown))
POINTER(GUID), # REFIID (pointer to the interface ID, typically GUID)
POINTER(
POINTER(IShellItem)
), # void** (output pointer to the requested interface)
]
SHCreateItemFromParsingName.restype = HRESULT
shell_item = POINTER(IShellItem)()
hr = SHCreateItemFromParsingName(
str(folder_path), None, IShellItem._iid_, byref(shell_item)
)
if hr == S_OK:
self.native.SetFolder(shell_item)


class SaveFileDialog(FileDialog):
def __init__(self, title, filename, initial_directory, file_types):
def __init__(
self,
title: str,
filename: str,
initial_directory: Union[os.PathLike, str],
file_types: List[str],
):
super().__init__(
WinForms.SaveFileDialog(),
comtypes.client.CreateObject(
CLSID_FileSaveDialog, interface=IFileSaveDialog
),
title,
initial_directory,
filename=filename,
file_types=file_types,
)

def _get_filenames(self):
return Path(self.native.FileName)
shell_item: IShellItem = self.native.GetResult()
display_name: str = shell_item.GetDisplayName(0x80058000) # SIGDN_FILESYSPATH
return Path(display_name)


class OpenFileDialog(FileDialog):
def __init__(
self,
title,
initial_directory,
file_types,
multiple_select,
title: str,
initial_directory: Union[os.PathLike, str],
file_types: List[str],
multiple_select: bool,
):
super().__init__(
WinForms.OpenFileDialog(),
comtypes.client.CreateObject(
CLSID_FileOpenDialog, interface=IFileOpenDialog
),
title,
initial_directory,
file_types=file_types,
)
if multiple_select:
self.native.Multiselect = True
self.native.SetOptions(FileOpenOptions.FOS_ALLOWMULTISELECT)

# Provided as a stub that can be mocked in test conditions
def selected_paths(self):
return self.native.FileNames

def _get_filenames(self):
if self.native.Multiselect:
return [Path(filename) for filename in self.selected_paths()]
else:
return Path(self.native.FileName)
# This is a stub method; we provide functionality using the COM API
return self._get_filenames()

def _get_filenames(self) -> List[Path]:
assert isinstance(self.native, IFileOpenDialog)
results: List[Path] = []
shell_item_array: IShellItemArray = self.native.GetResults()
item_count: int = shell_item_array.GetCount()
for i in range(item_count):
shell_item: IShellItem = shell_item_array.GetItemAt(i)
szFilePath: str = str(
shell_item.GetDisplayName(0x80058000)
) # SIGDN_FILESYSPATH
results.append(Path(szFilePath))
return results


class SelectFolderDialog(FileDialog):
def __init__(self, title, initial_directory, multiple_select):
def __init__(
self,
title: str,
initial_directory: Union[os.PathLike, str],
multiple_select: bool,
):
super().__init__(
WinForms.FolderBrowserDialog(),
comtypes.client.CreateObject(
CLSID_FileOpenDialog,
interface=IFileOpenDialog,
),
title,
initial_directory,
)
self.native.SetOptions(FileOpenOptions.FOS_PICKFOLDERS)
self.multiple_select: bool = multiple_select

# The native dialog doesn't support multiple selection, so the only effect
# this has is to change whether we return a list.
self.multiple_select = multiple_select

def _get_filenames(self):
filename = Path(self.native.SelectedPath)
return [filename] if self.multiple_select else filename
def _get_filenames(self) -> Union[List[Path], Path]:
shell_item: IShellItem = self.native.GetResult()
display_name: str = shell_item.GetDisplayName(0x80058000) # SIGDN_FILESYSPATH
return [Path(display_name)] if self.multiple_select else Path(display_name)

def _set_title(self, title):
self.native.Description = title
self.native.SetTitle(title)

def _set_initial_directory(self, initial_directory):
self.native.SelectedPath = initial_directory
if initial_directory is None:
return
folder_path: Path = Path(initial_directory).resolve()
if folder_path.is_dir(): # sourcery skip: extract-method
shell_item = POINTER(IShellItem)()
hr = windll.shell32.SHCreateItemFromParsingName(
str(folder_path),
None,
IShellItem._iid_,
byref(shell_item),
)
if hr == S_OK:
self.native.SetFolder(shell_item)
119 changes: 119 additions & 0 deletions winforms/src/toga_winforms/libs/com/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
from __future__ import annotations

from ctypes import Structure, c_int, c_ulong
from ctypes.wintypes import LPCWSTR
from enum import IntFlag
from typing import TYPE_CHECKING, Sequence

if TYPE_CHECKING:
from ctypes import _CData


class FileOpenOptions(IntFlag):
FOS_UNKNOWN1 = 0x00000001
FOS_OVERWRITEPROMPT = 0x00000002
FOS_STRICTFILETYPES = 0x00000004
FOS_NOCHANGEDIR = 0x00000008
FOS_UNKNOWN2 = 0x00000010
FOS_PICKFOLDERS = 0x00000020
FOS_FORCEFILESYSTEM = 0x00000040
FOS_ALLNONSTORAGEITEMS = 0x00000080
FOS_NOVALIDATE = 0x00000100
FOS_ALLOWMULTISELECT = 0x00000200
FOS_UNKNOWN4 = 0x00000400
FOS_PATHMUSTEXIST = 0x00000800
FOS_FILEMUSTEXIST = 0x00001000
FOS_CREATEPROMPT = 0x00002000
FOS_SHAREAWARE = 0x00004000
FOS_NOREADONLYRETURN = 0x00008000
FOS_NOTESTFILECREATE = 0x00010000
FOS_HIDEMRUPLACES = 0x00020000
FOS_HIDEPINNEDPLACES = 0x00040000
FOS_UNKNOWN5 = 0x00080000
FOS_NODEREFERENCELINKS = 0x00100000
FOS_UNKNOWN6 = 0x00200000
FOS_UNKNOWN7 = 0x00400000
FOS_UNKNOWN8 = 0x00800000
FOS_UNKNOWN9 = 0x01000000
FOS_DONTADDTORECENT = 0x02000000
FOS_UNKNOWN10 = 0x04000000
FOS_UNKNOWN11 = 0x08000000
FOS_FORCESHOWHIDDEN = 0x10000000
FOS_DEFAULTNOMINIMODE = 0x20000000
FOS_FORCEPREVIEWPANEON = 0x40000000
FOS_UNKNOWN12 = 0x80000000


# Shell Folder Get Attributes Options
SFGAOF = c_ulong


class SFGAO(IntFlag):
SFGAO_CANCOPY = 0x00000001 # Objects can be copied.
SFGAO_CANMOVE = 0x00000002 # Objects can be moved.
SFGAO_CANLINK = 0x00000004 # Objects can be linked.
SFGAO_STORAGE = 0x00000008 # Objects can be stored.
SFGAO_CANRENAME = 0x00000010 # Objects can be renamed.
SFGAO_CANDELETE = 0x00000020 # Objects can be deleted.
SFGAO_HASPROPSHEET = 0x00000040 # Objects have property sheets.
SFGAO_DROPTARGET = 0x00000100 # Objects are drop targets.
SFGAO_CAPABILITYMASK = 0x00000177 # Mask for all capability flags.
SFGAO_ENCRYPTED = 0x00002000 # Object is encrypted (use alt color).
SFGAO_ISSLOW = 0x00004000 # Accessing this object is slow.
SFGAO_GHOSTED = 0x00008000 # Object is ghosted (dimmed).
SFGAO_LINK = 0x00010000 # Shortcut (link).
SFGAO_SHARE = 0x00020000 # Shared.
SFGAO_READONLY = 0x00040000 # Read-only.
SFGAO_HIDDEN = 0x00080000 # Hidden object.
SFGAO_DISPLAYATTRMASK = 0x000FC000 # Mask for display attributes.
SFGAO_FILESYSANCESTOR = 0x10000000 # May contain children with file system folders.
SFGAO_FOLDER = 0x20000000 # Is a folder.
SFGAO_FILESYSTEM = 0x40000000 # Is part of the file system.
SFGAO_HASSUBFOLDER = 0x80000000 # May contain subfolders.
SFGAO_CONTENTSMASK = 0x80000000 # Mask for contents.
SFGAO_VALIDATE = 0x01000000 # Invalidate cached information.
SFGAO_REMOVABLE = 0x02000000 # Is a removable media.
SFGAO_COMPRESSED = 0x04000000 # Object is compressed.
SFGAO_BROWSABLE = 0x08000000 # Supports browsing.
SFGAO_NONENUMERATED = 0x00100000 # Is not enumerated.
SFGAO_NEWCONTENT = 0x00200000 # New content is present.
SFGAO_CANMONIKER = 0x00400000 # Can create monikers for this item.
SFGAO_HASSTORAGE = 0x00400000 # Supports storage interfaces.
SFGAO_STREAM = 0x00400000 # Is a stream object.
SFGAO_STORAGEANCESTOR = 0x00800000 # May contain children with storage folders.
SFGAO_STORAGECAPMASK = 0x70C50008 # Mask for storage capability attributes.
SFGAO_PKEYSFGAOMASK = (
0x81044000 # Attributes that are part of the PKEY_SFGAOFlags property.
)


class SIGDN(c_int):
SIGDN_NORMALDISPLAY = 0x00000000
SIGDN_PARENTRELATIVEPARSING = 0x80018001
SIGDN_PARENTRELATIVEFORADDRESSBAR = 0x8001C001
SIGDN_DESKTOPABSOLUTEPARSING = 0x80028000
SIGDN_PARENTRELATIVEEDITING = 0x80031001
SIGDN_DESKTOPABSOLUTEEDITING = 0x8004C000
SIGDN_FILESYSPATH = 0x80058000
SIGDN_URL = 0x80068000


class FDAP(c_int):
FDAP_BOTTOM = 0x00000000
FDAP_TOP = 0x00000001


class FDE_SHAREVIOLATION_RESPONSE(c_int): # noqa: N801
FDESVR_DEFAULT = 0x00000000
FDESVR_ACCEPT = 0x00000001
FDESVR_REFUSE = 0x00000002


FDE_OVERWRITE_RESPONSE = FDE_SHAREVIOLATION_RESPONSE


class COMDLG_FILTERSPEC(Structure): # noqa: N801
_fields_: Sequence[tuple[str, type[_CData]] | tuple[str, type[_CData], int]] = [
("pszName", LPCWSTR),
("pszSpec", LPCWSTR),
]
Loading
Loading