mirror of
https://github.com/ChrisTitusTech/winutil.git
synced 2026-10-08 11:39:10 +11:00
* 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
253 lines
8.0 KiB
Python
253 lines
8.0 KiB
Python
"""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()
|