Automate WinUtil title screen generation (#4965)

* feat: add title screen generation tooling

Add local automation for capturing WinUtil in Light and Dark themes
and generating the composite title-screen image.

* ci: automate title screen updates

Add a manual Windows workflow that compiles WinUtil, generates the
Light and Dark composite, and opens an image-only pull request.

* docs: update AGENTS.md and SPEC.md with title-screen generation details

* fix: address review comments

Tested image is correct, SHA-256 hash is identical to the previously verified output.

* fix: deselect bitmap before reading capture pixels
This commit is contained in:
Omar
2026-09-02 16:10:44 -05:00
committed by GitHub
parent d1a61790a1
commit 41e4f35897
15 changed files with 1516 additions and 2 deletions
+1
View File
@@ -0,0 +1 @@
3.13
+75
View File
@@ -0,0 +1,75 @@
# WinUtil title screen
This tool generates the Light and Dark composite used as WinUtil's title screen
in the repository README and documentation site. It opens the Tweaks tab,
captures both themes, and combines them into one PNG. The two raw captures are
temporary and are removed when the command finishes.
## Requirements
- Windows with an interactive desktop
- [uv](https://docs.astral.sh/uv/)
- WinUtil compiled and running
- An elevated PowerShell terminal
Run the commands below from `tools/title-screen`.
## Generate and review a test image
Start WinUtil from the repository root:
```powershell
.\Compile.ps1 -Run
```
With WinUtil still open, return to this directory in an elevated terminal and
generate a test image:
```powershell
uv run --locked python automate_title_screen.py --output "$env:TEMP\winutil-title-screen.png"
```
Open the resulting PNG and check that:
- the Tweaks tab is shown,
- the Light theme is on the upper-left side of the diagonal,
- the Dark theme is on the lower-right side, and
- no desktop background or other windows are visible.
The automation works whether WinUtil starts in Light or Dark mode. It leaves the
window on the Tweaks tab in Light mode.
## Automation
The title screen is updated through a manually triggered GitHub Actions workflow.
When the generated image changes, the workflow opens or updates a pull request
for review. It does not merge the pull request automatically.
Failed runs upload the capture log, UI Automation inspection, and available image
as diagnostic artifacts for 14 days.
## Tests
The tests cover theme detection and composite image generation without opening
WinUtil:
```powershell
uv run --locked python -m unittest discover
```
## Troubleshooting
If WinUtil cannot be found, make sure the compiled WPF window is open and that
the terminal is elevated. The script deliberately ignores editors, terminals,
and browser windows that merely contain "WinUtil" in their title.
If a tab or theme control cannot be found, capture the UI Automation tree while
WinUtil is open:
```powershell
uv run --locked python inspect_winutil.py "$env:TEMP\winutil-inspect.txt"
```
The inspector opens the theme menu before recording its controls. Attach the
text file when reporting a failure. It contains window and control metadata, not
the generated screenshots.
+252
View File
@@ -0,0 +1,252 @@
"""Generate WinUtil's Light and Dark Tweaks title-screen composite.
The automation targets a strictly validated WinUtil WPF window, selects the
Tweaks tab, and chooses the explicit Dark and Light menu items rather than
toggling an unknown initial state. Raw captures live in a temporary directory;
only the requested composite output persists.
Local execution must use the same elevation level as WinUtil. CI may provide
``WINUTIL_HWND`` to bypass desktop discovery after launching a known window.
The current maximized WPF bounds determine the capture dimensions.
"""
from __future__ import annotations
import argparse
import tempfile
import time
from pathlib import Path
from PIL import Image, ImageStat
from pywinauto.application import Application
from capture_winutil import (
capture_window,
find_winutil_hwnd,
get_window_capture_size,
)
from create_composite import create_composite
CONTROL_TIMEOUT_SECONDS = 10
MENU_SETTLE_SECONDS = 0.25
TAB_SETTLE_SECONDS = 1.0
THEME_SETTLE_SECONDS = 2.0
WINDOW_SETTLE_SECONDS = 0.5
THEME_BRIGHTNESS_THRESHOLD = 128
SUPPORTED_THEMES = {"Dark", "Light"}
def is_dark_mode(image: Image.Image) -> bool:
"""Return whether the stable center band resembles WinUtil's Dark theme.
The center band avoids the title-bar icons and outer DWM frame. Current Dark
captures average roughly 30-40 luminance while Light captures are above 230,
leaving a wide margin around the midpoint threshold.
"""
width, height = image.size
sample = image.crop(
(
width // 4,
height // 2 - 10,
3 * width // 4,
height // 2 + 10,
)
).convert("L")
return ImageStat.Stat(sample).mean[0] < THEME_BRIGHTNESS_THRESHOLD
def capture_theme(
hwnd,
width: int,
height: int,
output_path: str | Path,
*,
expected_dark: bool,
) -> Image.Image:
"""Capture a theme and reject a stale or unsuccessful theme change."""
image = capture_window(hwnd, width, height, output_path)
actual_dark = is_dark_mode(image)
if actual_dark != expected_dark:
expected_name = "Dark" if expected_dark else "Light"
actual_name = "Dark" if actual_dark else "Light"
raise RuntimeError(
f"Expected {expected_name} mode after selecting its theme menu item, "
f"but the capture looks {actual_name}."
)
return image
def maximize_window(hwnd) -> tuple[int, int]:
"""Maximize WinUtil and return its refreshed physical capture dimensions."""
app = Application(backend="win32").connect(
handle=hwnd,
timeout=CONTROL_TIMEOUT_SECONDS,
)
window = app.window(handle=hwnd).wrapper_object()
window.maximize()
time.sleep(WINDOW_SETTLE_SECONDS)
return get_window_capture_size(hwnd)
def select_tweaks_tab(hwnd) -> None:
"""Select the Tweaks tab through its stable WPF automation identifier."""
app = Application(backend="uia").connect(
handle=hwnd,
timeout=CONTROL_TIMEOUT_SECONDS,
)
window = app.window(handle=hwnd)
tweaks_spec = window.child_window(
auto_id="WPFTab2BT",
control_type="Button",
)
try:
tweaks_spec.wait(
"exists enabled visible ready",
timeout=CONTROL_TIMEOUT_SECONDS,
)
tweaks_button = tweaks_spec.wrapper_object()
except Exception as exc:
raise RuntimeError(
f"Tweaks button WPFTab2BT was not available in HWND {hex(hwnd)}. "
"Confirm WinUtil is fully loaded and run at the same elevation level."
) from exc
window.set_focus()
tweaks_button.click_input()
time.sleep(TAB_SETTLE_SECONDS)
def set_theme(hwnd, theme_name: str) -> None:
"""Open WinUtil's theme menu and choose an explicit theme menu item."""
if theme_name not in SUPPORTED_THEMES:
supported = ", ".join(sorted(SUPPORTED_THEMES))
raise ValueError(
f"Unsupported WinUtil theme {theme_name!r}; expected one of {supported}."
)
# WPF can replace automation elements while swapping its resource dictionary,
# so each theme selection starts with a fresh UIA connection and wrapper.
app = Application(backend="uia").connect(
handle=hwnd,
timeout=CONTROL_TIMEOUT_SECONDS,
)
window = app.window(handle=hwnd)
theme_button_spec = window.child_window(
auto_id="ThemeButton",
control_type="Button",
)
try:
theme_button_spec.wait(
"exists enabled visible ready",
timeout=CONTROL_TIMEOUT_SECONDS,
)
theme_button = theme_button_spec.wrapper_object()
except Exception as exc:
raise RuntimeError(
f"ThemeButton was not available in HWND {hex(hwnd)}. Confirm WinUtil "
"is fully loaded and run at the same elevation level."
) from exc
# ThemeButton advertises UIA InvokePattern, but its WinUtil handler requires
# real pointer input. click_input sends the same mouse path as a user click.
window.set_focus()
theme_button.click_input()
time.sleep(MENU_SETTLE_SECONDS)
deadline = time.monotonic() + CONTROL_TIMEOUT_SECONDS
popup_details = []
while time.monotonic() < deadline:
popup_windows = [
candidate
for candidate in app.windows(visible_only=True)
if candidate.handle != hwnd
]
popup_details = [
f"{popup.window_text()!r} ({popup.class_name()})"
for popup in popup_windows
]
# Framework versions differ on whether a WPF menu is attached to the main
# UIA tree or exposed as its own top-level popup, so search both forms.
search_roots = [*popup_windows, window.wrapper_object()]
for root in search_roots:
matching_controls = root.descendants(title=theme_name)
if matching_controls:
matching_controls[0].click_input()
time.sleep(THEME_SETTLE_SECONDS)
return
time.sleep(0.1)
visible_popups = ", ".join(popup_details) if popup_details else "none"
raise RuntimeError(
f"Theme popup opened, but its {theme_name!r} option was not found in the "
"refreshed WinUtil UIA tree. Run inspect_winutil.py to check whether the "
f"menu changed. Visible same-process popups: {visible_popups}."
)
def generate_title_screen(output_path: str | Path) -> Path:
"""Capture both themes and write one final title-screen composite."""
output_path = Path(output_path)
print("Locating WinUtil window...")
hwnd, _, _ = find_winutil_hwnd()
print("Maximizing WinUtil...")
width, height = maximize_window(hwnd)
print("Selecting Tweaks tab...")
select_tweaks_tab(hwnd)
print("Selecting Dark theme...")
set_theme(hwnd, "Dark")
with tempfile.TemporaryDirectory(prefix="winutil-title-screen-") as temp_dir:
capture_directory = Path(temp_dir)
dark_path = capture_directory / "winutil-dark.png"
light_path = capture_directory / "winutil-light.png"
print("Capturing Dark theme...")
capture_theme(
hwnd,
width,
height,
dark_path,
expected_dark=True,
)
print("Selecting Light theme...")
set_theme(hwnd, "Light")
print("Capturing Light theme...")
capture_theme(
hwnd,
width,
height,
light_path,
expected_dark=False,
)
print("Generating title-screen composite...")
create_composite(dark_path, light_path, output_path)
print(f"Generated title screen: {output_path}")
return output_path
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Capture WinUtil's Light and Dark Tweaks title screen."
)
parser.add_argument("--output", required=True, type=Path, help="Destination PNG")
return parser.parse_args()
def main() -> None:
args = _parse_args()
generate_title_screen(args.output)
if __name__ == "__main__":
main()
+437
View File
@@ -0,0 +1,437 @@
"""Locate and capture the WinUtil WPF window through Win32 APIs.
The module identifies WinUtil by both its title and WPF ``HwndWrapper`` class so
an editor or terminal containing the word "winutil" cannot be captured by
mistake. It opts into physical-pixel DPI coordinates before reading bounds, then
uses ``PrintWindow`` and a 32-bit GDI bitmap to capture the WPF surface without
including the surrounding desktop.
"""
from __future__ import annotations
import argparse
import ctypes
import os
from ctypes import wintypes
from pathlib import Path
from PIL import Image
GENERIC_ALL = 0x10000000
PW_RENDERFULLCONTENT = 2
DIB_RGB_COLORS = 0
BI_RGB = 0
DWMWA_EXTENDED_FRAME_BOUNDS = 9
# Win32 otherwise virtualizes coordinates for DPI-unaware Python processes. The
# older shcore call supports systems where Per-Monitor V2 is unavailable.
try:
ctypes.windll.user32.SetProcessDpiAwarenessContext(-4)
except Exception:
try:
ctypes.windll.shcore.SetProcessDpiAwareness(2)
except Exception:
pass
user32 = ctypes.windll.user32
dwmapi = ctypes.windll.dwmapi
gdi32 = ctypes.windll.gdi32
kernel32 = ctypes.windll.kernel32
# ctypes assumes integer arguments and return values unless signatures are
# declared. Explicit handle types prevent silent 32-bit truncation on 64-bit
# Windows, which can otherwise produce invalid windows or GDI crashes.
user32.OpenInputDesktop.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
user32.OpenInputDesktop.restype = wintypes.HDESK
user32.OpenDesktopW.argtypes = [
wintypes.LPCWSTR,
wintypes.DWORD,
wintypes.BOOL,
wintypes.DWORD,
]
user32.OpenDesktopW.restype = wintypes.HDESK
user32.CloseDesktop.argtypes = [wintypes.HDESK]
user32.CloseDesktop.restype = wintypes.BOOL
WINDOW_ENUM_CALLBACK = ctypes.WINFUNCTYPE(
wintypes.BOOL,
wintypes.HWND,
wintypes.LPARAM,
)
user32.EnumDesktopWindows.argtypes = [
wintypes.HDESK,
WINDOW_ENUM_CALLBACK,
wintypes.LPARAM,
]
user32.EnumDesktopWindows.restype = wintypes.BOOL
user32.EnumWindows.argtypes = [WINDOW_ENUM_CALLBACK, wintypes.LPARAM]
user32.EnumWindows.restype = wintypes.BOOL
user32.IsWindowVisible.argtypes = [wintypes.HWND]
user32.IsWindowVisible.restype = wintypes.BOOL
user32.GetWindowTextLengthW.argtypes = [wintypes.HWND]
user32.GetWindowTextLengthW.restype = ctypes.c_int
user32.GetWindowTextW.argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int]
user32.GetWindowTextW.restype = ctypes.c_int
user32.GetClassNameW.argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int]
user32.GetClassNameW.restype = ctypes.c_int
class RECT(ctypes.Structure):
"""Win32 rectangle used by both User32 and DWM bounds APIs."""
_fields_ = [
("left", ctypes.c_long),
("top", ctypes.c_long),
("right", ctypes.c_long),
("bottom", ctypes.c_long),
]
dwmapi.DwmGetWindowAttribute.argtypes = [
wintypes.HWND,
wintypes.DWORD,
ctypes.c_void_p,
wintypes.DWORD,
]
dwmapi.DwmGetWindowAttribute.restype = wintypes.DWORD
user32.GetWindowRect.argtypes = [wintypes.HWND, ctypes.POINTER(RECT)]
user32.GetWindowRect.restype = wintypes.BOOL
user32.GetDpiForWindow.argtypes = [wintypes.HWND]
user32.GetDpiForWindow.restype = wintypes.UINT
user32.GetWindowDC.argtypes = [wintypes.HWND]
user32.GetWindowDC.restype = wintypes.HDC
user32.ReleaseDC.argtypes = [wintypes.HWND, wintypes.HDC]
user32.ReleaseDC.restype = ctypes.c_int
user32.PrintWindow.argtypes = [wintypes.HWND, wintypes.HDC, wintypes.UINT]
user32.PrintWindow.restype = wintypes.BOOL
gdi32.CreateCompatibleDC.argtypes = [wintypes.HDC]
gdi32.CreateCompatibleDC.restype = wintypes.HDC
gdi32.CreateCompatibleBitmap.argtypes = [wintypes.HDC, ctypes.c_int, ctypes.c_int]
gdi32.CreateCompatibleBitmap.restype = wintypes.HBITMAP
gdi32.SelectObject.argtypes = [wintypes.HDC, wintypes.HGDIOBJ]
gdi32.SelectObject.restype = wintypes.HGDIOBJ
gdi32.DeleteDC.argtypes = [wintypes.HDC]
gdi32.DeleteDC.restype = wintypes.BOOL
gdi32.DeleteObject.argtypes = [wintypes.HGDIOBJ]
gdi32.DeleteObject.restype = wintypes.BOOL
kernel32.GetLastError.argtypes = []
kernel32.GetLastError.restype = wintypes.DWORD
class BITMAPINFOHEADER(ctypes.Structure):
"""Header describing the top-down, uncompressed bitmap requested from GDI."""
_fields_ = [
("biSize", wintypes.DWORD),
("biWidth", ctypes.c_long),
("biHeight", ctypes.c_long),
("biPlanes", wintypes.WORD),
("biBitCount", wintypes.WORD),
("biCompression", wintypes.DWORD),
("biSizeImage", wintypes.DWORD),
("biXPelsPerMeter", ctypes.c_long),
("biYPelsPerMeter", ctypes.c_long),
("biClrUsed", wintypes.DWORD),
("biClrImportant", wintypes.DWORD),
]
class BITMAPINFO(ctypes.Structure):
"""GDI bitmap metadata with correctly aligned unused RGB color entries."""
_fields_ = [
("bmiHeader", BITMAPINFOHEADER),
("bmiColors", wintypes.DWORD * 3),
]
gdi32.GetDIBits.argtypes = [
wintypes.HDC,
wintypes.HBITMAP,
wintypes.UINT,
wintypes.UINT,
ctypes.c_void_p,
ctypes.POINTER(BITMAPINFO),
wintypes.UINT,
]
gdi32.GetDIBits.restype = ctypes.c_int
def _window_text(hwnd) -> str:
"""Return the title of a top-level window."""
length = user32.GetWindowTextLengthW(hwnd)
buffer = ctypes.create_unicode_buffer(length + 1)
user32.GetWindowTextW(hwnd, buffer, length + 1)
return buffer.value
def _window_class(hwnd) -> str:
"""Return the Win32 class name of a top-level window."""
buffer = ctypes.create_unicode_buffer(256)
user32.GetClassNameW(hwnd, buffer, len(buffer))
return buffer.value
def _is_winutil_window(title: str, class_name: str) -> bool:
"""Return whether a title and class identify the WinUtil WPF window."""
return "winutil" in title.lower() and "hwndwrapper" in class_name.lower()
def _describe_window(hwnd, title: str, class_name: str, width: int, height: int):
"""Print the window identity, DPI scale, and physical capture size."""
dpi = user32.GetDpiForWindow(hwnd)
scale = dpi / 96.0 if dpi > 0 else 1.0
print(
f"Located HWND: {hex(hwnd)} | Title: {title!r} | Class: {class_name!r} "
f"| DPI: {dpi} ({scale:.2f}x) | Capture Size: {width}x{height}"
)
def find_winutil_hwnd():
"""Return the validated WinUtil HWND and physical capture dimensions.
``WINUTIL_HWND`` is an optional CI optimization, not a trust boundary: its
title and WPF class are validated exactly like a discovered window.
"""
explicit_hwnd = os.environ.get("WINUTIL_HWND")
if explicit_hwnd:
try:
hwnd = int(explicit_hwnd, 0)
except ValueError as exc:
raise RuntimeError(
f"WINUTIL_HWND is not a valid window handle: {explicit_hwnd!r}"
) from exc
title = _window_text(hwnd)
class_name = _window_class(hwnd)
if not _is_winutil_window(title, class_name):
raise RuntimeError(
f"WINUTIL_HWND {hex(hwnd)} is not the WinUtil WPF window: "
f"title={title!r}, class={class_name!r}"
)
width, height = get_window_capture_size(hwnd)
_describe_window(hwnd, title, class_name, width, height)
return hwnd, width, height
found_windows = {}
def collect_window(hwnd, _):
if not user32.IsWindowVisible(hwnd):
return True
rect = RECT()
if not user32.GetWindowRect(hwnd, ctypes.byref(rect)):
return True
width = rect.right - rect.left
height = rect.bottom - rect.top
if width < 100 or height < 100:
return True
title = _window_text(hwnd)
class_name = _window_class(hwnd)
if _is_winutil_window(title, class_name):
found_windows[hwnd] = (hwnd, title, class_name, width, height)
return True
callback = WINDOW_ENUM_CALLBACK(collect_window)
user32.EnumWindows(callback, 0)
# Hosted runners can expose the GUI on the Default desktop while the calling
# process sees another input desktop. Enumerating both finds that window
# without moving this thread between desktops.
desktop_handles = [
user32.OpenInputDesktop(0, False, GENERIC_ALL),
user32.OpenDesktopW("Default", 0, False, GENERIC_ALL),
]
for desktop_handle in desktop_handles:
if not desktop_handle:
continue
try:
user32.EnumDesktopWindows(desktop_handle, callback, 0)
finally:
user32.CloseDesktop(desktop_handle)
if not found_windows:
raise RuntimeError(
"Could not find a visible WinUtil WPF window. Confirm WinUtil is open "
"and run this command at the same elevation level."
)
windows = sorted(
found_windows.values(),
key=lambda candidate: candidate[3] * candidate[4],
reverse=True,
)
if len(windows) > 1:
print(
f"Warning: found {len(windows)} WinUtil windows; using the largest "
"WPF window."
)
hwnd, title, class_name, _, _ = windows[0]
width, height = get_window_capture_size(hwnd)
_describe_window(hwnd, title, class_name, width, height)
return hwnd, width, height
def get_window_capture_size(hwnd) -> tuple[int, int]:
"""Return the current physical-pixel capture size for a window.
DWM extended-frame bounds exclude invisible shadow padding and are preferred
when available. Because DPI awareness is enabled during import, neither the
DWM nor User32 result should be scaled again.
"""
window_rect = RECT()
if not user32.GetWindowRect(hwnd, ctypes.byref(window_rect)):
raise RuntimeError(f"GetWindowRect failed for HWND {hex(hwnd)}")
window_width = window_rect.right - window_rect.left
window_height = window_rect.bottom - window_rect.top
frame_rect = RECT()
frame_result = dwmapi.DwmGetWindowAttribute(
hwnd,
DWMWA_EXTENDED_FRAME_BOUNDS,
ctypes.byref(frame_rect),
ctypes.sizeof(frame_rect),
)
frame_width = frame_rect.right - frame_rect.left
frame_height = frame_rect.bottom - frame_rect.top
if frame_result == 0 and frame_width > 0 and frame_height > 0:
return frame_width, frame_height
if window_width > 0 and window_height > 0:
return window_width, window_height
raise RuntimeError(f"Window {hex(hwnd)} has invalid bounds")
def capture_window(
hwnd,
width: int,
height: int,
output_path: str | Path,
) -> Image.Image:
"""Capture an HWND to a validated PNG and return the Pillow image.
The supplied dimensions must match the window's current physical bounds.
``PW_RENDERFULLCONTENT`` is attempted first because WPF may render portions
outside the visible desktop; ordinary ``PrintWindow`` is retained as a
compatibility fallback.
"""
output_path = Path(output_path)
window_dc = user32.GetWindowDC(hwnd)
if not window_dc:
raise RuntimeError("GetWindowDC failed. Run at the same elevation as WinUtil.")
memory_dc = gdi32.CreateCompatibleDC(window_dc)
if not memory_dc:
user32.ReleaseDC(hwnd, window_dc)
raise RuntimeError("CreateCompatibleDC failed")
bitmap = gdi32.CreateCompatibleBitmap(window_dc, width, height)
if not bitmap:
gdi32.DeleteDC(memory_dc)
user32.ReleaseDC(hwnd, window_dc)
raise RuntimeError("CreateCompatibleBitmap failed")
# A compatible memory DC receives the pixels rendered by PrintWindow. The
# previously selected GDI object must be restored before deleting the bitmap.
previous_bitmap = gdi32.SelectObject(memory_dc, bitmap)
bitmap_selected = True
buffer_size = width * height * 4
pixel_buffer = ctypes.create_string_buffer(buffer_size)
try:
success = user32.PrintWindow(hwnd, memory_dc, PW_RENDERFULLCONTENT)
if not success:
extended_error = kernel32.GetLastError()
print(
f"PrintWindow(2) failed with error {extended_error}; retrying "
"without PW_RENDERFULLCONTENT."
)
success = user32.PrintWindow(hwnd, memory_dc, 0)
if not success:
fallback_error = kernel32.GetLastError()
raise RuntimeError(
"PrintWindow failed in both modes. Run at the same elevation "
f"as WinUtil. Errors: extended={extended_error}, "
f"fallback={fallback_error}."
)
bitmap_info = BITMAPINFO()
bitmap_info.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER)
bitmap_info.bmiHeader.biWidth = width
# Negative height requests a top-down DIB. Positive-height DIBs are stored
# bottom-up and would produce a vertically flipped PNG.
bitmap_info.bmiHeader.biHeight = -height
bitmap_info.bmiHeader.biPlanes = 1
bitmap_info.bmiHeader.biBitCount = 32
bitmap_info.bmiHeader.biCompression = BI_RGB
# GetDIBits requires the source bitmap not to be selected into a DC.
gdi32.SelectObject(memory_dc, previous_bitmap)
bitmap_selected = False
scan_lines = gdi32.GetDIBits(
memory_dc,
bitmap,
0,
height,
pixel_buffer,
ctypes.byref(bitmap_info),
DIB_RGB_COLORS,
)
if scan_lines != height:
raise RuntimeError(
f"GetDIBits returned {scan_lines} of {height} expected scan lines."
)
finally:
# GDI handles are process-global and are not reclaimed promptly by Python.
# Always release them, including when PrintWindow or GetDIBits fails.
if bitmap_selected:
gdi32.SelectObject(memory_dc, previous_bitmap)
gdi32.DeleteObject(bitmap)
gdi32.DeleteDC(memory_dc)
user32.ReleaseDC(hwnd, window_dc)
# GDI writes BGR color bytes, but BI_RGB does not define the fourth byte as
# alpha. Decode the channel order, then make the window capture fully opaque.
image = Image.frombytes(
"RGBA",
(width, height),
pixel_buffer.raw,
"raw",
"BGRA",
)
image.putalpha(255)
# PrintWindow can report success while returning a blank surface across an
# integrity-level boundary, so reject all-black and all-white captures.
extrema = image.getextrema()
if all(channel[1] == 0 for channel in extrema[:3]):
raise ValueError("Captured image is completely black")
if all(channel[0] == 255 for channel in extrema[:3]):
raise ValueError("Captured image is completely white")
image.save(output_path, "PNG")
print(f"Verified and saved: {output_path} ({width}x{height})")
return image
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Capture the visible WinUtil window.")
parser.add_argument("output", type=Path, help="Destination PNG path")
return parser.parse_args()
def main() -> None:
args = _parse_args()
hwnd, width, height = find_winutil_hwnd()
capture_window(hwnd, width, height, args.output)
if __name__ == "__main__":
main()
+180
View File
@@ -0,0 +1,180 @@
"""Create WinUtil's framed diagonal Light and Dark title-screen image.
WinUtil captures include a narrow near-black DWM shadow around the actual WPF
window. The compositor detects that shadow from the Dark capture, applies the
same crop to both themes, and blends them with a supersampled diagonal mask.
Only the final composite is written; the cropped and framed theme images remain
in memory.
"""
from __future__ import annotations
import argparse
from pathlib import Path
from PIL import Image, ImageDraw
BORDER_COLOR = (180, 180, 180, 255)
BORDER_WIDTH = 2
# DWM shadow pixels are nearly black. WinUtil's actual Dark theme remains above
# this threshold at the window edges used by the bounds scan.
BRIGHTNESS_THRESHOLD = 20
LARGE_SHADOW_MARGIN = 30
MASK_SCALE = 4
# These endpoints keep the split clear of the left tab labels near the top and
# the right-side action controls near the bottom of the Tweaks layout.
TOP_SPLIT = 0.16
BOTTOM_SPLIT = 0.86
def _contains_content(pixel) -> bool:
"""Return whether a pixel is brighter than the near-black DWM shadow."""
return any(channel > BRIGHTNESS_THRESHOLD for channel in pixel[:3])
def _find_content_bounds(image: Image.Image) -> tuple[int, int, int, int]:
"""Return the non-shadow bounds detected from a Dark theme capture.
Every edge coordinate is checked because Windows 11 shadows can be narrower
than ten pixels. Sampling at a fixed interval could skip the first content
pixel and trim real UI from the final image.
"""
width, height = image.size
pixels = image.load()
left = next(
(
x
for x in range(width)
if any(_contains_content(pixels[x, y]) for y in range(height))
),
0,
)
right = next(
(
x
for x in range(width - 1, -1, -1)
if any(_contains_content(pixels[x, y]) for y in range(height))
),
width - 1,
)
top = next(
(
y
for y in range(height)
if any(_contains_content(pixels[x, y]) for x in range(width))
),
0,
)
bottom = next(
(
y
for y in range(height - 1, -1, -1)
if any(_contains_content(pixels[x, y]) for x in range(width))
),
height - 1,
)
right_margin = width - right - 1
bottom_margin = height - bottom - 1
margins = (left, top, right_margin, bottom_margin)
if any(margin > LARGE_SHADOW_MARGIN for margin in margins):
print(
"Warning: unusually large shadow margins detected "
f"(left={left}, top={top}, right={right_margin}, "
f"bottom={bottom_margin}). Input images may already be cropped or "
"framed."
)
return left, top, right + 1, bottom + 1
def _apply_frame(image: Image.Image) -> Image.Image:
"""Return a copy with the title-screen border drawn inside its bounds."""
framed = image.copy()
width, height = framed.size
draw = ImageDraw.Draw(framed)
for inset in range(BORDER_WIDTH):
draw.rectangle(
[inset, inset, width - 1 - inset, height - 1 - inset],
outline=BORDER_COLOR,
)
return framed
def create_composite(
dark_path: str | Path,
light_path: str | Path,
output_path: str | Path,
) -> Path:
"""Create one framed diagonal composite from matching theme captures."""
dark_path = Path(dark_path)
light_path = Path(light_path)
output_path = Path(output_path)
with Image.open(dark_path) as dark_source:
dark_image = dark_source.convert("RGBA")
with Image.open(light_path) as light_source:
light_image = light_source.convert("RGBA")
if dark_image.size != light_image.size:
raise ValueError(
"Image dimensions do not match: "
f"Dark is {dark_image.size}, Light is {light_image.size}"
)
crop_box = _find_content_bounds(dark_image)
print(f"Content crop box (stripping DWM shadow): {crop_box}")
dark_framed = _apply_frame(dark_image.crop(crop_box))
light_framed = _apply_frame(light_image.crop(crop_box))
width, height = dark_framed.size
# Drawing the mask at four times the target resolution and downsampling it
# with Lanczos produces a smooth diagonal without softening either UI image.
mask_width = width * MASK_SCALE
mask_height = height * MASK_SCALE
mask_high_resolution = Image.new("L", (mask_width, mask_height), 0)
draw_mask = ImageDraw.Draw(mask_high_resolution)
draw_mask.polygon(
[
(0, 0),
(TOP_SPLIT * mask_width, 0),
(BOTTOM_SPLIT * mask_width, mask_height),
(0, mask_height),
],
fill=255,
)
mask = mask_high_resolution.resize(
(width, height),
resample=Image.Resampling.LANCZOS,
)
# A white mask selects Light, so Light occupies the left side and Dark the
# right. Reapplying the frame removes any mask antialiasing at the outer edge.
composite = Image.composite(light_framed, dark_framed, mask)
composite = _apply_frame(composite)
composite.save(output_path, "PNG")
print(f"Generated title-screen composite: {output_path} ({width}x{height})")
return output_path
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Create WinUtil's diagonal Light and Dark title-screen image."
)
parser.add_argument("dark", type=Path, help="Raw Dark theme PNG")
parser.add_argument("light", type=Path, help="Raw Light theme PNG")
parser.add_argument("output", type=Path, help="Destination composite PNG")
return parser.parse_args()
def main() -> None:
args = _parse_args()
create_composite(args.dark, args.light, args.output)
if __name__ == "__main__":
main()
+135
View File
@@ -0,0 +1,135 @@
"""Write WinUtil window and UI Automation diagnostics to an explicit path.
The regular tree does not contain WPF popup-menu entries until the popup is
open. This inspector therefore records the desktop and main window first, then
clicks ``ThemeButton`` and records both the refreshed window tree and any
same-process popup windows. It must run at the same integrity level as WinUtil.
"""
from __future__ import annotations
import argparse
import contextlib
import time
from pathlib import Path
from pywinauto import Desktop
from pywinauto.application import Application
from capture_winutil import find_winutil_hwnd
def dump_tree(element, depth: int = 0, max_depth: int = 6) -> None:
"""Print a bounded UI Automation subtree without failing on stale elements."""
if depth > max_depth:
return
indent = " " * depth
try:
control_type = element.element_info.control_type
name = element.element_info.name or ""
automation_id = element.element_info.automation_id or ""
class_name = element.element_info.class_name or ""
print(
f"{indent}[{control_type}] name={name!r:40s} "
f"auto_id={automation_id!r:30s} class={class_name!r}"
)
except Exception as exc:
print(f"{indent}<element inspection failed: {exc}>")
return
try:
children = element.children()
except Exception as exc:
print(f"{indent}<child enumeration failed: {exc}>")
return
for child in children:
dump_tree(child, depth + 1, max_depth)
def inspect_winutil() -> None:
"""Print desktop, WinUtil, and transient theme-menu diagnostics."""
print("=== All visible top-level windows (win32 backend) ===")
for window in Desktop(backend="win32").windows():
try:
if window.is_visible():
print(
f" title={window.window_text()!r:50s} "
f"class={window.class_name()!r}"
)
except Exception as exc:
print(f" <window inspection failed: {exc}>")
print("\n=== Looking for WinUtil (shared strict lookup) ===")
winutil_hwnd, _, _ = find_winutil_hwnd()
print("\n=== Control tree via UIA backend ===")
try:
app = Application(backend="uia").connect(handle=winutil_hwnd)
window = app.window(handle=winutil_hwnd)
dump_tree(window)
print("\n=== Opening theme popup and dumping its UIA tree ===")
theme_button = window.child_window(
auto_id="ThemeButton",
control_type="Button",
).wrapper_object()
window.set_focus()
theme_button.click_input()
time.sleep(0.5)
print("\n=== Main WinUtil UIA tree with theme popup open ===")
dump_tree(window)
# WPF may expose the menu as a separate top-level popup or attach it to
# the main tree depending on framework and rendering state. Record both.
popup_windows = [
candidate
for candidate in app.windows(visible_only=True)
if candidate.handle != winutil_hwnd
]
if not popup_windows:
print("No separate same-process popup window was found.")
for popup in popup_windows:
print(
f"Popup HWND={hex(popup.handle)} "
f"title={popup.window_text()!r} class={popup.class_name()!r}"
)
dump_tree(popup)
except Exception as exc:
raise RuntimeError(
"UI Automation inspection failed. Run at the same elevation level "
"as WinUtil."
) from exc
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Write WinUtil UI Automation diagnostics."
)
parser.add_argument("output", type=Path, help="Destination text file")
return parser.parse_args()
def main() -> None:
args = _parse_args()
failure = None
# Preserve diagnostics even when discovery or UIA access fails, then return a
# non-zero exit so local callers and CI do not mistake the dump for success.
with args.output.open("w", encoding="utf-8") as output_file:
with contextlib.redirect_stdout(output_file):
try:
inspect_winutil()
except Exception as exc:
print(f"\nInspection failed: {exc}")
if exc.__cause__:
print(f"Cause: {exc.__cause__}")
failure = exc
print(f"Diagnostics written to {args.output}")
if failure:
raise failure
if __name__ == "__main__":
main()
+12
View File
@@ -0,0 +1,12 @@
[project]
name = "winutil-title-screen"
version = "0.1.0"
description = "Generate WinUtil's Light and Dark title-screen composite"
requires-python = ">=3.13"
dependencies = [
"pillow",
"pywinauto",
]
[tool.uv]
package = false
@@ -0,0 +1,19 @@
"""Non-GUI tests for title-screen automation validation helpers."""
import unittest
from PIL import Image
from automate_title_screen import is_dark_mode
class AutomateTitleScreenTests(unittest.TestCase):
def test_classifies_dark_and_light_captures(self):
dark_image = Image.new("RGB", (100, 100), (35, 35, 35))
light_image = Image.new("RGB", (100, 100), (240, 240, 240))
self.assertTrue(is_dark_mode(dark_image))
self.assertFalse(is_dark_mode(light_image))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,45 @@
"""Smoke tests for the WinUtil title-screen compositor."""
import tempfile
import unittest
from pathlib import Path
from PIL import Image, ImageDraw
from create_composite import BORDER_COLOR, create_composite
class CreateCompositeTests(unittest.TestCase):
def test_creates_only_requested_composite(self):
with tempfile.TemporaryDirectory(prefix="winutil-composite-test-") as temp_dir:
work_dir = Path(temp_dir)
dark_path = work_dir / "dark.png"
light_path = work_dir / "light.png"
output_path = work_dir / "title-screen.png"
self._create_capture(dark_path, (32, 34, 36, 255))
self._create_capture(light_path, (235, 237, 239, 255))
result = create_composite(dark_path, light_path, output_path)
self.assertEqual(result, output_path)
self.assertEqual(
{path.name for path in work_dir.glob("*.png")},
{"dark.png", "light.png", "title-screen.png"},
)
with Image.open(output_path) as composite:
self.assertEqual(composite.size, (64, 40))
self.assertEqual(composite.getpixel((0, 0)), BORDER_COLOR)
self.assertEqual(composite.getpixel((5, 20)), (235, 237, 239, 255))
self.assertEqual(composite.getpixel((58, 20)), (32, 34, 36, 255))
@staticmethod
def _create_capture(path: Path, content_color: tuple[int, int, int, int]):
image = Image.new("RGBA", (70, 46), (0, 0, 0, 255))
draw = ImageDraw.Draw(image)
draw.rectangle((3, 3, 66, 42), fill=content_color)
image.save(path, "PNG")
if __name__ == "__main__":
unittest.main()
+141
View File
@@ -0,0 +1,141 @@
version = 1
revision = 3
requires-python = ">=3.13"
[[package]]
name = "comtypes"
version = "1.4.16"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c6/2a/65274c13327f637ec13af8d39f2cf579d9ebe7a0e683696b5f05236d2805/comtypes-1.4.16.tar.gz", hash = "sha256:cd66d1add01265cface4df51ba1e31cd1657e04463c281c802e737e79e1ba93c", size = 260252, upload-time = "2026-03-02T23:11:42.413Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/7c/0eb685107290b6221c03c46d39214a4e42a124189691cb83ae3228257f46/comtypes-1.4.16-py3-none-any.whl", hash = "sha256:e18d85179ff12955524c5a8c3bc09cb3c0d890f1da4d7123d14244c7b78f84c8", size = 296230, upload-time = "2026-03-02T23:11:41.049Z" },
]
[[package]]
name = "pillow"
version = "12.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" },
{ url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" },
{ url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" },
{ url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" },
{ url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" },
{ url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" },
{ url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" },
{ url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" },
{ url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" },
{ url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" },
{ url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" },
{ url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" },
{ url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" },
{ url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" },
{ url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" },
{ url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" },
{ url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" },
{ url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" },
{ url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" },
{ url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" },
{ url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" },
{ url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" },
{ url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" },
{ url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" },
{ url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" },
{ url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" },
{ url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" },
{ url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" },
{ url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" },
{ url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" },
{ url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" },
{ url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" },
{ url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" },
{ url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" },
{ url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" },
{ url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" },
{ url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" },
{ url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" },
{ url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" },
{ url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" },
{ url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" },
{ url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" },
{ url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" },
{ url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" },
{ url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" },
{ url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" },
{ url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" },
{ url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" },
{ url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" },
{ url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" },
{ url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" },
{ url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" },
]
[[package]]
name = "python-xlib"
version = "0.33"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/86/f5/8c0653e5bb54e0cbdfe27bf32d41f27bc4e12faa8742778c17f2a71be2c0/python-xlib-0.33.tar.gz", hash = "sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32", size = 269068, upload-time = "2022-12-25T18:53:00.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/b8/ff33610932e0ee81ae7f1269c890f697d56ff74b9f5b2ee5d9b7fa2c5355/python_xlib-0.33-py2.py3-none-any.whl", hash = "sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398", size = 182185, upload-time = "2022-12-25T18:52:58.662Z" },
]
[[package]]
name = "pywin32"
version = "312"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" },
{ url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" },
{ url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" },
{ url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" },
{ url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" },
{ url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" },
{ url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" },
{ url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" },
{ url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" },
]
[[package]]
name = "pywinauto"
version = "0.6.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "comtypes", marker = "sys_platform == 'win32'" },
{ name = "python-xlib", marker = "sys_platform == 'linux'" },
{ name = "pywin32", marker = "sys_platform == 'win32'" },
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2c/85/cc65e3b64e7473cc86c07f0b5c415d509402c03ef19dadc39c583835eb5f/pywinauto-0.6.9.tar.gz", hash = "sha256:94d710bfa796df245250f952ffa65d97233d9807bcd42e052b71b81af469b6de", size = 434734, upload-time = "2025-01-06T11:30:01.801Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/44/46/f2283648e0c237af451dc50ebc42121abfd198d12391fd9ad4df1c6b97d2/pywinauto-0.6.9-py2.py3-none-any.whl", hash = "sha256:5924b3072864a1d730c5546bbeb17cf4063ba518b618dbc5e43c18276c7c9356", size = 363041, upload-time = "2025-01-06T11:29:55.586Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "winutil-title-screen"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "pillow" },
{ name = "pywinauto" },
]
[package.metadata]
requires-dist = [
{ name = "pillow" },
{ name = "pywinauto" },
]