A Debian image (python:3.13-slim-bookworm with Xvfb and GNOME's window manager) runs the linters, the whole test suite, the screenshots and the smoke test of a PyInstaller Linux binary and of the source program, so no window opens on the development machine. The screenshot tool works on Linux, a smoke driver presses the known button with the virtual mouse, and Windows Sandbox scripts are written (not run, they open a window on the host). README and the wiki Install pages describe it. Review records RC-077 (Python) and RC-078 (shell). Task: MIL-013#2 Task: MIL-013#3 Task: MIL-013#4 Task: MIL-013#5 Task: MIL-013#6 Task: MIL-013#7 Task: MIL-013#9 Task: MIL-013#10
197 lines
6.9 KiB
Python
197 lines
6.9 KiB
Python
"""Capture screenshots of the running flash card window.
|
|
|
|
@file capture_screenshots.py
|
|
@brief Starts the program, grabs the level screen, the front of a card and,
|
|
three seconds later, the back, and saves them as PNG files.
|
|
|
|
The tool needs Pillow (`python -m pip install -e ".[tools]"`), which is a tool
|
|
dependency only and not a dependency of the program. It grabs the screen area
|
|
of the window, so it needs a visible desktop.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import ctypes
|
|
import os
|
|
import random
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import tkinter as tk
|
|
from collections.abc import Sequence
|
|
from pathlib import Path
|
|
|
|
from PIL import Image, ImageGrab
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
|
|
from flash_card.constants import ( # noqa: E402
|
|
DEFAULT_DATA_DIR,
|
|
FLIP_DELAY_MS,
|
|
PROJECT_ROOT,
|
|
VOCABULARY_FILE_NAME,
|
|
)
|
|
from flash_card.domain.value_objects.level import Level # noqa: E402
|
|
from flash_card.presentation.app import create_application # noqa: E402
|
|
from flash_card.presentation.features.flash_card.flash_card_view import ( # noqa: E402
|
|
FlashCardView,
|
|
)
|
|
|
|
## @brief Default folder of the screenshots.
|
|
DEFAULT_OUTPUT_DIR = PROJECT_ROOT / "docs" / "images"
|
|
## @brief Seed that makes the first card the same on every run.
|
|
SCREENSHOT_SEED = 7
|
|
## @brief Extra time in seconds to let the window settle before a grab.
|
|
SETTLE_SECONDS = 0.5
|
|
## @brief Height in pixels of the menu bar above the client area on X11.
|
|
MENU_BAR_HEIGHT_X11 = 28
|
|
## @brief Platform kind of Windows.
|
|
KIND_WINDOWS = "windows"
|
|
## @brief Platform kind of every other platform (an X11 display).
|
|
KIND_X11 = "x11"
|
|
|
|
|
|
def platform_kind(platform: str) -> str:
|
|
"""Choose how to grab the screen.
|
|
|
|
@param platform A `sys.platform` value.
|
|
@return "windows" for win32, otherwise "x11".
|
|
"""
|
|
return KIND_WINDOWS if platform == "win32" else KIND_X11
|
|
|
|
|
|
def window_box(
|
|
kind: str, rootx: int, rooty: int, width: int, height: int, menu_height: int
|
|
) -> tuple[int, int, int, int]:
|
|
"""Return the screen box to grab for a window.
|
|
|
|
@param kind The platform kind from `platform_kind`.
|
|
@param rootx Left edge of the client area.
|
|
@param rooty Top edge of the client area.
|
|
@param width Width of the client area.
|
|
@param height Height of the client area.
|
|
@param menu_height Height of the menu bar above the client area; it is
|
|
added on X11 only, as on Windows the frame box is read from the system.
|
|
@return Left, top, right and bottom in screen pixels.
|
|
"""
|
|
top = rooty - menu_height if kind == KIND_X11 else rooty
|
|
return rootx, top, rootx + width, rooty + height
|
|
|
|
|
|
def _grab(box: tuple[int, int, int, int]) -> Image.Image:
|
|
"""Grab a screen box on the current platform."""
|
|
if platform_kind(sys.platform) == KIND_X11:
|
|
return ImageGrab.grab(bbox=box, xdisplay=os.environ.get("DISPLAY"))
|
|
return ImageGrab.grab(bbox=box)
|
|
|
|
|
|
def _make_dpi_aware() -> None:
|
|
"""Make Windows report real pixel coordinates (no effect elsewhere)."""
|
|
if sys.platform == "win32":
|
|
ctypes.windll.shcore.SetProcessDpiAwareness(2) # type: ignore[attr-defined,unused-ignore]
|
|
|
|
|
|
def _frame_box(root: tk.Tk) -> tuple[int, int, int, int]:
|
|
"""Return the screen box of the whole window, with title bar and menu bar.
|
|
|
|
@param root The Tk root window.
|
|
@return Left, top, right and bottom in screen pixels.
|
|
"""
|
|
import ctypes.wintypes # Windows only, so it is imported here
|
|
|
|
handle = ctypes.windll.user32.GetParent(root.winfo_id()) # type: ignore[attr-defined,unused-ignore]
|
|
rect = ctypes.wintypes.RECT()
|
|
ctypes.windll.user32.GetWindowRect(handle, ctypes.byref(rect)) # type: ignore[attr-defined,unused-ignore]
|
|
# Windows 11 adds an invisible resize border of about 7 pixels at the left,
|
|
# the right and the bottom; cut it so no other window is in the picture.
|
|
border = 7
|
|
return rect.left + border, rect.top, rect.right - border, rect.bottom - border
|
|
|
|
|
|
def _pump(root: object, seconds: float) -> None:
|
|
"""Run the Tk event loop for a while.
|
|
|
|
@param root The Tk root window.
|
|
@param seconds How long to run.
|
|
"""
|
|
end = time.monotonic() + seconds
|
|
while time.monotonic() < end:
|
|
root.update() # type: ignore[attr-defined]
|
|
time.sleep(0.01)
|
|
|
|
|
|
def capture(output_dir: Path) -> list[Path]:
|
|
"""Capture the level screen, then the front and the back of one card.
|
|
|
|
@param output_dir Folder to write `screenshot-level.png`,
|
|
`screenshot-front.png`, `screenshot-back.png` and `screenshot-menu.png`
|
|
(the whole window with its menu bar) into.
|
|
@return The four files written: level, front, back, menu.
|
|
"""
|
|
_make_dpi_aware()
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
random.seed(SCREENSHOT_SEED)
|
|
level = output_dir / "screenshot-level.png"
|
|
menu = output_dir / "screenshot-menu.png"
|
|
front = output_dir / "screenshot-front.png"
|
|
back = output_dir / "screenshot-back.png"
|
|
with tempfile.TemporaryDirectory() as data_dir:
|
|
shutil.copy(DEFAULT_DATA_DIR / VOCABULARY_FILE_NAME, data_dir)
|
|
application = create_application(Path(data_dir), speech_enabled=False)
|
|
root = application.root
|
|
root.lift()
|
|
root.attributes("-topmost", True)
|
|
_pump(root, SETTLE_SECONDS)
|
|
kind = platform_kind(sys.platform)
|
|
client = (
|
|
root.winfo_rootx(),
|
|
root.winfo_rooty(),
|
|
root.winfo_width(),
|
|
root.winfo_height(),
|
|
)
|
|
_grab(window_box(kind, *client, 0)).save(level)
|
|
application.start(Level(None))
|
|
view = application.view
|
|
if not isinstance(view, FlashCardView):
|
|
raise RuntimeError("the card window did not open")
|
|
_pump(root, SETTLE_SECONDS)
|
|
box = (
|
|
view.canvas.winfo_rootx() - 20,
|
|
view.canvas.winfo_rooty() - 20,
|
|
view.canvas.winfo_rootx() + view.canvas.winfo_width() + 20,
|
|
view.canvas.winfo_rooty()
|
|
+ view.canvas.winfo_height()
|
|
+ view.right_button.winfo_height()
|
|
+ 20,
|
|
)
|
|
_grab(box).save(front)
|
|
if kind == KIND_WINDOWS:
|
|
menu_box = _frame_box(root)
|
|
else:
|
|
menu_box = window_box(kind, *client, MENU_BAR_HEIGHT_X11)
|
|
_grab(menu_box).save(menu)
|
|
_pump(root, FLIP_DELAY_MS / 1000 + SETTLE_SECONDS)
|
|
_grab(box).save(back)
|
|
root.destroy()
|
|
return [level, front, back, menu]
|
|
|
|
|
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
"""Run the tool.
|
|
|
|
@param argv Command line arguments, or None for `sys.argv`.
|
|
@return The exit code, 0 on success.
|
|
"""
|
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR)
|
|
args = parser.parse_args(argv)
|
|
for path in capture(args.output_dir):
|
|
sys.stdout.write(f"{path}\n")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|