mirror of
https://github.com/ChrisTitusTech/winutil.git
synced 2026-10-08 11:39:10 +11:00
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:
@@ -0,0 +1 @@
|
||||
3.13
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Generated
+141
@@ -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" },
|
||||
]
|
||||
Reference in New Issue
Block a user