- rust-toolchain sets CARGO_HOME to the Windows userprofile path while the msys2 shell's HOME is elsewhere — the yaml-cpp patch targeted an empty directory and the assertion ls failed - examples/screenshot.rs uses the macOS-only VisualTestAppContext; its items are now cfg-gated with a non-macOS stub main so workspace test builds pass on Linux/Windows
261 lines
9.8 KiB
Rust
261 lines
9.8 KiB
Rust
// Oak Video Editor - Non-Linear Video Editor
|
||
// Copyright (C) 2026 Oak Team
|
||
//
|
||
// This program is free software: you can redistribute it and/or modify
|
||
// it under the terms of the GNU General Public License as published by
|
||
// the Free Software Foundation, either version 3 of the License, or
|
||
// (at your option) any later version.
|
||
//
|
||
// This program is distributed in the hope that it will be useful,
|
||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
// GNU General Public License for more details.
|
||
//
|
||
// You should have received a copy of the GNU General Public License
|
||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||
|
||
//! Offscreen screenshot capture for the app window.
|
||
//!
|
||
//! Renders the full [`OakApp`] shell at 1600×900 (2× = 3200×1866 px) in an
|
||
//! offscreen macOS window and writes the PNGs to
|
||
//! `docs/screenshot-window.png` (zh-CN) and `docs/screenshot-window-en.png`
|
||
//! (en-US), then opens the project manager (M13 D4) on the same shell and
|
||
//! captures it to `docs/screenshot-manager.png` / `-en.png`, using the same
|
||
//! [`VisualTestAppContext`] machinery the gpui visual tests use. The window
|
||
//! is created at `(-10000, -10000)` so nothing flickers on screen.
|
||
//!
|
||
//! Unlike the real app's startup ([`oakapp::app::run`]) the example must
|
||
//! initialize the i18n layer itself, or every menu renders in the en-US
|
||
//! fallback while the panels fall back to their built-in Chinese defaults.
|
||
//! The active language is captured first and restored at the end, so the
|
||
//! persisted preference is untouched.
|
||
//!
|
||
//! Run it on the macOS main thread (examples run on the main thread, unlike
|
||
//! `#[test]` harness threads):
|
||
//!
|
||
//! ```text
|
||
//! cargo run --example screenshot # 1600×900 → both PNGs
|
||
//! cargo run --example screenshot -- 1100 900 # any size (same filenames)
|
||
//! ```
|
||
|
||
#[cfg(target_os = "macos")]
|
||
use gpui::{px, size, AnyWindowHandle, AppContext, Entity, Result, VisualTestAppContext};
|
||
#[cfg(target_os = "macos")]
|
||
use gpui_platform::current_platform;
|
||
#[cfg(target_os = "macos")]
|
||
use oakapp::app::OakApp;
|
||
#[cfg(target_os = "macos")]
|
||
use oakapp::i18n::{self, Language};
|
||
#[cfg(target_os = "macos")]
|
||
use oakapp::oakui::MockEngine;
|
||
|
||
#[cfg(target_os = "macos")]
|
||
const DEFAULT_WIDTH: f32 = 1600.0;
|
||
#[cfg(target_os = "macos")]
|
||
const DEFAULT_HEIGHT: f32 = 900.0;
|
||
#[cfg(target_os = "macos")]
|
||
const OUT_ZH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/docs/screenshot-window.png");
|
||
#[cfg(target_os = "macos")]
|
||
const OUT_EN: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/docs/screenshot-window-en.png");
|
||
#[cfg(target_os = "macos")]
|
||
const OUT_MGR_ZH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/docs/screenshot-manager.png");
|
||
#[cfg(target_os = "macos")]
|
||
const OUT_MGR_EN: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/docs/screenshot-manager-en.png");
|
||
#[cfg(target_os = "macos")]
|
||
const OUT_PREF_ZH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/docs/screenshot-preferences.png");
|
||
#[cfg(target_os = "macos")]
|
||
const OUT_PREF_EN: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/docs/screenshot-preferences-en.png");
|
||
|
||
/// Logical y of the timeline toolbar row, which sits at the top of the
|
||
/// bottom dock panel in the default layout: the dock starts at y 27.5 (the
|
||
/// menu bar height), the viewers get 60% of the remaining height, and a 6px
|
||
/// split handle separates them from the timeline. The toolbar is its 31px
|
||
/// first row. The assertion scans a small band around it so minor layout
|
||
/// drift does not false-negative.
|
||
#[cfg(target_os = "macos")]
|
||
const TOOLBAR_Y: f32 = 541.0;
|
||
#[cfg(target_os = "macos")]
|
||
const TOOLBAR_BAND: f32 = 44.0;
|
||
|
||
#[cfg(target_os = "macos")]
|
||
fn main() -> Result<()> {
|
||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||
let width = args
|
||
.first()
|
||
.and_then(|s| s.parse().ok())
|
||
.unwrap_or(DEFAULT_WIDTH);
|
||
let height = args
|
||
.get(1)
|
||
.and_then(|s| s.parse().ok())
|
||
.unwrap_or(DEFAULT_HEIGHT);
|
||
|
||
let mut cx = VisualTestAppContext::new(current_platform(false));
|
||
cx.update(|app| app.init_colors());
|
||
|
||
// Initialize the UI language like the real app's startup would: zh-CN
|
||
// for the primary screenshot, then en-US for the English one. The
|
||
// original persisted language is restored at the end.
|
||
let original = i18n::language();
|
||
i18n::set_language(Language::ZhCN);
|
||
{
|
||
let (handle, root) = open_shell(&mut cx, width, height);
|
||
let image = cx.capture_screenshot(handle.into())?;
|
||
std::fs::create_dir_all(std::path::Path::new(OUT_ZH).parent().unwrap())?;
|
||
image.save(OUT_ZH)?;
|
||
println!("wrote {OUT_ZH} ({}×{})", image.width(), image.height());
|
||
assert_toolbar(&image, "zh-CN");
|
||
capture_preferences(&mut cx, handle, &root, OUT_PREF_ZH)?;
|
||
capture_manager(&mut cx, handle, &root, OUT_MGR_ZH)?;
|
||
}
|
||
i18n::set_language(Language::EnUs);
|
||
{
|
||
let (handle, root) = open_shell(&mut cx, width, height);
|
||
let image = cx.capture_screenshot(handle.into())?;
|
||
std::fs::create_dir_all(std::path::Path::new(OUT_EN).parent().unwrap())?;
|
||
image.save(OUT_EN)?;
|
||
println!("wrote {OUT_EN} ({}×{})", image.width(), image.height());
|
||
assert_toolbar(&image, "en-US");
|
||
capture_preferences(&mut cx, handle, &root, OUT_PREF_EN)?;
|
||
capture_manager(&mut cx, handle, &root, OUT_MGR_EN)?;
|
||
}
|
||
i18n::set_language(original);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Opens the app shell offscreen and lets the layout settle (see
|
||
/// [`settle`]). Returns the typed window handle and the root entity so the
|
||
/// caller can still drive the shell (the manager capture) after the plain
|
||
/// screenshot.
|
||
#[cfg(target_os = "macos")]
|
||
fn open_shell(
|
||
cx: &mut VisualTestAppContext,
|
||
width: f32,
|
||
height: f32,
|
||
) -> (
|
||
gpui::WindowHandle<OakApp<MockEngine>>,
|
||
Entity<OakApp<MockEngine>>,
|
||
) {
|
||
let mut root_slot = None;
|
||
let window = cx
|
||
.open_offscreen_window(size(px(width), px(height)), |window, cx| {
|
||
// Compact pro-app text metrics, matching the real app's startup
|
||
// (`src/app.rs run_with` sets rem 14px; gpui's default is 16px).
|
||
window.set_rem_size(px(14.0));
|
||
let root = cx.new(|cx| OakApp::<MockEngine>::new(window, None, cx));
|
||
root_slot = Some(root.clone());
|
||
root
|
||
})
|
||
.expect("offscreen window opens");
|
||
settle(cx, window.into());
|
||
(window, root_slot.expect("root entity"))
|
||
}
|
||
|
||
/// Draws enough frames for the layout to settle and the async toolbar-icon
|
||
/// assets to decode: the node editor fits its graph once the canvas size is
|
||
/// known, the viewers upload their first CPU frame, and the PNG toolbar
|
||
/// icons load through the background executor on the frame after the asset
|
||
/// future resolves.
|
||
#[cfg(target_os = "macos")]
|
||
fn settle(cx: &mut VisualTestAppContext, handle: AnyWindowHandle) {
|
||
for _ in 0..16 {
|
||
cx.run_until_parked();
|
||
cx.update_window(handle, |_root, window, app| {
|
||
let _ = window.draw(app);
|
||
})
|
||
.expect("window still open");
|
||
}
|
||
cx.run_until_parked();
|
||
|
||
for _ in 0..16 {
|
||
cx.run_until_parked();
|
||
cx.update_window(handle, |_root, window, app| {
|
||
let _ = window.draw(app);
|
||
})
|
||
.expect("window still open");
|
||
}
|
||
cx.run_until_parked();
|
||
}
|
||
|
||
/// Opens the preferences dialog on the shell (M12 P5b) and captures it: the
|
||
/// modal lists the grouped settings (general / rendering / cache / proxy /
|
||
/// project / audio). Drives the root ENTITY (not the window handle — see
|
||
/// [`capture_manager`]). The dialog closes afterwards so the manager
|
||
/// capture starts from a clean shell.
|
||
#[cfg(target_os = "macos")]
|
||
fn capture_preferences(
|
||
cx: &mut VisualTestAppContext,
|
||
handle: gpui::WindowHandle<OakApp<MockEngine>>,
|
||
root: &Entity<OakApp<MockEngine>>,
|
||
out: &str,
|
||
) -> Result<()> {
|
||
root.update(cx, |app, cx| app.open_preferences(cx));
|
||
settle(cx, handle.into());
|
||
let image = cx.capture_screenshot(handle.into())?;
|
||
image.save(out)?;
|
||
println!("wrote {out} ({}×{})", image.width(), image.height());
|
||
// Close the dialog (the Close button path: commit + dismiss).
|
||
root.update(cx, |app, cx| app.close_modal(cx));
|
||
cx.run_until_parked();
|
||
Ok(())
|
||
}
|
||
|
||
/// Opens the project manager on the shell (M13 D4) and captures it: the
|
||
/// modal card lists the mock library with its per-project stats. Drives the
|
||
/// root ENTITY (not the window handle — a window update borrows the window,
|
||
/// and building the modal inside it would re-enter it).
|
||
#[cfg(target_os = "macos")]
|
||
fn capture_manager(
|
||
cx: &mut VisualTestAppContext,
|
||
handle: gpui::WindowHandle<OakApp<MockEngine>>,
|
||
root: &Entity<OakApp<MockEngine>>,
|
||
out: &str,
|
||
) -> Result<()> {
|
||
root.update(cx, |app, cx| app.show_project_manager(cx));
|
||
settle(cx, handle.into());
|
||
let image = cx.capture_screenshot(handle.into())?;
|
||
image.save(out)?;
|
||
println!("wrote {out} ({}×{})", image.width(), image.height());
|
||
Ok(())
|
||
}
|
||
|
||
/// The timeline toolbar's tool icons (16px at 2× = 32px on 48px pitch) must
|
||
/// render: the toolbar is the 31px row at the top of the bottom dock panel.
|
||
/// Scan the tool cells for bright glyph pixels, so a broken icon load fails
|
||
/// the capture loudly instead of shipping an empty toolbar.
|
||
#[cfg(target_os = "macos")]
|
||
fn assert_toolbar(image: &image::RgbaImage, language: &str) {
|
||
// The image is 2× the logical size; convert logical → pixel y. TOOLBAR_Y
|
||
// is measured from the window's top edge.
|
||
let mut rendered = 0usize;
|
||
for (index, cell_x) in [12u32, 44, 76, 108, 140, 172, 204, 236].iter().enumerate() {
|
||
let mut bright = 0u32;
|
||
for dy in 0..(TOOLBAR_BAND as i32 * 2) {
|
||
for dx in 0..40i32 {
|
||
let x = (*cell_x as i32 + dx) as u32;
|
||
let y = ((TOOLBAR_Y as i32 - TOOLBAR_BAND as i32 / 2) * 2 + dy).max(0) as u32;
|
||
if x >= image.width() || y >= image.height() {
|
||
continue;
|
||
}
|
||
let p = image.get_pixel(x, y);
|
||
if p[0] > 150 && p[1] > 150 && p[2] > 150 {
|
||
bright += 1;
|
||
}
|
||
}
|
||
}
|
||
println!("[screenshot] {language} toolbar tool {index} bright pixels: {bright}");
|
||
if bright > 20 {
|
||
rendered += 1;
|
||
}
|
||
}
|
||
assert!(
|
||
rendered >= 6,
|
||
"{language} timeline toolbar icons did not render (only {rendered}/8 tool cells had pixels)"
|
||
);
|
||
}
|
||
|
||
#[cfg(not(target_os = "macos"))]
|
||
fn main() {
|
||
eprintln!("the screenshot example is macOS-only (offscreen Metal rendering)");
|
||
}
|