Skip to content

Commit

Permalink
Construct context menu using Qt
Browse files Browse the repository at this point in the history
  • Loading branch information
probonopd authored Jul 4, 2024
1 parent 1aee691 commit d7d0b0b
Showing 1 changed file with 78 additions and 223 deletions.
301 changes: 78 additions & 223 deletions windowscontextmenu.py
Original file line number Diff line number Diff line change
@@ -1,256 +1,111 @@
#!/usr/bin/env python3

"""
Script for interacting with Windows context menus using win32gui, win32com, and ctypes.
"""
Script for interacting with Windows context menus using PyQt6 and win32com.
from __future__ import annotations
This script provides a function to show a context menu for a given file or folder path.
The context menu is constructed in Qt and is populated with the available verbs.
When a verb is selected, it is executed using the win32com library.
"""

import ctypes
import os
from ctypes import windll
from pathlib import WindowsPath
from typing import TYPE_CHECKING, Sequence

import win32com.client
import win32con
import win32gui

if TYPE_CHECKING:
from _win32typing import PyResourceId, PyWNDCLASS
from win32com.client import DispatchBaseClass
from win32com.client.dynamic import CDispatch

# Ensure DPI awareness for sharp menu display
try:
windll.shcore.SetProcessDpiAwareness(2) # PROCESS_SYSTEM_DPI_AWARE
except Exception as e:
print("Failed to set DPI awareness:", e)

# Load libraries
user32: ctypes.WinDLL = windll.user32
kernel32: ctypes.WinDLL = windll.kernel32

# Define window class and procedure
WNDPROC: ctypes.WINFUNCTYPE = ctypes.WINFUNCTYPE(
ctypes.c_long,
ctypes.c_void_p,
ctypes.c_uint,
ctypes.c_uint,
ctypes.c_long,
)


def wnd_proc(
hwnd: int | None,
message: int,
wparam: float | None,
lparam: float | None,
) -> int:
if message == win32con.WM_DESTROY:
win32gui.PostQuitMessage(0)
return 0
return win32gui.DefWindowProc(hwnd, message, wparam, lparam)


class RobustInvisibleWindow:
"""Context manager for creating and destroying an invisible window."""
CLASS_NAME: str = "RobustInvisibleWindow"
DISPLAY_NAME: str = "Robust Invisible Window"

def __init__(self):
self.hwnd: int | None = None
from pathlib import Path
from typing import Sequence

def __enter__(self) -> int:
self.register_class()
self.hwnd = self.create_window()
return self.hwnd
from PyQt6.QtGui import QAction, QIcon, QCursor
from PyQt6.QtWidgets import QApplication, QMenu, QMessageBox

def __exit__(self, exc_type, exc_val, exc_tb):
if self.hwnd is not None:
win32gui.DestroyWindow(self.hwnd)
self.unregister_class()

def register_class(self):
"""Register the window class."""
wc: PyWNDCLASS = win32gui.WNDCLASS()
wc.lpfnWndProc = WNDPROC(wnd_proc)
wc.lpszClassName = self.CLASS_NAME
wc.hInstance = kernel32.GetModuleHandleW(None)
wc.hCursor = user32.LoadCursorW(None, 32512)
try:
self._class_atom: PyResourceId = win32gui.RegisterClass(wc)
except Exception as e:
if getattr(e, "winerror", None) != 1410: # class already registered
raise

def unregister_class(self):
"""Unregister the window class."""
win32gui.UnregisterClass(self.CLASS_NAME, kernel32.GetModuleHandleW(None))
import win32com.client

def create_window(self) -> int:
"""Create an invisible window."""
return user32.CreateWindowExW(
0, self.CLASS_NAME, self.DISPLAY_NAME, 0, 0, 0, 0, 0, None, None,
kernel32.GetModuleHandleW(None), None
)

def _safe_path_parse(file_path: os.PathLike | str) -> Path:
"""Safely parse a file path to a Path object."""
return Path(file_path)

def _safe_path_parse(file_path: os.PathLike | str) -> WindowsPath:
return WindowsPath(os.fspath(file_path))

def show_context_menu(paths: Sequence[os.PathLike | str]):
"""
Show the appropriate context menu.
def safe_isfile(path: WindowsPath) -> bool | None:
try:
result: bool = path.is_file()
except (OSError, ValueError):
return None
Args:
paths (Sequence[os.PathLike | str]): The paths for which to show the context menu.
"""
if isinstance(paths, (str, os.PathLike)):
paths = [_safe_path_parse(paths)]
elif isinstance(paths, list):
paths = [_safe_path_parse(p) for p in paths]
else:
return result
return

menu = QMenu()

def safe_isdir(path: WindowsPath) -> bool | None:
try:
result: bool = path.is_dir()
except (OSError, ValueError):
return None
else:
return result
shell = win32com.client.Dispatch("Shell.Application")
items = [shell.NameSpace(str(p.parent)).ParseName(p.name) for p in paths]

print(f"Paths: {paths}")

def windows_context_menu_file(file_path: os.PathLike | str):
"""Opens the default Windows context menu for a filepath at the position of the cursor."""
parsed_filepath: WindowsPath = _safe_path_parse(file_path)
hwnd = None

shell: CDispatch = win32com.client.Dispatch("Shell.Application")
folder: CDispatch = shell.NameSpace(str(parsed_filepath.parent))
item: CDispatch = folder.ParseName(parsed_filepath.name)
context_menu: CDispatch = item.Verbs()
hmenu: int = win32gui.CreatePopupMenu()
for i, verb in enumerate(context_menu):
# Populate context menu with verbs
# FIXME: Handle multiple items; currently only the first item is used. This might mean that we need to get the Verbs in a different way?
# TODO: Check if https://github.com/NickHugi/PyKotor/blob/master/Libraries/Utility/src/utility/system/windows_context_menu.py handles multiple items better
# May need to take a look at SHMultiFileProperties and https://stackoverflow.com/a/34551988/1839209.
verbs = items[0].Verbs()
for verb in verbs:
if verb.Name:
win32gui.AppendMenu(hmenu, win32con.MF_STRING, i + 1, verb.Name)
app = QApplication.instance()
action = QAction(verb.Name, app)
action.triggered.connect(lambda _, v=verb: execute_verb(v))
menu.addAction(action)
else:
win32gui.AppendMenu(hmenu, win32con.MF_SEPARATOR, 0, "")
pt: tuple[int, int] = win32gui.GetCursorPos()

with RobustInvisibleWindow() as hwnd:
cmd: int = win32gui.TrackPopupMenu(
hmenu, win32con.TPM_LEFTALIGN | win32con.TPM_RETURNCMD,
pt[0], pt[1], 0, hwnd, None
)
if cmd:
verb: DispatchBaseClass = context_menu.Item(cmd - 1)
if verb:
verb.DoIt()
menu.addSeparator()

menu.exec(QCursor.pos())

def windows_context_menu_folder(folder_path: os.PathLike | str):
"""Opens the default Windows context menu for a folderpath at the position of the cursor."""
parsed_folderpath: WindowsPath = _safe_path_parse(folder_path)
hwnd = None

shell: CDispatch = win32com.client.Dispatch("Shell.Application")
folder: CDispatch = shell.NameSpace(str(parsed_folderpath))
item: CDispatch = folder.Self
context_menu: CDispatch = item.Verbs()
hmenu: int = win32gui.CreatePopupMenu()
for i, verb in enumerate(context_menu):
if verb.Name:
win32gui.AppendMenu(hmenu, win32con.MF_STRING, i + 1, verb.Name)
# FIXME: The following actions only give errors, so disable them. But we need a way to get the non-localized names of these actions.
# if i == 12 or i == 18:
# win32gui.EnableMenuItem(hmenu, i + 1, win32con.MF_BYCOMMAND | win32con.MF_DISABLED)
else:
win32gui.AppendMenu(hmenu, win32con.MF_SEPARATOR, 0, "")
pt: tuple[int, int] = win32gui.GetCursorPos()
def execute_verb(verb):
"""
Execute the specified verb.
with RobustInvisibleWindow() as hwnd:
cmd: int = win32gui.TrackPopupMenu(
hmenu, win32con.TPM_LEFTALIGN | win32con.TPM_RETURNCMD,
pt[0], pt[1], 0, hwnd, None
)
if cmd:
verb: DispatchBaseClass = context_menu.Item(cmd - 1)
if verb:
verb.DoIt()
Args:
verb: The verb to execute.
"""
try:
print(f"Executing verb: {verb.Name}")
verb.DoIt()
except Exception as e:
show_error_message(f"An error occurred while executing the action: {e}")

def show_error_message(message):
"""
Display an error message.
Args:
message (str): The error message to display.
"""
app = QApplication.instance()
if app is None:
app = QApplication([])
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Icon.Critical)
msg_box.setText("An error occurred.")
msg_box.setInformativeText(message)
msg_box.setWindowTitle("Error")
msg_box.exec()


def windows_context_menu_multiple(paths: Sequence[os.PathLike | str]):
"""Opens the default Windows context menu for multiple files/folder paths at the position of the cursor."""
parsed_paths: list[WindowsPath] = [_safe_path_parse(path) for path in paths]
hwnd = None
if __name__ == "__main__":
app = QApplication([])

shell: CDispatch = win32com.client.Dispatch("Shell.Application")
folders_items: list[CDispatch] = [
shell.NameSpace(str(path.parent if safe_isfile(path) else path)).ParseName(path.name)
for path in parsed_paths
# Example with multiple file paths
multiple_files = [
r"C:\Windows\System32\notepad.exe",
r"C:\Windows\System32\calc.exe",
]
context_menu: CDispatch = folders_items[0].Verbs()
hmenu: int = win32gui.CreatePopupMenu()
for i, verb in enumerate(context_menu):
if verb.Name:
win32gui.AppendMenu(hmenu, win32con.MF_STRING, i + 1, verb.Name)
else:
win32gui.AppendMenu(hmenu, win32con.MF_SEPARATOR, 0, "")
pt: tuple[int, int] = win32gui.GetCursorPos()

with RobustInvisibleWindow() as hwnd:
cmd: int = win32gui.TrackPopupMenu(
hmenu, win32con.TPM_LEFTALIGN | win32con.TPM_RETURNCMD,
pt[0], pt[1], 0, hwnd, None
)
if cmd:
verb: DispatchBaseClass = context_menu.Item(cmd - 1)
if verb:
verb.DoIt()


def windows_context_menu(path: os.PathLike | str):
"""Opens the default Windows context menu for a folder/file path at the position of the cursor."""
parsed_path: WindowsPath = _safe_path_parse(path)
if safe_isfile(parsed_path):
windows_context_menu_file(parsed_path)
elif safe_isdir(parsed_path):
windows_context_menu_folder(parsed_path)
else:
msg = f"Path is neither file nor folder: {path}"
raise ValueError(msg)


def show_context_menu(paths):
"""Determines appropriate context menu action based on paths."""
if isinstance(paths, str):
paths = [paths]
elif not isinstance(paths, list):
return

is_file = all(os.path.isfile(path) for path in paths)
is_dir = all(os.path.isdir(path) for path in paths)

if is_file and len(paths) == 1:
windows_context_menu(paths[0])
elif is_dir and len(paths) == 1:
windows_context_menu_folder(paths[0])
elif is_file:
windows_context_menu_multiple(paths)
else:
print("TODO: Handle mixed types or invalid paths")


"""# Example usage
if __name__ == "__main__":
show_context_menu(multiple_files)

# Example with a folder path
folderpath = r"C:\Windows\System32"
show_context_menu(folderpath)

# Example with a file path
filepath = r"C:\Windows\System32\notepad.exe"
show_context_menu(filepath)
# Example with multiple file paths
multiple_files = [
r"C:\Windows\System32\notepad.exe",
r"C:\Windows\System32\notepad.exe",
]
show_context_menu(multiple_files)
"""

0 comments on commit d7d0b0b

Please sign in to comment.