///|
/// `moui screenshot` — render an app to a PNG without opening a window.
///
/// The command resolves the project module and its shared app package, writes a
/// temporary offscreen render package beside the app, runs it through `moon`,
/// reads the captured RGBA frame, and encodes a PNG. The temporary package is
/// removed afterwards unless `--keep-temp` is passed.
///
/// The app package must expose `pub fn program() -> @moui.Program[Model, Msg]`
/// (the `moui new` template does) and its module must already depend on the
/// selected renderer package.
///|
priv struct ScreenshotOptions {
project_root : String
app_package : String
out : String
width : Int32
height : Int32
scale : Double
scheme : String
renderer : String
keep_temp : Bool
}
///|
priv struct ScreenshotResult {
out : String
width : Int32
height : Int32
renderer : String
scheme : String
}
///|
priv struct ParsedScreenshot {
help : Bool
json : Bool
options : ScreenshotOptions
}
///|
fn screenshot_usage() -> String {
(
#|Usage: moui screenshot [project] [options]
#|
#|Render an app to a PNG with an offscreen renderer (no window, no display).
#|The app package must expose `pub fn program()` and the project module must
#|depend on the selected renderer package (`moui new` desktop projects do).
#|
#|Options:
#| --app PACKAGE App package under the project root. Defaults to `app`.
#| --out PATH Output PNG path (or directory). Defaults to
#| `moui-screenshot.png`.
#| --width N Logical width in DIPs. Defaults to 1080.
#| --height N Logical height in DIPs. Defaults to 720.
#| --scale FACTOR Device scale factor. Defaults to 2.0.
#| --scheme SCHEME Runtime environment scheme: light, dark, or system.
#| Defaults to light. Apps that pin their own theme
#| ignore this; it only feeds environment-aware views.
#| --renderer NAME Renderer: skia (default) or sun.
#| --keep-temp Keep the generated render package for debugging.
#| --json Emit machine-readable output.
#| -h, --help Show this help.
)
}
///|
fn screenshot_parse_int(value : StringView) -> Int32? {
try @strconv.parse_int(value) catch {
_ => None
} noraise {
parsed => Some(parsed)
}
}
///|
fn screenshot_parse_double(value : StringView) -> Double? {
try @strconv.parse_double(value) catch {
_ => None
} noraise {
parsed => Some(parsed)
}
}
///|
fn parse_screenshot_args(
args : Array[String],
cwd : String,
) -> Result[ParsedScreenshot, String] {
let project : Ref[String?] = Ref(None)
let app_package = Ref("app")
let out = Ref("moui-screenshot.png")
let width = Ref(1080)
let height = Ref(720)
let scale = Ref(2.0)
let scheme = Ref("light")
let renderer = Ref("skia")
let keep_temp = Ref(false)
let help = Ref(false)
let json = Ref(false)
let mut index = 0
while index < args.length() {
let argument = args[index]
let (name, inline) = option_parts(argument)
match name {
"--app"
| "--out"
| "--width"
| "--height"
| "--scale"
| "--scheme"
| "--renderer" => {
let (value, next) = match option_value(args, index, inline, name) {
Ok(result) => result
Err(message) => return Err(message)
}
match name {
"--app" => app_package.val = value
"--out" => out.val = value
"--width" =>
match screenshot_parse_int(value) {
Some(parsed) => {
if parsed <= 0 {
return Err("--width must be a positive integer")
}
width.val = parsed
}
None => return Err("invalid --width: " + value)
}
"--height" =>
match screenshot_parse_int(value) {
Some(parsed) => {
if parsed <= 0 {
return Err("--height must be a positive integer")
}
height.val = parsed
}
None => return Err("invalid --height: " + value)
}
"--scale" =>
match screenshot_parse_double(value) {
Some(parsed) => {
if parsed <= 0.0 || parsed > 8.0 {
return Err("--scale must be in (0, 8]")
}
scale.val = parsed
}
None => return Err("invalid --scale: " + value)
}
"--scheme" => {
let normalized = value.to_lower()
if normalized != "light" &&
normalized != "dark" &&
normalized != "system" {
return Err("--scheme must be light, dark, or system")
}
scheme.val = normalized
}
"--renderer" => {
let normalized = value.to_lower()
if normalized != "skia" && normalized != "sun" {
return Err("--renderer must be skia or sun")
}
renderer.val = normalized
}
_ => ()
}
index = next
}
"--keep-temp" | "--json" | "-h" | "--help" => {
match flag_without_value(inline, name) {
Ok(_) => ()
Err(message) => return Err(message)
}
match name {
"--keep-temp" => keep_temp.val = true
"--json" => json.val = true
"-h" | "--help" => help.val = true
_ => ()
}
index += 1
}
_ if argument.has_prefix("-") =>
return Err("unknown option for moui screenshot: " + name)
_ => {
match project.val {
None => project.val = Some(argument)
Some(_) =>
return Err("moui screenshot accepts at most one project path")
}
index += 1
}
}
}
let resolved_project = match project.val {
Some(value) => resolve_path(cwd, value)
None => cwd
}
Ok({
help: help.val,
json: json.val,
options: {
project_root: resolved_project,
app_package: app_package.val,
out: resolve_path(cwd, out.val),
width: width.val,
height: height.val,
scale: scale.val,
scheme: scheme.val,
renderer: renderer.val,
keep_temp: keep_temp.val,
},
})
}
///|
/// Module root and app import path resolved from a project/app directory.
priv struct ScreenshotTarget {
module_root : String
module_name : String
app_dir : String
app_relative : String
app_import : String
}
///|
fn screenshot_module_name(moon_mod_text : String) -> String? {
for line in moon_mod_text.split("\n") {
let trimmed = line.trim()
if trimmed.has_prefix("name") {
match trimmed.find("\"") {
Some(start) =>
match trimmed[start + 1:].find("\"") {
Some(end) =>
return Some(trimmed[start + 1:start + 1 + end].to_owned())
None => ()
}
None => ()
}
}
}
None
}
///|
/// Walk up from `start` until a directory containing `moon.mod` is found.
fn screenshot_find_module_root(start : String) -> String? {
let mut current = normalized_path(start)
while current != "" {
if path_is_file(join_path(current, "moon.mod")) {
return Some(current)
}
let parent = parent_dir(current)
if parent == current {
return None
}
current = parent
}
None
}
///|
fn screenshot_resolve_target(
project_root : String,
app_package : String,
) -> Result[ScreenshotTarget, String] {
let project_path = normalized_path(project_root)
let module_root = match screenshot_find_module_root(project_path) {
Some(value) => value
None =>
return Err(
"no moon.mod found at or above " +
project_path +
"; run moui screenshot from a project directory",
)
}
let is_module_root = normalized_path(project_path) == module_root
let app_dir = if !is_module_root &&
path_is_file(join_path(project_path, "moon.pkg")) {
project_path
} else {
normalized_path(join_path(module_root, app_package))
}
let app_dir = if path_is_file(join_path(app_dir, "moon.pkg")) {
app_dir
} else if is_module_root && path_is_file(join_path(module_root, "moon.pkg")) {
// The module root package itself may be the app.
module_root
} else {
return Err(
"app package not found: " +
app_dir +
" (pass --app PACKAGE or point at a package directory)",
)
}
let moon_mod_text = match
read_file_string_optional(join_path(module_root, "moon.mod")) {
Some(value) => value
None => return Err("could not read moon.mod under " + module_root)
}
let module_name = match screenshot_module_name(moon_mod_text) {
Some(value) => value
None => return Err("moon.mod under " + module_root + " has no module name")
}
let app_relative = relative_path_under(module_root, app_dir)
let app_import = if app_relative == "." || app_relative == "" {
module_name
} else {
module_name + "/" + app_relative
}
Ok({ module_root, module_name, app_dir, app_relative, app_import, })
}
///|
/// The app package must expose a zero-argument program constructor; the
/// generated render package calls it as `@app.program()`.
fn screenshot_app_exposes_program(app_dir : String) -> Result[Bool, String] {
let entries = @fs.read_dir(app_dir) catch {
_ => return Err("could not read app package: " + app_dir)
}
for name in entries {
if name.has_suffix(".mbt") {
match read_file_string_optional(join_path(app_dir, name)) {
Some(text) => if text.contains("pub fn program(") { return Ok(true) }
None => ()
}
}
}
Ok(false)
}
///|
fn screenshot_has_dependency(
moon_mod_text : String,
module_name : String,
) -> Bool {
moon_mod_text.contains("\"" + module_name)
}
///|
fn screenshot_package_dir(module_root : String) -> String {
join_path(module_root, "moui_screenshot_tmp")
}
///|
let screenshot_package_marker : String = "MOUI_SCREENSHOT_PACKAGE"
///|
fn screenshot_package_moon_pkg(renderer : String) -> String {
let renderer_import = if renderer == "sun" {
"wzzc-dev/moui_sun_renderer"
} else {
"wzzc-dev/moui_skia_renderer"
}
(
#|import {
#| "moonbitlang/core/encoding/utf8",
#| "moonbitlang/core/env",
#| "moonbitlang/core/string" @strings,
#| "wzzc-dev/moui/core",
#| "wzzc-dev/moui/runtime",
#| "__RENDERER_IMPORT__" @render,
#| "__APP_IMPORT__" @app,
#|}
#|
#|supported_targets = "+native"
#|
#|pkgtype(kind: "executable")
#|
#|options(
#| "native-stub": [ "screenshot_io.c" ],
#| targets: { "main.mbt": [ "native" ] },
#|)
).replace_all(old="__RENDERER_IMPORT__", new=renderer_import)
}
///|
fn screenshot_package_main(renderer : String) -> String {
let renderer_block = if renderer == "sun" {
(
#| let target = @render.SunPresentTarget::new(
#| description="moui-screenshot",
#| present=frame => {
#| captured.val = Some({
#| width: frame.width(),
#| height: frame.height(),
#| row_bytes: frame.row_bytes(),
#| pixels: frame.bytes(),
#| })
#| true
#| },
#| )
#| let renderer = @render.create_with_present_target(metrics, target) catch {
#| _ => abort("moui screenshot: could not create the sun renderer")
#| } noraise {
#| renderer => renderer
#| }
#| ignore(renderer.render_frame(runtime.draw_frame()))
)
} else {
(
#| let target = @render.SkiaPresentTarget::new(
#| description="moui-screenshot",
#| present=frame => {
#| captured.val = Some({
#| width: frame.width(),
#| height: frame.height(),
#| row_bytes: frame.row_bytes(),
#| pixels: frame.pixels(),
#| })
#| true
#| },
#| )
#| let renderer = @render.create_with_present_target(metrics, target) catch {
#| _ => abort("moui screenshot: could not create the skia renderer")
#| } noraise {
#| renderer => renderer
#| }
#| ignore(renderer.render_frame(runtime.draw_frame()))
)
}
let template =
#|///|
#|#borrow(path, data)
#|extern "C" fn screenshot_write(path : Bytes, data : Bytes) -> Int32 = "moui_screenshot_write"
#|
#|///|
#|struct Shot {
#| width : Int32
#| height : Int32
#| row_bytes : Int32
#| pixels : Bytes
#|}
#|
#|///|
#|fn arg_value(args : Array[String], flag : String) -> String? {
#| let mut index = 0
#| while index < args.length() {
#| if args[index] == flag && index + 1 < args.length() {
#| return Some(args[index + 1])
#| }
#| index = index + 1
#| }
#| None
#|}
#|
#|///|
#|fn write_or_abort(path : String, data : Bytes) -> Unit {
#| if screenshot_write(@utf8.encode(path[:], bom=false) + b"\x00", data) != 0 {
#| abort("moui screenshot: could not write " + path)
#| }
#|}
#|
#|///|
#|fn parse_double_or(value : String?, fallback : Double) -> Double {
#| match value {
#| Some(text) =>
#| try @strings.parse_double(text) catch {
#| _ => fallback
#| } noraise {
#| parsed => parsed
#| }
#| None => fallback
#| }
#|}
#|
#|fn main {
#| let args = @env.args()
#| let out = arg_value(args, "--out").unwrap_or(".")
#| let width = parse_double_or(arg_value(args, "--width"), 1080.0)
#| let height = parse_double_or(arg_value(args, "--height"), 720.0)
#| let scale = parse_double_or(arg_value(args, "--scale"), 2.0)
#| let scheme = arg_value(args, "--scheme").unwrap_or("light")
#| let environment = match scheme {
#| "dark" => @core.Environment::neutral().with_system_scheme(@core.Dark)
#| "system" => @core.Environment::neutral()
#| _ => @core.Environment::neutral().with_system_scheme(@core.Light)
#| }
#| let runtime = @runtime.new_program_with_dimensions(
#| program=@app.program(),
#| width~,
#| height~,
#| environment~,
#| )
#| let captured : Ref[Shot?] = Ref(None)
#| let metrics = @core.SurfaceMetrics::from_logical(
#| size=@core.Size::new(width~, height~),
#| scale_factor=scale,
#| )
#|__RENDERER_BLOCK__
#| let shot = match captured.val {
#| Some(value) => value
#| None => abort("moui screenshot: the renderer presented no frame")
#| }
#| write_or_abort(out + "/shot.rgba", shot.pixels)
#| let meta : String = "width=" + shot.width.to_string() +
#| "\nheight=" + shot.height.to_string() +
#| "\nrowBytes=" + shot.row_bytes.to_string() + "\n"
#| write_or_abort(out + "/shot.meta", @utf8.encode(meta[:], bom=false))
#|}
template.replace_all(old="__RENDERER_BLOCK__", new=renderer_block)
}
///|
fn screenshot_package_c_stub() -> String {
(
#|#include
#|#include
#|#include
#|
#|MOONBIT_FFI_EXPORT int32_t moui_screenshot_write(
#| moonbit_bytes_t path,
#| moonbit_bytes_t data) {
#| FILE *file = fopen((const char *)path, "wb");
#| if (file == NULL) {
#| return 1;
#| }
#| int32_t length = (int32_t)Moonbit_array_length(data);
#| size_t written = fwrite(data, 1, (size_t)length, file);
#| int close_status = fclose(file);
#| if (written != (size_t)length || close_status != 0) {
#| return 2;
#| }
#| return 0;
#|}
)
}
///|
fn screenshot_write_package(
target : ScreenshotTarget,
renderer : String,
) -> Result[String, String] {
let package_dir = screenshot_package_dir(target.module_root)
let has_marker = path_is_file(
join_path(package_dir, screenshot_package_marker),
)
let package_exists = @fs.is_dir(package_dir) catch { _ => false }
if has_marker {
remove_tree(package_dir)
} else if package_exists {
return Err(
"refusing to overwrite " +
package_dir +
": it exists but is not a moui screenshot package",
)
}
match create_dir_if_missing(package_dir) {
Ok(_) => ()
Err(message) => return Err(message)
}
let pkg_text = screenshot_package_moon_pkg(renderer).replace_all(
old="__APP_IMPORT__",
new=target.app_import,
)
let files : Array[(String, String)] = [
("moon.pkg", pkg_text),
("main.mbt", screenshot_package_main(renderer)),
("screenshot_io.c", screenshot_package_c_stub()),
(
screenshot_package_marker,
"schemaVersion=1\nmodule=" +
target.module_name +
"\napp=" +
target.app_import +
"\n",
),
]
for entry in files {
match
write_file_bytes_result(
join_path(package_dir, entry.0),
@utf8.encode(entry.1[:], bom=false),
) {
Ok(_) => ()
Err(message) => {
remove_tree(package_dir)
return Err(message)
}
}
}
Ok(package_dir)
}
///|
fn screenshot_read_meta(path : String) -> Result[(Int32, Int32, Int32), String] {
let text = match read_file_string_optional(path) {
Some(value) => value
None => return Err("could not read screenshot metadata: " + path)
}
let mut width = 0
let mut height = 0
let mut row_bytes = 0
for line in text.split("\n") {
let trimmed = line.trim()
if trimmed.has_prefix("width=") {
width = screenshot_parse_int(trimmed[6:]).unwrap_or(0)
} else if trimmed.has_prefix("height=") {
height = screenshot_parse_int(trimmed[7:]).unwrap_or(0)
} else if trimmed.has_prefix("rowBytes=") {
row_bytes = screenshot_parse_int(trimmed[9:]).unwrap_or(0)
}
}
if width <= 0 || height <= 0 || row_bytes <= 0 {
return Err("screenshot metadata is incomplete: " + path)
}
Ok((width, height, row_bytes))
}
///|
fn screenshot_output_png_path(out : String) -> String {
if out.has_suffix(".png") {
return out
}
let is_directory = @fs.is_dir(out) catch { _ => false }
if is_directory || out.has_suffix("/") {
join_path(out, "screenshot.png")
} else {
out + ".png"
}
}
///|
fn screenshot_cleanup(package_dir : String, keep_temp : Bool) -> Unit {
if !keep_temp {
remove_tree(package_dir)
}
}
///|
fn run_screenshot(args : Array[String], cwd : String) -> CliOutcome {
let parsed = match parse_screenshot_args(args, cwd) {
Ok(value) => value
Err(message) =>
return failed_outcome(message, json_requested(args), screenshot_usage())
}
if parsed.help {
return stdout_outcome(screenshot_usage())
}
let options = parsed.options
let target = match
screenshot_resolve_target(options.project_root, options.app_package) {
Ok(value) => value
Err(message) =>
return failed_outcome(message, parsed.json, screenshot_usage())
}
match screenshot_app_exposes_program(target.app_dir) {
Ok(true) => ()
Ok(false) =>
return failed_outcome(
"app package " +
target.app_import +
" must expose `pub fn program() -> @moui.Program[Model, Msg]`",
parsed.json,
screenshot_usage(),
)
Err(message) =>
return failed_outcome(message, parsed.json, screenshot_usage())
}
let moon_mod_text = read_file_string_optional(
join_path(target.module_root, "moon.mod"),
).unwrap_or("")
let renderer_module = if options.renderer == "sun" {
"wzzc-dev/moui_sun_renderer"
} else {
"wzzc-dev/moui_skia_renderer"
}
if !screenshot_has_dependency(moon_mod_text, renderer_module) {
return failed_outcome(
"project module " +
target.module_name +
" does not depend on " +
renderer_module +
"; add it to moon.mod (generated desktop projects include it)",
parsed.json,
screenshot_usage(),
)
}
let package_dir = match screenshot_write_package(target, options.renderer) {
Ok(value) => value
Err(message) =>
return failed_outcome(message, parsed.json, screenshot_usage())
}
let temp_out = join_path(
process_tmp_dir(),
"moui-screenshot-" + @env.now().to_string(),
)
let cleanup = fn() { screenshot_cleanup(package_dir, options.keep_temp) }
let render_result = match create_dir_if_missing(temp_out) {
Ok(_) =>
process_exec_argv(
[
"moon",
"run",
"moui_screenshot_tmp",
"--target",
"native",
"--",
"--out",
temp_out,
"--width",
options.width.to_string(),
"--height",
options.height.to_string(),
"--scale",
options.scale.to_string(),
"--scheme",
options.scheme,
],
cwd=target.module_root,
capture=true,
timeout_ms=600000,
)
Err(message) => Err(message)
}
let outcome = match render_result {
Ok(result) =>
if result.exit_code != 0 {
cleanup()
remove_tree(temp_out)
return failed_outcome(
"offscreen render failed (moon exit " +
result.exit_code.to_string() +
"):\n" +
result.stderr,
parsed.json,
"",
)
} else {
let meta_path = join_path(temp_out, "shot.meta")
let rgba_path = join_path(temp_out, "shot.rgba")
let meta = screenshot_read_meta(meta_path)
let png = match meta {
Err(message) => Err(message)
Ok((width, height, row_bytes)) =>
match read_file_bytes_optional(rgba_path) {
None => Err("could not read screenshot pixels: " + rgba_path)
Some(pixels) => {
let encoded = try
png_encode_rgba(width, height, row_bytes, pixels)
catch {
_ => None
} noraise {
value => Some(value)
}
match encoded {
Some(bytes) => Ok((bytes, width, height))
None => Err("could not encode the screenshot as PNG")
}
}
}
}
match png {
Ok((bytes, width, height)) => {
let out_path = screenshot_output_png_path(options.out)
let parent = parent_dir(out_path)
if parent != "" && parent != "." {
match mkdir_p(parent) {
Ok(_) => ()
Err(message) => {
cleanup()
remove_tree(temp_out)
return failed_outcome(message, parsed.json, "")
}
}
}
match write_file_bytes_result(out_path, bytes) {
Ok(_) =>
Ok(ScreenshotResult::{
out: out_path,
width,
height,
renderer: options.renderer,
scheme: options.scheme,
})
Err(message) => Err(message)
}
}
Err(message) => Err(message)
}
}
Err(message) => Err(message)
}
cleanup()
remove_tree(temp_out)
match outcome {
Ok(value) =>
if parsed.json {
let json_value : Json = {
"schemaVersion": 1,
"ok": true,
"command": "screenshot",
"out": value.out,
"width": value.width,
"height": value.height,
"renderer": value.renderer,
"scheme": value.scheme,
"module": target.module_name,
"package": target.app_relative,
}
stdout_outcome(json_value.stringify(indent=2) + "\n")
} else {
stdout_outcome(
"[screenshot] wrote " +
value.out +
" (" +
value.width.to_string() +
"x" +
value.height.to_string() +
", " +
value.renderer +
", " +
value.scheme +
")\n",
)
}
Err(message) => failed_outcome(message, parsed.json, "")
}
}