Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
Binary file modified asset/sim-image-web.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
27 changes: 25 additions & 2 deletions matrixbox_simulator/device/run_screenshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -257,6 +272,8 @@ def _write_web_screenshot(
str(height),
"--led-size",
str(scale),
"--theme",
theme,
"--output",
str(path),
],
Expand Down Expand Up @@ -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(
Expand Down
17 changes: 13 additions & 4 deletions matrixbox_simulator/web/capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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__":
Expand Down
28 changes: 24 additions & 4 deletions matrixbox_simulator/web/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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();
}
Expand Down
9 changes: 5 additions & 4 deletions matrixbox_simulator/web/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@
<button type="button" data-view="panel">Panel</button>
<button type="button" data-view="device">Device</button>
</div>
<label class="switch">
<input id="seams" type="checkbox">
<span>Seams</span>
</label>
<div class="segmented" role="group" aria-label="Theme">
<button type="button" data-theme-choice="auto">Auto</button>
<button type="button" data-theme-choice="light">Light</button>
<button type="button" data-theme-choice="dark">Dark</button>
</div>
</div>
</header>

Expand Down
66 changes: 46 additions & 20 deletions matrixbox_simulator/web/static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,60 @@
--live: #2f9e5b;
--offline: #b54848;
--floor-shadow: rgba(0, 0, 0, 0.45);
--cable-shade: #b3b3ba;
--cable-body: #e2e2e7;
--cable-highlight: #ffffff;
--plug-end: #e9e9ed;
--plug-upper: linear-gradient(90deg, #dcdce1, #ffffff);
--plug-side: linear-gradient(90deg, #c9c9cf, #f4f4f6);
--plug-lower: linear-gradient(90deg, #9d9da5, #c2c2c8);
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
--sans: system-ui, -apple-system, "Segoe UI", sans-serif;
}

/* Dark follows the OS unless the page's own theme toggle picked one;
the same tokens again for an explicit dark pick. */
@media (prefers-color-scheme: dark) {
:root {
:root:not([data-theme="light"]) {
--page: #1d1d22;
--page-edge: #0c0c0f;
--ink: #e6e6ea;
--ink-muted: #8d8d98;
--chrome: rgba(30, 30, 36, 0.75);
--chrome-border: rgba(255, 255, 255, 0.08);
--floor-shadow: rgba(0, 0, 0, 0.8);
--cable-shade: #050506;
--cable-body: #1c1c20;
--cable-highlight: #45454c;
--plug-end: #26262b;
--plug-upper: linear-gradient(90deg, #2a2a30, #3c3c43);
--plug-side: linear-gradient(90deg, #17171a, #2a2a2f);
--plug-lower: linear-gradient(90deg, #0b0b0d, #17171a);
}
}

:root[data-theme="dark"] {
color-scheme: dark;
--page: #1d1d22;
--page-edge: #0c0c0f;
--ink: #e6e6ea;
--ink-muted: #8d8d98;
--chrome: rgba(30, 30, 36, 0.75);
--chrome-border: rgba(255, 255, 255, 0.08);
--floor-shadow: rgba(0, 0, 0, 0.8);
--cable-shade: #050506;
--cable-body: #1c1c20;
--cable-highlight: #45454c;
--plug-end: #26262b;
--plug-upper: linear-gradient(90deg, #2a2a30, #3c3c43);
--plug-side: linear-gradient(90deg, #17171a, #2a2a2f);
--plug-lower: linear-gradient(90deg, #0b0b0d, #17171a);
}

:root[data-theme="light"] {
color-scheme: light;
}

* {
box-sizing: border-box;
}
Expand Down Expand Up @@ -127,18 +165,6 @@ body.capture-panel {
color: #fff;
}

.switch {
display: inline-flex;
gap: 6px;
align-items: center;
color: var(--ink-muted);
cursor: pointer;
}

.switch input {
accent-color: var(--accent);
}

.stage {
position: relative;
min-height: 0;
Expand Down Expand Up @@ -360,20 +386,20 @@ body.capture-panel {

.plug-side {
position: absolute;
background: linear-gradient(90deg, #c9c9cf, #f4f4f6);
background: var(--plug-side);
}

.plug-side.end {
border-radius: calc(var(--led) * 0.6);
background: #e9e9ed;
background: var(--plug-end);
}

.plug-side.upper {
background: linear-gradient(90deg, #dcdce1, #ffffff);
background: var(--plug-upper);
}

.plug-side.lower {
background: linear-gradient(90deg, #9d9da5, #c2c2c8);
background: var(--plug-lower);
}

.cable-anchor {
Expand Down Expand Up @@ -404,15 +430,15 @@ body.capture-panel {
}

.cable-shade {
stroke: #b3b3ba;
stroke: var(--cable-shade);
}

.cable-body {
stroke: #e2e2e7;
stroke: var(--cable-body);
}

.cable-highlight {
stroke: #ffffff;
stroke: var(--cable-highlight);
}

.footer {
Expand Down
15 changes: 12 additions & 3 deletions tests/test_web_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ def lit_frame(width: int, height: int) -> wire.Frame:
return wire.Frame(width=width, height=height, pixels=bytes(pixels))


def capture(style: str, tmp_path: Path) -> Image.Image:
output = tmp_path / f"{style}.png"
def capture(style: str, tmp_path: Path, theme: str = "dark") -> Image.Image:
output = tmp_path / f"{style}-{theme}.png"
try:
WebCapture(lit_frame(64, 32), style, led_size=4).save(output)
WebCapture(lit_frame(64, 32), style, led_size=4, theme=theme).save(output)
except SystemExit as exit_error:
pytest.skip(f"no headless browser available: {exit_error}")

Expand All @@ -47,6 +47,15 @@ def test_device_style_crops_to_the_device(tmp_path: Path) -> None:
assert 32 * 4 < height < (32 + 90) * 4


def test_device_backdrop_follows_the_theme(tmp_path: Path) -> None:
dark = capture("device", tmp_path, theme="dark").convert("L")
light = capture("device", tmp_path, theme="light").convert("L")

corner = (2, 2, 3, 3)
assert dark.crop(corner).tobytes()[0] < 60
assert light.crop(corner).tobytes()[0] > 180


def test_screenshot_defaults_to_raw_pixels() -> None:
args = run_screenshot.build_parser().parse_args(["clock"])

Expand Down
Loading