diff --git a/README.md b/README.md index 35db2f3..55b0e6d 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,9 @@ when an app only checks the button now and then. Only for the browser page itself: - `v`: switch between the flat panel and the device mockup. +- Auto / Light / Dark in the toolbar: follow the OS theme, or pin one. + Remembered per browser, and `?theme=light` or `?theme=dark` in the + URL works too. - `t`: toggle a guide line at each panel seam (see "Panel sizes"). Off by default. - Drag the device to rotate it, double-click to reset the angle. @@ -239,7 +242,9 @@ matrixbox screenshot /path/to/matrixbox/apps/clock --style device -o clock.png browser, so they need [Playwright][playwright] (`pip install 'matrixbox-simulator[screenshot]'`). An installed Chrome is used if there is one, otherwise run `playwright install chromium` once. -For these, `--scale` is the size of one LED in pixels. +For these, `--scale` is the size of one LED in pixels, and +`--theme {dark,light}` picks the backdrop and cable color behind the +device (default dark). ## Sandbox diff --git a/asset/sim-image-web.png b/asset/sim-image-web.png index 50d7f6f..fbd2930 100644 Binary files a/asset/sim-image-web.png and b/asset/sim-image-web.png differ diff --git a/matrixbox_simulator/device/run_screenshot.py b/matrixbox_simulator/device/run_screenshot.py index ee2a274..e18e9ac 100644 --- a/matrixbox_simulator/device/run_screenshot.py +++ b/matrixbox_simulator/device/run_screenshot.py @@ -28,6 +28,7 @@ from matrixbox_simulator.sizes import ROTATION_OVERRIDES, SIZE_PRESETS SCREENSHOT_STYLES = ("pixels", "panel", "device") +SCREENSHOT_THEMES = ("dark", "light") def build_parser( @@ -98,6 +99,13 @@ def build_parser( "LEDs in a dark housing, like the web renderer. device: the web " "renderer's 3D device mockup. panel and device need Playwright", ) + parser.add_argument( + "--theme", + choices=SCREENSHOT_THEMES, + default="dark", + help="page theme for --style device, the backdrop and cable color " + "(default dark)", + ) parser.add_argument( "-o", "--output", @@ -237,7 +245,14 @@ def _write_screenshot( def _write_web_screenshot( - path: Path, width: int, height: int, rgb: bytes, *, style: str, scale: int + path: Path, + width: int, + height: int, + rgb: bytes, + *, + style: str, + scale: int, + theme: str, ) -> None: # A separate, unsandboxed process: this one has open()/stat() and # friends redirected into the app's sandbox, which a browser driver @@ -257,6 +272,8 @@ def _write_web_screenshot( str(height), "--led-size", str(scale), + "--theme", + theme, "--output", str(path), ], @@ -403,7 +420,13 @@ def run_app_code() -> None: _write_screenshot(output, width, height, rgb, scale=args.scale) else: _write_web_screenshot( - output, width, height, rgb, style=args.style, scale=args.scale + output, + width, + height, + rgb, + style=args.style, + scale=args.scale, + theme=args.theme, ) print( diff --git a/matrixbox_simulator/web/capture.py b/matrixbox_simulator/web/capture.py index eec5494..929cbf1 100644 --- a/matrixbox_simulator/web/capture.py +++ b/matrixbox_simulator/web/capture.py @@ -25,6 +25,7 @@ sync_playwright = None STYLES = ("panel", "device") +THEMES = ("dark", "light") READY_TIMEOUT_MS = 15000 # Room around the panel or device; the page crops to it anyway, this @@ -33,10 +34,13 @@ class WebCapture: - def __init__(self, frame: wire.Frame, style: str, led_size: int) -> None: + def __init__( + self, frame: wire.Frame, style: str, led_size: int, theme: str = "dark" + ) -> None: self.frame = frame self.style = style self.led_size = led_size + self.theme = theme def save(self, output: Path) -> None: server = WebServer("127.0.0.1", 0) @@ -81,9 +85,13 @@ def _screenshot(self, base_url: str, output: Path) -> None: try: width, height = self.viewport_size() page = browser.new_page( - viewport={"width": width, "height": height}, color_scheme="dark" + viewport={"width": width, "height": height}, + color_scheme="light" if self.theme == "light" else "dark", + ) + page.goto( + f"{base_url}?view={self.style}&theme={self.theme}" + f"&capture=1&led={self.led_size}" ) - page.goto(f"{base_url}?view={self.style}&capture=1&led={self.led_size}") page.wait_for_selector("body[data-ready]", timeout=READY_TIMEOUT_MS) clip = page.evaluate("window.matrixboxCaptureBounds()") output.parent.mkdir(parents=True, exist_ok=True) @@ -102,6 +110,7 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--width", type=int, required=True) parser.add_argument("--height", type=int, required=True) parser.add_argument("--led-size", type=int, default=8) + parser.add_argument("--theme", choices=THEMES, default="dark") parser.add_argument("-o", "--output", required=True) return parser @@ -121,7 +130,7 @@ def main() -> None: pixels=rgb, tiles=panel_count_for(args.width, args.height), ) - WebCapture(frame, args.style, args.led_size).save(Path(args.output)) + WebCapture(frame, args.style, args.led_size, args.theme).save(Path(args.output)) if __name__ == "__main__": diff --git a/matrixbox_simulator/web/static/app.js b/matrixbox_simulator/web/static/app.js index f800ddc..10dca18 100644 --- a/matrixbox_simulator/web/static/app.js +++ b/matrixbox_simulator/web/static/app.js @@ -701,18 +701,18 @@ class SimulatorPage { this.deviceScreen = document.getElementById("device-screen"); this.connection = document.getElementById("connection"); this.stats = document.getElementById("stats"); - this.seamsToggle = document.getElementById("seams"); this.viewButtons = document.querySelectorAll("[data-view]"); + this.themeButtons = document.querySelectorAll("[data-theme-choice]"); this.drawPending = false; this.lastFrameSize = ""; const params = new URLSearchParams(window.location.search); this.view = params.get("view") ?? this.preferences.read("view", "panel"); + this.setTheme(params.get("theme") ?? this.preferences.read("theme", "auto"), { remember: false }); // Screenshot mode: just the panel or device at a fixed LED size, no // page chrome, flagging when it's safe to capture. this.capture = params.get("capture") === "1" ? { ledCssSize: Number(params.get("led")) || 8 } : null; this.renderer.showSeams = this.preferences.read("seams", "off") === "on"; - this.seamsToggle.checked = this.renderer.showSeams; this.renderAppControls(); this.bindControls(); this.setView(this.view); @@ -785,7 +785,10 @@ class SimulatorPage { button.addEventListener("click", () => this.setView(button.dataset.view)); } - this.seamsToggle.addEventListener("change", () => this.setSeams(this.seamsToggle.checked)); + for (const button of this.themeButtons) { + button.addEventListener("click", () => this.setTheme(button.dataset.themeChoice)); + } + window.addEventListener("resize", () => this.relayout()); window.addEventListener("keydown", (event) => { if (event.metaKey || event.ctrlKey || event.altKey) { @@ -822,9 +825,26 @@ class SimulatorPage { } } + // "auto" follows the OS; light or dark pins it regardless. + setTheme(theme, { remember = true } = {}) { + const choice = ["light", "dark"].includes(theme) ? theme : "auto"; + if (choice === "auto") { + delete document.documentElement.dataset.theme; + } else { + document.documentElement.dataset.theme = choice; + } + + for (const button of this.themeButtons) { + button.setAttribute("aria-pressed", String(button.dataset.themeChoice === choice)); + } + + if (remember) { + this.preferences.write("theme", choice); + } + } + setSeams(enabled) { this.renderer.showSeams = enabled; - this.seamsToggle.checked = enabled; this.preferences.write("seams", enabled ? "on" : "off"); this.scheduleDraw(); } diff --git a/matrixbox_simulator/web/static/index.html b/matrixbox_simulator/web/static/index.html index 4834a14..fe3f81c 100644 --- a/matrixbox_simulator/web/static/index.html +++ b/matrixbox_simulator/web/static/index.html @@ -17,10 +17,11 @@ - +