diff --git a/CLAUDE.md b/CLAUDE.md index fd07c171ed..568047b6a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1565 +**Current Version:** 0.5.1566 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 36b9765959..90632d9236 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5690,7 +5690,7 @@ checksum = "1542e48011813fbdf3c075da4a4ed53ee93c816eef62e36eb5064a6fd2be10a5" [[package]] name = "perry" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "anyhow", "base64 0.22.1", @@ -5754,7 +5754,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "perry-dispatch", "serde", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "cc", "libc", @@ -5771,7 +5771,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "aho-corasick", "anyhow", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "anyhow", "perry-hir", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "anyhow", "perry-hir", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "anyhow", "perry-dispatch", @@ -5814,7 +5814,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "anyhow", "perry-hir", @@ -5822,7 +5822,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "anyhow", "base64 0.22.1", @@ -5834,7 +5834,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "anyhow", "perry-hir", @@ -5842,7 +5842,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "anyhow", "async-trait", @@ -5870,14 +5870,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "serde", "serde_json", @@ -5885,7 +5885,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1565" +version = "0.5.1566" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "anyhow", "clap", @@ -5911,7 +5911,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "block2", "objc2", @@ -5921,7 +5921,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "argon2", "perry-ffi", @@ -5930,7 +5930,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "perry-ffi", "reqwest", @@ -5939,7 +5939,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "bcrypt", "perry-ffi", @@ -5947,7 +5947,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "perry-ffi", "rusqlite", @@ -5955,7 +5955,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "perry-ffi", "scraper", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "perry-ffi", "perry-runtime", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "chrono", "cron", @@ -5981,7 +5981,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "chrono", "perry-ffi", @@ -5989,7 +5989,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "perry-ffi", "rust_decimal", @@ -5997,7 +5997,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "perry-ffi", "serde_json", @@ -6005,7 +6005,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -6013,7 +6013,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "perry-ffi", "perry-runtime", @@ -6021,14 +6021,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "bytes", "http-body-util", @@ -6046,7 +6046,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "bytes", "lazy_static", @@ -6059,7 +6059,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "base64 0.22.1", "bytes", @@ -6091,7 +6091,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "lazy_static", "perry-ffi", @@ -6101,7 +6101,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "base64 0.22.1", "jsonwebtoken", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "lru", "perry-ffi", @@ -6121,7 +6121,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "chrono", "perry-ffi", @@ -6129,7 +6129,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "bson", "futures-util", @@ -6141,7 +6141,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "chrono", "perry-ffi", @@ -6153,7 +6153,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "nanoid", "perry-ffi", @@ -6162,7 +6162,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "bytes", "perry-ffi", @@ -6177,7 +6177,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "const-oid 0.10.2", "der 0.8.1", @@ -6196,7 +6196,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "lettre", "perry-ffi", @@ -6206,7 +6206,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "notify", "perry-ffi", @@ -6218,7 +6218,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "perry-ffi", "printpdf", @@ -6226,7 +6226,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "perry-ffi", "sqlx", @@ -6235,7 +6235,7 @@ dependencies = [ [[package]] name = "perry-ext-qs" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "perry-ffi", "perry-runtime", @@ -6244,7 +6244,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "governor", "perry-ffi", @@ -6252,7 +6252,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "fast_image_resize", "image", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "lazy_static", "perry-ffi", @@ -6272,7 +6272,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "anyhow", "perry-ffi", @@ -6292,7 +6292,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "perry-ffi", "perry-runtime", @@ -6301,7 +6301,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "perry-ffi", "uuid", @@ -6309,7 +6309,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "perry-ffi", "perry-validation", @@ -6318,7 +6318,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "futures-util", "lazy_static", @@ -6331,7 +6331,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "brotli", "flate2", @@ -6341,7 +6341,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6351,7 +6351,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "anyhow", "perry-api-manifest", @@ -6372,11 +6372,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1565" +version = "0.5.1566" [[package]] name = "perry-parser" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "anyhow", "perry-diagnostics", @@ -6390,7 +6390,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "perex", "regex", @@ -6398,7 +6398,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "ahash", "anyhow", @@ -6458,14 +6458,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6560,14 +6560,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "anyhow", "perry-hir", @@ -6576,7 +6576,7 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "dirs", "perry-ffi", @@ -6586,7 +6586,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "base64 0.22.1", "itoa", @@ -6604,7 +6604,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "rand 0.10.2", "serde", @@ -6614,7 +6614,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6637,7 +6637,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "base64 0.22.1", "block2", @@ -6654,7 +6654,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "base64 0.22.1", "block2", @@ -6671,7 +6671,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1565" +version = "0.5.1566" [[package]] name = "perry-ui-test" @@ -6682,11 +6682,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1565" +version = "0.5.1566" [[package]] name = "perry-ui-tvos" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "base64 0.22.1", "block2", @@ -6703,7 +6703,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "base64 0.22.1", "block2", @@ -6720,7 +6720,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "block2", "libc", @@ -6734,7 +6734,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "base64 0.22.1", "libc", @@ -6753,7 +6753,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "base64 0.22.1", "libc", @@ -6766,7 +6766,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "anyhow", "base64 0.22.1", @@ -6782,7 +6782,7 @@ dependencies = [ [[package]] name = "perry-validation" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "idna", "regex", @@ -6792,7 +6792,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1565" +version = "0.5.1566" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index d15ea1e7f6..eb96d810ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -338,7 +338,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1565" +version = "0.5.1566" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/10260-widget-max-width.md b/changelog.d/10260-widget-max-width.md new file mode 100644 index 0000000000..d606a705ae --- /dev/null +++ b/changelog.d/10260-widget-max-width.md @@ -0,0 +1,10 @@ +Add `widgetSetMaxWidth(widget, maxWidth)` for responsive centered content columns. +On macOS, Auto Layout fills available width up to the cap while preserving padding, +stack alignment, and widget identity. Caps can be set before or after insertion and +updated in place. JavaScript and WebAssembly web targets use CSS max-width and auto +margins. Other backends export a documented no-op for portable compilation. + +Fixes #10167. Native layout regressions cover resizing, padded parents and content, +repeated updates, insertion order, fill alignment, hide/show, reparenting, +scroll views, and app-root placement. Native, JavaScript, and WebAssembly codegen +tests cover the new runtime call. diff --git a/crates/perry-codegen-arkts/src/mutations.rs b/crates/perry-codegen-arkts/src/mutations.rs index 6cfdfa1966..60d302ac41 100644 --- a/crates/perry-codegen-arkts/src/mutations.rs +++ b/crates/perry-codegen-arkts/src/mutations.rs @@ -624,6 +624,8 @@ pub(crate) fn collect_mutations_in_expr( cond, ); } + // Responsive max-width currently degrades to a no-op on HarmonyOS. + "widgetSetMaxWidth" => {} "widgetMatchParentWidth" => { push_mut(Mutation::Modifier(".width('100%')".to_string()), out, cond); } diff --git a/crates/perry-codegen-js/src/emit/calls.rs b/crates/perry-codegen-js/src/emit/calls.rs index 002a3d76a1..79bd1a9b10 100644 --- a/crates/perry-codegen-js/src/emit/calls.rs +++ b/crates/perry-codegen-js/src/emit/calls.rs @@ -304,6 +304,7 @@ impl JsEmitter { "widgetReorderChild" => "perry_ui_widget_reorder_child", "widgetClearChildren" => "perry_ui_widget_remove_all_children", "widgetSetWidth" => "perry_ui_widget_set_width", + "widgetSetMaxWidth" => "perry_ui_widget_set_max_width", "widgetSetHeight" => "perry_ui_widget_set_height", "widgetSetHugging" => "perry_ui_widget_set_hugging", "widgetSetHidden" => "perry_ui_set_widget_hidden", diff --git a/crates/perry-codegen-js/src/lib.rs b/crates/perry-codegen-js/src/lib.rs index 4685d2e412..3c7243bcfe 100644 --- a/crates/perry-codegen-js/src/lib.rs +++ b/crates/perry-codegen-js/src/lib.rs @@ -213,3 +213,23 @@ fn html_escape(s: &str) -> String { .replace('>', ">") .replace('"', """) } + +#[cfg(test)] +mod max_width_tests { + use super::*; + use perry_hir::Expr; + + #[test] + fn max_width_calls_the_web_runtime() { + let mut module = Module::new("max_width"); + module.init.push(Stmt::Expr(Expr::NativeMethodCall { + module: "perry/ui".into(), + class_name: None, + object: None, + method: "widgetSetMaxWidth".into(), + args: vec![Expr::Number(1.0), Expr::Number(640.0)], + })); + let (js, _) = compile_module_to_js(&module, false); + assert!(js.contains("perry_ui_widget_set_max_width(1, 640)"), "{js}"); + } +} diff --git a/crates/perry-codegen-js/src/web_runtime.js b/crates/perry-codegen-js/src/web_runtime.js index 23df4ca85d..7e79e3e0cd 100644 --- a/crates/perry-codegen-js/src/web_runtime.js +++ b/crates/perry-codegen-js/src/web_runtime.js @@ -1236,6 +1236,19 @@ function perry_ui_button_set_content_tint_color(h, r, g, b, a) { if (el) el.style.color = `rgba(${Math.round(r*255)},${Math.round(g*255)},${Math.round(b*255)},${a})`; } +function perry_ui_widget_set_max_width(h, maxWidth) { + if (!Number.isFinite(maxWidth) || maxWidth < 0) return; + const el = getHandle(h); + if (!el) return; + el.style.boxSizing = "border-box"; + el.style.width = "100%"; + el.style.minWidth = "0"; + el.style.maxWidth = maxWidth + "px"; + el.style.flexShrink = "1"; + el.style.marginLeft = "auto"; + el.style.marginRight = "auto"; +} + function perry_ui_widget_set_width(h, w) { const el = getHandle(h); if (el) { el.style.width = w + "px"; el.style.minWidth = w + "px"; el.style.maxWidth = w + "px"; el.style.flexShrink = "0"; } @@ -3834,6 +3847,7 @@ window.__perry = { perry_ui_app_on_terminate, perry_ui_app_set_timer, // Widget layout + perry_ui_widget_set_max_width, perry_ui_widget_set_width, perry_ui_widget_set_height, perry_ui_widget_set_hugging, diff --git a/crates/perry-codegen-wasm/src/emit/ui_method_map.rs b/crates/perry-codegen-wasm/src/emit/ui_method_map.rs index 2f72800225..a437b44216 100644 --- a/crates/perry-codegen-wasm/src/emit/ui_method_map.rs +++ b/crates/perry-codegen-wasm/src/emit/ui_method_map.rs @@ -93,6 +93,7 @@ pub(super) fn map_ui_method(method: &str, class_name: Option<&str>) -> &'static "setTooltip" | "set_tooltip" => "perry_ui_set_tooltip", "setControlSize" | "set_control_size" => "perry_ui_set_control_size", "widgetSetWidth" => "perry_ui_widget_set_width", + "widgetSetMaxWidth" => "perry_ui_widget_set_max_width", "widgetSetHeight" => "perry_ui_widget_set_height", "widgetSetHugging" => "perry_ui_widget_set_hugging", "widgetSetHidden" => "perry_ui_set_widget_hidden", diff --git a/crates/perry-codegen-wasm/src/lib.rs b/crates/perry-codegen-wasm/src/lib.rs index 7f02b0fa10..d3d8d8ecab 100644 --- a/crates/perry-codegen-wasm/src/lib.rs +++ b/crates/perry-codegen-wasm/src/lib.rs @@ -123,3 +123,27 @@ mod tests { ); } } + +#[cfg(test)] +mod max_width_tests { + use super::*; + use perry_hir::{Expr, Stmt}; + + #[test] + fn max_width_uses_the_runtime_bridge() { + let mut module = Module::new("max_width"); + module.init.push(Stmt::Expr(Expr::NativeMethodCall { + module: "perry/ui".into(), + class_name: None, + object: None, + method: "widgetSetMaxWidth".into(), + args: vec![Expr::Number(1.0), Expr::Number(640.0)], + })); + let modules = vec![("max_width".into(), module)]; + let wasm = compile_modules_to_wasm(&modules).unwrap(); + // UI calls use the generic bridge with the mapped runtime name in the + // data section (the separate FFI import list is for declare functions). + let symbol = b"perry_ui_widget_set_max_width"; + assert!(wasm.windows(symbol.len()).any(|bytes| bytes == symbol)); + } +} diff --git a/crates/perry-codegen-wasm/src/wasm_runtime.js b/crates/perry-codegen-wasm/src/wasm_runtime.js index 5b58665346..9749db43e9 100644 --- a/crates/perry-codegen-wasm/src/wasm_runtime.js +++ b/crates/perry-codegen-wasm/src/wasm_runtime.js @@ -3099,7 +3099,20 @@ function perry_ui_widget_set_background_gradient(h, r1, g1, b1, a1, r2, g2, b2, const dir = direction === 1 ? "to right" : "to bottom"; el.style.background = `linear-gradient(${dir}, ${c1}, ${c2})`; } -function perry_ui_widget_set_width(h, w) { const el = uiGet(h); if (el) el.style.width = w + "px"; } +function perry_ui_widget_set_max_width(h, maxWidth) { + if (!Number.isFinite(maxWidth) || maxWidth < 0) return; + const el = uiGet(h); + if (!el) return; + el.style.boxSizing = "border-box"; + el.style.width = "100%"; + el.style.minWidth = "0"; + el.style.maxWidth = maxWidth + "px"; + el.style.flexShrink = "1"; + el.style.marginLeft = "auto"; + el.style.marginRight = "auto"; +} + +function perry_ui_widget_set_width(h, w) { const el = uiGet(h); if (el) { el.style.width = w + "px"; el.style.maxWidth = ""; } } function perry_ui_widget_set_height(h, height) { const el = uiGet(h); if (el) el.style.height = height + "px"; } function perry_ui_widget_set_hugging(h) { const el = uiGet(h); if (el) el.style.flex = "0 0 auto"; } function perry_ui_widget_match_parent_width(h) { const el = uiGet(h); if (el) el.style.width = "100%"; } @@ -4723,7 +4736,7 @@ const __perryUiDispatch = { perry_ui_widget_set_border_color, perry_ui_widget_set_border_width, perry_ui_text_set_decoration, perry_ui_textfield_set_borderless, perry_ui_stack_set_alignment, - perry_ui_widget_set_width, perry_ui_widget_set_height, perry_ui_widget_set_hugging, + perry_ui_widget_set_max_width, perry_ui_widget_set_width, perry_ui_widget_set_height, perry_ui_widget_set_hugging, perry_ui_widget_match_parent_width, perry_ui_widget_match_parent_height, perry_ui_widget_set_edge_insets, perry_ui_stack_set_detaches_hidden, perry_ui_stack_set_distribution, perry_ui_widget_set_context_menu, diff --git a/crates/perry-codegen/tests/widget_max_width.rs b/crates/perry-codegen/tests/widget_max_width.rs new file mode 100644 index 0000000000..5b0a05f4fe --- /dev/null +++ b/crates/perry-codegen/tests/widget_max_width.rs @@ -0,0 +1,26 @@ +use perry_codegen::{compile_module, CompileOptions}; +use perry_hir::{Expr, Module, Stmt}; + +#[test] +fn max_width_lowers_to_the_native_widget_abi() { + let mut module = Module::new("widget_max_width"); + module.init.push(Stmt::Expr(Expr::NativeMethodCall { + module: "perry/ui".into(), + class_name: None, + object: None, + method: "widgetSetMaxWidth".into(), + args: vec![Expr::Number(1.0), Expr::Number(640.0)], + })); + let options = CompileOptions { + emit_ir_only: true, + ..Default::default() + }; + let ir = String::from_utf8(compile_module(&module, options).unwrap()).unwrap(); + assert!( + ir.lines().any( + |line| line.contains("call void @perry_ui_widget_set_max_width(i64 ") + && line.contains(", double ") + ), + "max-width must pass a native widget handle and numeric cap: {ir}" + ); +} diff --git a/crates/perry-dispatch/src/ui_table/part_a.rs b/crates/perry-dispatch/src/ui_table/part_a.rs index 19c6a4b086..fd8f037743 100644 --- a/crates/perry-dispatch/src/ui_table/part_a.rs +++ b/crates/perry-dispatch/src/ui_table/part_a.rs @@ -563,6 +563,12 @@ pub(crate) const PERRY_UI_TABLE_PART_A: &[MethodRow] = &[ args: &[ArgKind::Widget, ArgKind::F64], ret: ReturnKind::Void, }, + MethodRow { + method: "widgetSetMaxWidth", + runtime: "perry_ui_widget_set_max_width", + args: &[ArgKind::Widget, ArgKind::F64], + ret: ReturnKind::Void, + }, // ---- Image ---- MethodRow { method: "ImageFile", diff --git a/crates/perry-ui-android/src/ffi/tabbar_layout.rs b/crates/perry-ui-android/src/ffi/tabbar_layout.rs index ceb49c7ae8..74c50c8949 100644 --- a/crates/perry-ui-android/src/ffi/tabbar_layout.rs +++ b/crates/perry-ui-android/src/ffi/tabbar_layout.rs @@ -379,3 +379,8 @@ pub extern "C" fn perry_ui_app_set_window_state(_app_handle: i64, _value_ptr: i6 /// Frame persistence only applies to repositionable desktop windows. #[no_mangle] pub extern "C" fn perry_ui_app_set_frame_autosave_name(_app_handle: i64, _value_ptr: i64) {} + +/// Responsive max-width is not implemented on this backend yet. +/// Export the shared ABI so portable perry/ui code continues to compile. +#[no_mangle] +pub extern "C" fn perry_ui_widget_set_max_width(_handle: i64, _max_width: f64) {} diff --git a/crates/perry-ui-gtk4/src/ffi/layout.rs b/crates/perry-ui-gtk4/src/ffi/layout.rs index 6ff574de20..e62de417b8 100644 --- a/crates/perry-ui-gtk4/src/ffi/layout.rs +++ b/crates/perry-ui-gtk4/src/ffi/layout.rs @@ -181,3 +181,8 @@ pub extern "C" fn perry_ui_hstack_create_with_insets( ) -> i64 { widgets::hstack::create_with_insets(spacing, top, left, bottom, right) } + +/// Responsive max-width is not implemented on this backend yet. +/// Export the shared ABI so portable perry/ui code continues to compile. +#[no_mangle] +pub extern "C" fn perry_ui_widget_set_max_width(_handle: i64, _max_width: f64) {} diff --git a/crates/perry-ui-ios/src/ffi/widgets_basic.rs b/crates/perry-ui-ios/src/ffi/widgets_basic.rs index b4d39f200f..58a977c39a 100644 --- a/crates/perry-ui-ios/src/ffi/widgets_basic.rs +++ b/crates/perry-ui-ios/src/ffi/widgets_basic.rs @@ -841,3 +841,8 @@ pub extern "C" fn perry_ui_textfield_set_text_color(handle: i64, r: f64, g: f64, pub extern "C" fn perry_ui_widget_add_child_at(parent_handle: i64, child_handle: i64, index: f64) { widgets::add_child_at(parent_handle, child_handle, index as i64); } + +/// Responsive max-width is not implemented on this backend yet. +/// Export the shared ABI so portable perry/ui code continues to compile. +#[no_mangle] +pub extern "C" fn perry_ui_widget_set_max_width(_handle: i64, _max_width: f64) {} diff --git a/crates/perry-ui-macos/Cargo.toml b/crates/perry-ui-macos/Cargo.toml index 332d166c05..31fce17283 100644 --- a/crates/perry-ui-macos/Cargo.toml +++ b/crates/perry-ui-macos/Cargo.toml @@ -85,3 +85,8 @@ harness = false name = "native_widget_padding" path = "tests/native_widget_padding.rs" harness = false + +[[test]] +name = "native_widget_max_width" +path = "tests/native_widget_max_width.rs" +harness = false diff --git a/crates/perry-ui-macos/src/app.rs b/crates/perry-ui-macos/src/app.rs index 0b9163a948..2535656a38 100644 --- a/crates/perry-ui-macos/src/app.rs +++ b/crates/perry-ui-macos/src/app.rs @@ -136,7 +136,7 @@ pub fn app_set_body(app_handle: i64, root_handle: i64) { if idx < apps.len() { apps[idx]._root_widget = Some(root_handle); - if let Some(view) = widgets::get_widget(root_handle) { + if let Some(view) = widgets::get_layout_widget(root_handle) { let window = &apps[idx].window; // Check if the current content view is an NSVisualEffectView (set by vibrancy). diff --git a/crates/perry-ui-macos/src/lib_ffi/core_widgets.rs b/crates/perry-ui-macos/src/lib_ffi/core_widgets.rs index 6107210f34..504264b2d6 100644 --- a/crates/perry-ui-macos/src/lib_ffi/core_widgets.rs +++ b/crates/perry-ui-macos/src/lib_ffi/core_widgets.rs @@ -473,6 +473,12 @@ pub extern "C" fn perry_ui_widget_set_width(handle: i64, width: f64) { widgets::set_width(handle, width); } +/// Fill the available width up to a cap, centering the widget within it. +#[no_mangle] +pub extern "C" fn perry_ui_widget_set_max_width(handle: i64, max_width: f64) { + widgets::set_max_width(handle, max_width); +} + /// Set a fixed height constraint on a widget. #[no_mangle] pub extern "C" fn perry_ui_widget_set_height(handle: i64, height: f64) { diff --git a/crates/perry-ui-macos/src/widgets/max_width.rs b/crates/perry-ui-macos/src/widgets/max_width.rs new file mode 100644 index 0000000000..65812f6e30 --- /dev/null +++ b/crates/perry-ui-macos/src/widgets/max_width.rs @@ -0,0 +1,216 @@ +//! A full-width layout slot lets a capped child center without fighting its +//! NSStackView's leading/fill alignment. Widget operations still target the +//! original content view; only insertion/removal uses the slot. + +use objc2::rc::Retained; +use objc2::{define_class, msg_send, DefinedClass, MainThreadOnly}; +use objc2_app_kit::{NSLayoutConstraint, NSStackView, NSStackViewGravity, NSView}; +use objc2_foundation::MainThreadMarker; +use std::cell::RefCell; + +pub struct MaxWidthIvars { + content: Retained, + cap: Retained, + parent_constraints: RefCell>>, +} + +define_class!( + #[unsafe(super(NSView))] + #[name = "PerryMaxWidthView"] + #[ivars = MaxWidthIvars] + pub struct MaxWidthView; + + impl MaxWidthView { + #[unsafe(method(viewDidMoveToSuperview))] + fn did_move_to_superview(&self) { + unsafe { let _: () = msg_send![super(self), viewDidMoveToSuperview]; } + self.refresh_parent(); + } + } +); + +impl MaxWidthView { + fn refresh_parent(&self) { + let mut constraints = self.ivars().parent_constraints.borrow_mut(); + for constraint in constraints.drain(..) { + constraint.setActive(false); + } + let Some(parent) = (unsafe { self.superview() }) else { + return; + }; + // A stack's edge insets are inside its bounds. Do not fill through them. + let inset = parent + .downcast_ref::() + .map(|stack| { + let insets = stack.edgeInsets(); + insets.left + insets.right + }) + .unwrap_or(0.0); + let fits = self + .widthAnchor() + .constraintLessThanOrEqualToAnchor_constant(&parent.widthAnchor(), -inset); + let fills = self + .widthAnchor() + .constraintEqualToAnchor_constant(&parent.widthAnchor(), -inset); + fills.setPriority(999.0); + fits.setActive(true); + fills.setActive(true); + constraints.extend([fits, fills]); + } +} + +pub(super) fn content(view: Retained) -> Retained { + view.downcast_ref::() + .map(|slot| slot.ivars().content.clone()) + .unwrap_or(view) +} + +pub(super) fn clear_cap(handle: i64) { + if let Some(view) = super::get_layout_widget(handle) { + if let Some(slot) = view.downcast_ref::() { + slot.ivars().cap.setActive(false); + } + } +} + +pub(super) fn refresh_children(view: &NSView) { + for child in view.subviews() { + if let Some(slot) = child.downcast_ref::() { + slot.refresh_parent(); + } + } +} + +/// Fill the available width up to `max_width`, keeping the content centered. +/// Invalid values are ignored; zero is a valid cap. A new cap replaces an old +/// fixed width, and repeated calls update the existing constraint in place. +pub fn set_max_width(handle: i64, max_width: f64) { + if !max_width.is_finite() || max_width < 0.0 { + return; + } + let Some(view) = super::get_layout_widget(handle) else { + return; + }; + super::WIDTH_CONSTRAINTS.with(|constraints| { + if let Some(old) = constraints.borrow_mut().remove(&handle) { + unsafe { + let _: () = msg_send![&*old, setActive: false]; + } + } + }); + if let Some(slot) = view.downcast_ref::() { + slot.ivars().cap.setConstant(max_width); + slot.ivars().cap.setActive(true); + return; + } + + let mtm = MainThreadMarker::new().expect("perry/ui must run on the main thread"); + let parent = unsafe { view.superview() }; + let window = view + .window() + .filter(|window| window.contentView().as_deref() == Some(&view)); + let stack_position = parent.as_ref().and_then(|parent| { + parent.downcast_ref::().and_then(|stack| { + stack + .viewsInGravity(NSStackViewGravity::Top) + .iter() + .position(|child| Retained::as_ptr(&child) == Retained::as_ptr(&view)) + }) + }); + + // Existing parent pins (matchParentWidth, ZStack, ScrollView, app body) + // describe the layout slot. Transfer them so the cap can win inside it. + let mut external = Vec::new(); + let mut ancestor = parent.clone(); + while let Some(owner) = ancestor { + for constraint in owner.constraints() { + unsafe { + let first = constraint.firstItem(); + let second = constraint.secondItem(); + let is_content = |item: &Option>| { + item.as_ref().is_some_and(|item| { + Retained::as_ptr(item).cast::() == Retained::as_ptr(&view) + }) + }; + if is_content(&first) || is_content(&second) { + constraint.setActive(false); + external.push((constraint, first, second)); + } + } + } + ancestor = unsafe { owner.superview() }; + } + if let Some(stack) = parent + .as_ref() + .and_then(|parent| parent.downcast_ref::()) + { + stack.removeView(&view); + } + view.removeFromSuperview(); + view.setTranslatesAutoresizingMaskIntoConstraints(false); + let cap = view + .widthAnchor() + .constraintLessThanOrEqualToConstant(max_width); + let slot = MaxWidthView::alloc(mtm).set_ivars(MaxWidthIvars { + content: view.clone(), + cap: cap.clone(), + parent_constraints: RefCell::new(Vec::new()), + }); + let slot: Retained = unsafe { msg_send![super(slot), init] }; + slot.setTranslatesAutoresizingMaskIntoConstraints(false); + slot.setHidden(view.isHidden()); + slot.addSubview(&view); + let fills = view + .widthAnchor() + .constraintEqualToAnchor(&slot.widthAnchor()); + fills.setPriority(998.0); + let fits = view + .widthAnchor() + .constraintLessThanOrEqualToAnchor(&slot.widthAnchor()); + // An explicit setWidth applied later can still overflow a narrow parent. + fits.setPriority(999.0); + for constraint in [ + cap, + fills, + fits, + view.centerXAnchor() + .constraintEqualToAnchor(&slot.centerXAnchor()), + view.topAnchor().constraintEqualToAnchor(&slot.topAnchor()), + view.bottomAnchor() + .constraintEqualToAnchor(&slot.bottomAnchor()), + ] { + constraint.setActive(true); + } + let slot: Retained = slot.into_super(); + super::WIDGETS.with(|widgets| widgets.borrow_mut()[handle as usize - 1] = slot.clone()); + if let Some(window) = window { + window.setContentView(Some(&slot)); + } else if let Some(parent) = parent { + if let (Some(stack), Some(index)) = (parent.downcast_ref::(), stack_position) { + stack.insertView_atIndex_inGravity(&slot, index, NSStackViewGravity::Top); + } else { + parent.addSubview(&slot); + } + } + for (old, first, second) in external { + unsafe { + let replace = |item: Option>| { + item.map(|item| { + if Retained::as_ptr(&item).cast::() == Retained::as_ptr(&view) { + Retained::cast_unchecked(slot.clone()) + } else { + item + } + }) + }; + let first = replace(first); + let second = replace(second); + let constraint = NSLayoutConstraint::constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant( + first.as_deref().unwrap(), old.firstAttribute(), old.relation(), + second.as_deref(), old.secondAttribute(), old.multiplier(), old.constant(), + ); + constraint.setPriority(old.priority()); + constraint.setActive(true); + } + } +} diff --git a/crates/perry-ui-macos/src/widgets/mod.rs b/crates/perry-ui-macos/src/widgets/mod.rs index a9c3971c0f..927144d90b 100644 --- a/crates/perry-ui-macos/src/widgets/mod.rs +++ b/crates/perry-ui-macos/src/widgets/mod.rs @@ -19,6 +19,8 @@ pub mod image_gallery; pub mod keyboard; pub mod lazyvstack; pub mod map_view; +mod max_width; +pub use max_width::set_max_width; pub mod navstack; pub(crate) mod padding; pub mod pdf_view; @@ -162,7 +164,8 @@ pub extern "C" fn perry_ui_query_widget_tree(out_len: *mut usize) -> *mut u8 { let json = WIDGETS.with(|w| { let widgets = w.borrow(); let mut s = String::from("["); - for (i, view) in widgets.iter().enumerate() { + for (i, layout_view) in widgets.iter().enumerate() { + let view = max_width::content(layout_view.clone()); let handle = (i + 1) as i64; if i > 0 { s.push(','); } unsafe { @@ -251,6 +254,11 @@ pub fn register_external_nsview(nsview_ptr: i64) -> i64 { /// Retrieve the NSView for a given handle. pub fn get_widget(handle: i64) -> Option> { + get_layout_widget(handle).map(max_width::content) +} + +/// The view inserted into containers (including a max-width layout slot). +pub(crate) fn get_layout_widget(handle: i64) -> Option> { WIDGETS.with(|w| { let widgets = w.borrow(); let idx = (handle - 1) as usize; @@ -262,7 +270,11 @@ pub fn get_widget(handle: i64) -> Option> { /// When unhiding a view that NSStackView detached (no superview), re-inserts it /// into its parent NSStackView at the original position. pub fn set_hidden(handle: i64, hidden: bool) { - if let Some(view) = get_widget(handle) { + if let Some(view) = get_layout_widget(handle) { + let content = max_width::content(view.clone()); + if Retained::as_ptr(&content) != Retained::as_ptr(&view) { + content.setHidden(hidden); + } unsafe { let _: () = objc2::msg_send![&*view, setHidden: hidden]; } @@ -396,6 +408,14 @@ fn collect_subtree_handles(view: &NSView) -> Vec { } } } + if view.downcast_ref::().is_some() { + for child in view.subviews() { + if let Some(h) = find_handle_for_view(&child) { + handles.push(h); + } + handles.extend(collect_subtree_handles(&child)); + } + } handles } @@ -463,6 +483,9 @@ pub fn clear_children(handle: i64) { // Phase 3: Batch deactivate constraints on all views for sv in &views { + if sv.downcast_ref::().is_some() { + continue; + } unsafe { let constraints: Retained = objc2::msg_send![&**sv, constraints]; let c_count: usize = objc2::msg_send![&*constraints, count]; @@ -509,7 +532,9 @@ fn refresh_stack_parent_map(parent_handle: i64, stack: &NSStackView) { /// Insert or move a child at an index, retaining its own layout metadata. /// Perry stacks use the top/leading gravity area for both orientations. pub fn add_child_at(parent_handle: i64, child_handle: i64, index: i64) { - if let (Some(parent), Some(child)) = (get_widget(parent_handle), get_widget(child_handle)) { + if let (Some(parent), Some(child)) = + (get_widget(parent_handle), get_layout_widget(child_handle)) + { let is_stack = AnyClass::get(c"NSStackView") .map(|class| parent.isKindOfClass(class)) .unwrap_or(false); @@ -542,7 +567,9 @@ pub fn add_child_at(parent_handle: i64, child_handle: i64, index: i64) { /// Add a child view to a parent view. /// If the parent is an NSStackView, uses addArrangedSubview for proper layout. pub fn add_child(parent_handle: i64, child_handle: i64) { - if let (Some(parent), Some(child)) = (get_widget(parent_handle), get_widget(child_handle)) { + if let (Some(parent), Some(child)) = + (get_widget(parent_handle), get_layout_widget(child_handle)) + { // Check if parent is an NSStackView let is_stack = if let Some(cls) = AnyClass::get(c"NSStackView") { parent.isKindOfClass(cls) @@ -568,7 +595,9 @@ pub fn add_child(parent_handle: i64, child_handle: i64) { /// If the parent is an NSStackView, removes from arranged subviews first. /// Also cleans up metadata maps for the removed child and its descendants. pub fn remove_child(parent_handle: i64, child_handle: i64) { - if let (Some(parent), Some(child)) = (get_widget(parent_handle), get_widget(child_handle)) { + if let (Some(parent), Some(child)) = + (get_widget(parent_handle), get_layout_widget(child_handle)) + { let is_stack = if let Some(cls) = AnyClass::get(c"NSStackView") { parent.isKindOfClass(cls) } else { @@ -599,7 +628,9 @@ pub fn remove_child(parent_handle: i64, child_handle: i64) { /// The overlay floats on top of the parent's stack layout. /// Caller must set frame via widgetSetOverlayFrame(child, x, y, w, h). pub fn add_overlay(parent_handle: i64, child_handle: i64) { - if let (Some(parent), Some(child)) = (get_widget(parent_handle), get_widget(child_handle)) { + if let (Some(parent), Some(child)) = + (get_widget(parent_handle), get_layout_widget(child_handle)) + { // Always use plain addSubview (not addArrangedSubview) so it floats on top parent.addSubview(&child); } @@ -883,6 +914,7 @@ pub fn set_corner_radius(handle: i64, radius: f64) { /// Set a fixed width constraint on a widget. /// Idempotent: deactivates any previous width constraint before creating a new one. pub fn set_width(handle: i64, width: f64) { + max_width::clear_cap(handle); if let Some(view) = get_widget(handle) { // Deactivate old width constraint if any WIDTH_CONSTRAINTS.with(|wc| { @@ -956,7 +988,7 @@ pub fn set_hugging_priority(handle: i64, priority: f64) { /// the child's widthAnchor to that stack's widthAnchor. Useful for VStack /// children (especially embedded NSViews) that should stretch horizontally. pub fn match_stack_width(child_handle: i64) { - if let Some(child) = get_widget(child_handle) { + if let Some(child) = get_layout_widget(child_handle) { unsafe { // Walk up the superview chain to find the NSStackView. // NSStackView may wrap arranged subviews in intermediate views. @@ -995,7 +1027,7 @@ pub fn match_stack_width(child_handle: i64) { /// fill the parent's height. Useful for HStack children that should stretch /// vertically instead of being centered. pub fn match_parent_height(child_handle: i64) { - if let Some(child) = get_widget(child_handle) { + if let Some(child) = get_layout_widget(child_handle) { unsafe { let superview_ptr: *const NSView = msg_send![&*child, superview]; if superview_ptr.is_null() { @@ -1019,7 +1051,7 @@ pub fn match_parent_height(child_handle: i64) { /// Pin a child view's leading and trailing anchors to its superview, forcing it /// to fill the parent's width. pub fn match_parent_width(child_handle: i64) { - if let Some(child) = get_widget(child_handle) { + if let Some(child) = get_layout_widget(child_handle) { unsafe { let superview_ptr: *const NSView = msg_send![&*child, superview]; if superview_ptr.is_null() { diff --git a/crates/perry-ui-macos/src/widgets/padding.rs b/crates/perry-ui-macos/src/widgets/padding.rs index de62433062..e4e88ae435 100644 --- a/crates/perry-ui-macos/src/widgets/padding.rs +++ b/crates/perry-ui-macos/src/widgets/padding.rs @@ -239,6 +239,7 @@ pub(crate) fn set_edge_insets(view: &NSView, top: f64, left: f64, bottom: f64, r if let Some(cls) = AnyClass::get(c"NSStackView") { if view.isKindOfClass(cls) { let _: () = msg_send![view, setEdgeInsets: insets]; + super::max_width::refresh_children(view); return; } } diff --git a/crates/perry-ui-macos/src/widgets/scrollview.rs b/crates/perry-ui-macos/src/widgets/scrollview.rs index 35d2886669..cac77febf2 100644 --- a/crates/perry-ui-macos/src/widgets/scrollview.rs +++ b/crates/perry-ui-macos/src/widgets/scrollview.rs @@ -77,7 +77,7 @@ pub fn create() -> i64 { pub fn set_child(scroll_handle: i64, child_handle: i64) { if let (Some(scroll_view), Some(child)) = ( super::get_widget(scroll_handle), - super::get_widget(child_handle), + super::get_layout_widget(child_handle), ) { unsafe { let sv: &NSScrollView = &*(Retained::as_ptr(&scroll_view) as *const NSScrollView); @@ -128,16 +128,17 @@ pub fn set_child(scroll_handle: i64, child_handle: i64) { let _: () = msg_send![&*c5, setActive: true]; // If NSStackView, switch to GravityAreas, stretch children to fill width + let content = super::get_widget(child_handle).unwrap(); let stack_cls = AnyClass::get(c"NSStackView"); if let Some(cls) = stack_cls { - if (*child).isKindOfClass(cls) { + if (*content).isKindOfClass(cls) { // GravityAreas: children use intrinsic height - let _: () = msg_send![&*child, setDistribution: -1_isize]; + let _: () = msg_send![&*content, setDistribution: -1_isize]; // Change alignment from Leading to Width so children fill cross-axis // NSLayoutAttribute: Leading=5, Width=7 (fills cross-axis) - let _: () = msg_send![&*child, setAlignment: 7_isize]; + let _: () = msg_send![&*content, setAlignment: 7_isize]; - let arranged: Retained = msg_send![&*child, arrangedSubviews]; + let arranged: Retained = msg_send![&*content, arrangedSubviews]; let n: usize = msg_send![&*arranged, count]; for i in 0..n { let subview: *mut AnyObject = msg_send![&*arranged, objectAtIndex: i]; diff --git a/crates/perry-ui-macos/src/widgets/zstack.rs b/crates/perry-ui-macos/src/widgets/zstack.rs index e9b8cf44fa..95cfeb93c3 100644 --- a/crates/perry-ui-macos/src/widgets/zstack.rs +++ b/crates/perry-ui-macos/src/widgets/zstack.rs @@ -50,7 +50,7 @@ pub fn create() -> i64 { pub fn add_child(parent_handle: i64, child_handle: i64) { if let (Some(parent), Some(child)) = ( super::get_widget(parent_handle), - super::get_widget(child_handle), + super::get_layout_widget(child_handle), ) { unsafe { parent.addSubview(&child); diff --git a/crates/perry-ui-macos/tests/native_widget_max_width.rs b/crates/perry-ui-macos/tests/native_widget_max_width.rs new file mode 100644 index 0000000000..037111b0f5 --- /dev/null +++ b/crates/perry-ui-macos/tests/native_widget_max_width.rs @@ -0,0 +1,154 @@ +#[cfg(target_os = "macos")] +fn main() { + use objc2::rc::Retained; + use objc2_app_kit::{NSApplication, NSStackView, NSView}; + use objc2_core_foundation::CGSize; + use objc2_foundation::MainThreadMarker; + use perry_runtime as _; + use perry_ui_macos::{perry_ui_widget_set_max_width, widgets}; + + if std::env::args().any(|arg| arg == "--list") { + println!("native_widget_max_width: test"); + return; + } + let mtm = MainThreadMarker::new().unwrap(); + let _app = NSApplication::sharedApplication(mtm); + let host = NSView::new(mtm); + host.setFrameSize(CGSize::new(1400.0, 400.0)); + + for before_insertion in [true, false] { + let parent = widgets::vstack::create_with_insets(0.0, 0.0, 20.0, 0.0, 40.0); + let column = widgets::vstack::create_with_insets(0.0, 0.0, 32.0, 0.0, 32.0); + let label_text = b"Content"; + let string = perry_runtime::string::js_string_from_bytes( + label_text.as_ptr(), + label_text.len() as u32, + ); + let label = widgets::text::create(string.cast()); + widgets::add_child(column, label); + let parent_view = widgets::get_widget(parent).unwrap(); + parent_view.setTranslatesAutoresizingMaskIntoConstraints(false); + host.addSubview(&parent_view); + let width = parent_view.widthAnchor().constraintEqualToConstant(1200.0); + width.setActive(true); + parent_view + .heightAnchor() + .constraintEqualToConstant(200.0) + .setActive(true); + parent_view + .leadingAnchor() + .constraintEqualToAnchor(&host.leadingAnchor()) + .setActive(true); + parent_view + .topAnchor() + .constraintEqualToAnchor(&host.topAnchor()) + .setActive(true); + + let original = widgets::get_widget(column).unwrap(); + widgets::set_width(column, 900.0); + if before_insertion { + perry_ui_widget_set_max_width(column, 640.0); + } + widgets::add_child(parent, column); + if !before_insertion { + host.layoutSubtreeIfNeeded(); + perry_ui_widget_set_max_width(column, 640.0); + } + assert_eq!( + Retained::as_ptr(&widgets::get_widget(column).unwrap()), + Retained::as_ptr(&original) + ); + + let check = |parent_width: f64, cap: f64| { + width.setConstant(parent_width); + host.layoutSubtreeIfNeeded(); + let frame = original.convertRect_toView(original.bounds(), Some(&parent_view)); + let insets = parent_view + .downcast_ref::() + .unwrap() + .edgeInsets(); + let available = parent_width - insets.left - insets.right; + near(frame.size.width, available.min(cap), "width"); + near( + frame.origin.x, + insets.left + (available - available.min(cap)) / 2.0, + "center", + ); + let label_view = widgets::get_widget(label).unwrap(); + let label_frame = label_view.convertRect_toView(label_view.bounds(), Some(&original)); + // NSTextField frames extend two points beyond their alignment rect. + let aligned = label_view.alignmentRectForFrame(label_frame); + near(aligned.origin.x, 32.0, "content padding"); + }; + for size in [1200.0, 700.0, 400.0, 1200.0] { + check(size, 640.0); + } + perry_ui_widget_set_max_width(column, 480.0); + perry_ui_widget_set_max_width(column, 480.0); + check(1200.0, 480.0); + perry_ui_widget_set_max_width(column, f64::NAN); + perry_ui_widget_set_max_width(column, -1.0); + check(400.0, 480.0); + + // Fill-aligned stacks must not force capped content to become full-width. + widgets::set_alignment(parent, 7); + check(1200.0, 480.0); + widgets::set_hidden(column, true); + host.layoutSubtreeIfNeeded(); + widgets::set_hidden(column, false); + check(1200.0, 480.0); + + widgets::set_edge_insets(parent, 0.0, 30.0, 0.0, 50.0); + check(400.0, 480.0); + widgets::set_width(column, 900.0); + host.layoutSubtreeIfNeeded(); + near( + original.frame().size.width, + 900.0, + "fixed width overrides cap", + ); + perry_ui_widget_set_max_width(column, 480.0); + check(400.0, 480.0); + + // Moving a capped widget retains the cap and binds to the new parent. + let other = widgets::vstack::create(0.0); + let other_view = widgets::get_widget(other).unwrap(); + other_view.setTranslatesAutoresizingMaskIntoConstraints(false); + host.addSubview(&other_view); + widgets::set_width(other, 300.0); + widgets::set_height(other, 200.0); + widgets::add_child(other, column); + host.layoutSubtreeIfNeeded(); + near(original.frame().size.width, 300.0, "reparented width"); + widgets::add_child(parent, column); + check(1200.0, 480.0); + other_view.removeFromSuperview(); + + widgets::clear_children(parent); + widgets::add_child(parent, column); + check(1200.0, 480.0); + let stack = parent_view.downcast_ref::().unwrap(); + assert_eq!(stack.arrangedSubviews().len(), 1); + widgets::remove_child(parent, column); + assert!(stack.arrangedSubviews().is_empty()); + parent_view.removeFromSuperview(); + } + containers::check(mtm); + println!( + "PASS native max-width resizing, padding, updates, insertion, alignment and visibility" + ); + + fn near(actual: f64, expected: f64, label: &str) { + assert!( + (actual - expected).abs() < 0.51, + "{label}: expected {expected}, got {actual}" + ); + } +} + +#[cfg(not(target_os = "macos"))] +fn main() {} + +#[cfg(target_os = "macos")] +#[path = "native_widget_max_width/containers.rs"] +mod containers; diff --git a/crates/perry-ui-macos/tests/native_widget_max_width/containers.rs b/crates/perry-ui-macos/tests/native_widget_max_width/containers.rs new file mode 100644 index 0000000000..c345ce921c --- /dev/null +++ b/crates/perry-ui-macos/tests/native_widget_max_width/containers.rs @@ -0,0 +1,80 @@ +use objc2_app_kit::NSView; +use objc2_core_foundation::CGSize; +use objc2_foundation::MainThreadMarker; +use perry_ui_macos::{app, widgets}; + +pub fn check(mtm: MainThreadMarker) { + for before in [true, false] { + for scroll in [true, false] { + let host = NSView::new(mtm); + host.setFrameSize(CGSize::new(1200.0, 400.0)); + let parent = if scroll { + widgets::scrollview::create() + } else { + widgets::zstack::create() + }; + let parent_view = widgets::get_widget(parent).unwrap(); + host.addSubview(&parent_view); + parent_view.setTranslatesAutoresizingMaskIntoConstraints(false); + let width = parent_view.widthAnchor().constraintEqualToConstant(1000.0); + width.setActive(true); + parent_view + .heightAnchor() + .constraintEqualToConstant(300.0) + .setActive(true); + let column = widgets::vstack::create(0.0); + if before { + widgets::set_max_width(column, 640.0); + } + if scroll { + widgets::scrollview::set_child(parent, column); + } else { + widgets::add_child(parent, column); + } + host.layoutSubtreeIfNeeded(); + if !before { + widgets::set_max_width(column, 640.0); + } + for size in [1000.0, 400.0, 1000.0] { + width.setConstant(size); + host.layoutSubtreeIfNeeded(); + let column_view = widgets::get_widget(column).unwrap(); + let rect = column_view.convertRect_toView(column_view.bounds(), Some(&parent_view)); + assert!( + (rect.size.width - size.min(640.0)).abs() < 1.0, + "scroll={scroll}, before={before}: {rect:?}" + ); + assert!( + (rect.origin.x - (size - size.min(640.0)) / 2.0).abs() < 1.0, + "scroll={scroll}, before={before}: {rect:?}" + ); + } + } + let app_handle = app::app_create(std::ptr::null(), 1000.0, 400.0); + let root = widgets::vstack::create(0.0); + if before { + widgets::set_max_width(root, 640.0); + } + app::app_set_body(app_handle, root); + let root_view = widgets::get_widget(root).unwrap(); + let window = root_view.window().unwrap(); + window.contentView().unwrap().layoutSubtreeIfNeeded(); + if !before { + widgets::set_max_width(root, 640.0); + } + for width in [1000.0, 400.0, 1000.0] { + window.setContentSize(CGSize::new(width, 400.0)); + let content = window.contentView().unwrap(); + content.layoutSubtreeIfNeeded(); + let rect = root_view.convertRect_toView(root_view.bounds(), Some(&content)); + assert!( + (rect.size.width - width.min(640.0)).abs() < 1.0, + "root before={before}: {rect:?}" + ); + assert!( + (rect.origin.x - (width - width.min(640.0)) / 2.0).abs() < 1.0, + "root before={before}: {rect:?}" + ); + } + } +} diff --git a/crates/perry-ui-tvos/src/ffi/styling.rs b/crates/perry-ui-tvos/src/ffi/styling.rs index e1002d34a7..17f52185ca 100644 --- a/crates/perry-ui-tvos/src/ffi/styling.rs +++ b/crates/perry-ui-tvos/src/ffi/styling.rs @@ -179,3 +179,8 @@ pub extern "C" fn perry_ui_stack_set_alignment(handle: i64, alignment: f64) { } } } + +/// Responsive max-width is not implemented on this backend yet. +/// Export the shared ABI so portable perry/ui code continues to compile. +#[no_mangle] +pub extern "C" fn perry_ui_widget_set_max_width(_handle: i64, _max_width: f64) {} diff --git a/crates/perry-ui-visionos/src/ffi_layout.rs b/crates/perry-ui-visionos/src/ffi_layout.rs index 50feedd979..dd6c2b6506 100644 --- a/crates/perry-ui-visionos/src/ffi_layout.rs +++ b/crates/perry-ui-visionos/src/ffi_layout.rs @@ -270,3 +270,8 @@ pub extern "C" fn perry_ui_stack_set_alignment(handle: i64, alignment: f64) { } } } + +/// Responsive max-width is not implemented on this backend yet. +/// Export the shared ABI so portable perry/ui code continues to compile. +#[no_mangle] +pub extern "C" fn perry_ui_widget_set_max_width(_handle: i64, _max_width: f64) {} diff --git a/crates/perry-ui-watchos/src/lib.rs b/crates/perry-ui-watchos/src/lib.rs index f17bd64ad6..da1cf6b2f2 100644 --- a/crates/perry-ui-watchos/src/lib.rs +++ b/crates/perry-ui-watchos/src/lib.rs @@ -1964,3 +1964,8 @@ pub extern "C" fn perry_ui_canvas_draw_image( /// Frame persistence only applies to repositionable desktop windows. #[no_mangle] pub extern "C" fn perry_ui_app_set_frame_autosave_name(_app_handle: i64, _value_ptr: i64) {} + +/// Responsive max-width is not implemented on this backend yet. +/// Export the shared ABI so portable perry/ui code continues to compile. +#[no_mangle] +pub extern "C" fn perry_ui_widget_set_max_width(_handle: i64, _max_width: f64) {} diff --git a/crates/perry-ui-windows/src/ffi/mod.rs b/crates/perry-ui-windows/src/ffi/mod.rs index 814e52e43c..51bd1aeb7d 100644 --- a/crates/perry-ui-windows/src/ffi/mod.rs +++ b/crates/perry-ui-windows/src/ffi/mod.rs @@ -38,3 +38,8 @@ pub mod textfield_scroll; pub mod widget_create; pub mod widget_layout_extras; pub mod widget_tree_state; + +/// Responsive max-width is not implemented on this backend yet. +/// Export the shared ABI so portable perry/ui code continues to compile. +#[no_mangle] +pub extern "C" fn perry_ui_widget_set_max_width(_handle: i64, _max_width: f64) {} diff --git a/crates/perry-ui/src/styling_matrix.rs b/crates/perry-ui/src/styling_matrix.rs index edc5db2b58..f4b5a5c45e 100644 --- a/crates/perry-ui/src/styling_matrix.rs +++ b/crates/perry-ui/src/styling_matrix.rs @@ -284,6 +284,12 @@ pub const MATRIX: &[MatrixRow] = &[ ffi: "perry_ui_widget_set_width", statuses: W_ALL_NATIVE_WEB_TODO, }, + MatrixRow { + widget: "*", + prop: "max_width", + ffi: "perry_ui_widget_set_max_width", + statuses: [Wired, Stub, Stub, Stub, Stub, Stub, Stub, Stub, Wired], + }, MatrixRow { widget: "*", prop: "height", diff --git a/docs/src/ui/layout.md b/docs/src/ui/layout.md index 18b5f0d812..82fca762c1 100644 --- a/docs/src/ui/layout.md +++ b/docs/src/ui/layout.md @@ -161,6 +161,27 @@ Pin a child's edges to its parent container: - `widgetMatchParentWidth(widget)` — stretch to fill parent's width - `widgetMatchParentHeight(widget)` — stretch to fill parent's height +- `widgetSetMaxWidth(widget, maxWidth)` — fill the available width up to a cap and center horizontally (macOS and Web) + +For a responsive content column, apply the cap to the column and put padding +inside it. This keeps a 32-point content inset on narrow windows and grows the +outside gutters on wide windows: + +```typescript +import { App, VStack, Text, widgetSetMaxWidth, setPadding } from "perry/ui"; + +const column = VStack(12, [Text("Responsive content")]); +setPadding(column, 32); +widgetSetMaxWidth(column, 720); +App({ title: "Content", width: 1000, height: 600, body: VStack(0, [column]) }); +``` + +The cap includes padding and can be set before or after adding the widget to a +parent. Calling it again replaces the cap; it also replaces a previous fixed +`widgetSetWidth`. Negative and non-finite values are ignored; zero is valid. +macOS uses Auto Layout, and Web (JavaScript and WebAssembly) uses CSS `max-width` +with automatic side margins. iOS, tvOS, visionOS, watchOS, Android, GTK4, +Windows (including WinUI), and HarmonyOS currently accept this API as a no-op. ## Content Hugging diff --git a/docs/src/ui/styling-matrix.md b/docs/src/ui/styling-matrix.md index e40ce58cfa..ac8e62017b 100644 --- a/docs/src/ui/styling-matrix.md +++ b/docs/src/ui/styling-matrix.md @@ -21,6 +21,7 @@ Legend: `✓` Wired (real native impl), `~` Stub (symbol exists, no-op), `✗` M | `control_size` | `perry_ui_widget_set_control_size` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | `hugging` | `perry_ui_widget_set_hugging` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | `width` | `perry_ui_widget_set_width` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| `max_width` | `perry_ui_widget_set_max_width` | ✓ | ~ | ~ | ~ | ~ | ~ | ~ | ~ | ✓ | | `height` | `perry_ui_widget_set_height` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | `match_parent_width` | `perry_ui_widget_match_parent_width` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | `match_parent_height` | `perry_ui_widget_match_parent_height` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | @@ -85,13 +86,13 @@ Legend: `✓` Wired (real native impl), `~` Stub (symbol exists, no-op), `✗` M | Platform | Wired | Stub | Missing | Not applicable | |---|---|---|---|---| -| macOS | 47 | 0 | 0 | 0 | -| iOS | 47 | 0 | 0 | 0 | -| tvOS | 47 | 0 | 0 | 0 | -| visionOS | 47 | 0 | 0 | 0 | -| watchOS | 47 | 0 | 0 | 0 | -| Android | 47 | 0 | 0 | 0 | -| GTK4 | 47 | 0 | 0 | 0 | -| Windows | 47 | 0 | 0 | 0 | -| Web | 47 | 0 | 0 | 0 | +| macOS | 48 | 0 | 0 | 0 | +| iOS | 47 | 1 | 0 | 0 | +| tvOS | 47 | 1 | 0 | 0 | +| visionOS | 47 | 1 | 0 | 0 | +| watchOS | 47 | 1 | 0 | 0 | +| Android | 47 | 1 | 0 | 0 | +| GTK4 | 47 | 1 | 0 | 0 | +| Windows | 47 | 1 | 0 | 0 | +| Web | 48 | 0 | 0 | 0 | diff --git a/docs/src/ui/widgets.md b/docs/src/ui/widgets.md index ece7401069..2fb32eabbd 100644 --- a/docs/src/ui/widgets.md +++ b/docs/src/ui/widgets.md @@ -444,6 +444,7 @@ Every widget handle accepts these: | Helper | Description | |---|---| | `widgetSetWidth(w, n)` / `widgetSetHeight(w, n)` | Explicit size in points | +| `widgetSetMaxWidth(w, n)` | Responsive centered width cap, including padding (macOS and Web; no-op elsewhere) | | `widgetSetBackgroundColor(w, r, g, b, a)` | RGBA in [0, 1] | | `setCornerRadius(w, r)` | Rounded corners in points | | `widgetSetOpacity(w, alpha)` | Opacity in [0, 1] | diff --git a/types/perry/ui/index.d.ts b/types/perry/ui/index.d.ts index 76d53639a3..7e7187da70 100644 --- a/types/perry/ui/index.d.ts +++ b/types/perry/ui/index.d.ts @@ -889,6 +889,14 @@ export function widgetClearChildren(widget: Widget): void; export function widgetRemoveChild(parent: Widget, child: Widget): void; export function widgetReorderChild(widget: Widget, fromIndex: number, toIndex: number): void; export function widgetSetWidth(widget: Widget, width: number): void; +/** + * Fill the available parent width up to maxWidth and center horizontally. + * The cap includes the widget's padding. May be set before or after insertion; + * repeated calls replace the cap. Replaces an explicit widgetSetWidth. + * Invalid (negative or non-finite) caps are ignored; zero is valid. + * Supported on macOS and Web (JS/Wasm). Currently a no-op on other backends. + */ +export function widgetSetMaxWidth(widget: Widget, maxWidth: number): void; export function widgetSetHeight(widget: Widget, height: number): void; export function widgetSetHugging(widget: Widget, priority: number): void; export function widgetSetHidden(widget: Widget, hidden: number): void;