diff --git a/docs/astro/astro.config.mjs b/docs/astro/astro.config.mjs index 7c3357e3052..73483542ae0 100644 --- a/docs/astro/astro.config.mjs +++ b/docs/astro/astro.config.mjs @@ -178,6 +178,10 @@ export default defineConfig({ label: "Navigation", slug: "guide/development/navigation", }, + { + label: "Federated Navigation", + slug: "guide/development/navigation-federation", + }, "guide/development/best-practices", "guide/development/third-party-libraries", ], diff --git a/docs/astro/src/content/docs/guide/development/navigation-federation.mdx b/docs/astro/src/content/docs/guide/development/navigation-federation.mdx new file mode 100644 index 00000000000..597846ef09e --- /dev/null +++ b/docs/astro/src/content/docs/guide/development/navigation-federation.mdx @@ -0,0 +1,159 @@ +--- +title: Federated Navigation +description: Compose an app from independently-authored parts using navigation contracts, mounts, and host capabilities. +--- + +Large apps are often built by several teams, each owning a feature and shipping +on its own release cycle. Federation lets each team declare its screens and the +contract at its boundary, and lets an integration layer compose them into one +app, without any team editing another's code. + +It builds on the [`navigator`](navigation) construct: each part runs its own +navigator internally, and the integrator mounts the part at a route. + +:::caution[Experimental] +These constructs are experimental. Build with +`SLINT_ENABLE_EXPERIMENTAL_FEATURES=1` (or set `enable_experimental` in the +compiler configuration) to use them. +::: + +## The navigation contract + +A contract is an `interface` that declares the routes a part exposes at its +boundary. It is the only thing the integrator and the part have to agree on. + +```slint +export interface FeatureNav { + @version(1) + @uri("app://feature") route Main; +} +``` + +- `route Main;` declares an entry route. A route may take parameters, for + example `route Details(id: int);`, for deep links that carry data. +- `@version(n)` versions the contract, so an integrator can tell which revision + a part was built against. +- `@uri("…")` gives a route a stable deep-link address. + +A contract may also declare the capabilities the host provides to a mounted +part: + +```slint +export interface HostServices { + callback log(string); + callback go-home(); +} +``` + +## Implementing a contract + +A part conforms with `implement <=> self;`, which checks at compile +time that its navigator covers every route the contract declares. It declares the +capabilities it `needs` from its host, and runs its own navigator internally: + +```slint +import { FeatureNav, HostServices } from "contract.slint"; + +enum MediaRoute { Main, Player } + +export component MediaFeature inherits Rectangle { + implement FeatureNav <=> self; + needs HostServices; + in-out property current-route: MediaRoute.Main; + + init => { root.log("media: mounted"); } + + navigator (current-route) { + MediaRoute.Main: Library { + play => { root.navigate(MediaRoute.Player); } + home => { root.go-home(); } + } + MediaRoute.Player: Player { + go-back => { root.back(); } + } + } +} +``` + +`needs HostServices` makes `log` and `go-home` usable inside the part as if it +declared them, but leaves them unbound: the integrator binds them at the mount. + +## Mounting a part + +The integration shell mounts a part at one of its own routes with +`mount … via Contract`, and binds the capabilities the part needs right there: + +```slint +import { FeatureNav } from "contract.slint"; +import { MediaFeature } from "media.slint"; + +enum Shell { Home, Media } + +export component App inherits Window { + in-out property current: Shell.Home; + + navigator (current) { + Shell.Home: HomeScreen { + open-media => { root.navigate(Shell.Media); } + } + Shell.Media: mount MediaFeature via FeatureNav { + log(message) => { debug(message); } + go-home => { root.navigate(Shell.Home); } + } + } +} +``` + +`mount MediaFeature via FeatureNav` instantiates the part directly and checks it +against the contract at build time. An unbound `needs` capability is a compile +error at the mount site, so the integration never ships a part missing a service +it requires. + +## External parts + +A part built and shipped on its own cycle is not a compile-time dependency. +Mount it with `mount extern via Contract` and hand it a `ComponentFactory` at +runtime: + +```slint +export component App inherits Window { + in property plugin-factory; + // ... + navigator (current) { + // ... + Shell.Plugin: mount extern via FeatureNav { + component-factory: root.plugin-factory; + } + } +} +``` + +The host supplies the factory. Here it loads the part from source at runtime +with the interpreter, but it could equally be a separately compiled binary: + +```rust +let factory = slint::ComponentFactory::new(|ctx| { + let compiler = slint_interpreter::Compiler::new(); + let def = spin_on::spin_on( + compiler.build_from_source(plugin_source, "plugin.slint".into()), + ) + .component("Plugin") + .unwrap(); + def.create_embedded(ctx).ok() +}); +app.set_plugin_factory(factory); +``` + +The external part renders into the mount's slot; the seam is a +`ComponentContainer`, so nothing about the shell has to be recompiled when the +part changes. + +## Why federation + +The contract is the whole coupling surface. Because it is declared structure +checked by the compiler, an integration team can compose parts it did not write, +each part can be versioned and deep-linked, and a part can be swapped, at build +time or at runtime, without either side reaching into the other's internals. + +A runnable version lives in `examples/navigation-federated` in the Slint +repository. diff --git a/examples/Cargo.lock b/examples/Cargo.lock index 066b9eabaab..905ff6188c9 100644 --- a/examples/Cargo.lock +++ b/examples/Cargo.lock @@ -6046,6 +6046,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d758ba1b47b00caf47f24925c0074ecb20d6dfcffe7f6d53395c0465674841a" +[[package]] +name = "generativity" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c81fb5260e37854d09d5c87183309fd8c555b75289427884b25660bc87a85e" + [[package]] name = "generic-array" version = "0.14.7" @@ -7191,6 +7197,17 @@ dependencies = [ "xkbcommon", ] +[[package]] +name = "i-slint-backend-qt" +version = "1.18.0" +dependencies = [ + "const-field-offset", + "i-slint-common", + "i-slint-core", + "i-slint-core-macros", + "vtable", +] + [[package]] name = "i-slint-backend-selector" version = "1.18.0" @@ -7199,6 +7216,7 @@ dependencies = [ "cfg_aliases", "i-slint-backend-android-activity", "i-slint-backend-linuxkms", + "i-slint-backend-qt", "i-slint-backend-testing", "i-slint-backend-winit", "i-slint-common", @@ -9372,6 +9390,15 @@ dependencies = [ "slint", ] +[[package]] +name = "navigation-federated" +version = "1.18.0" +dependencies = [ + "slint", + "slint-interpreter", + "spin_on", +] + [[package]] name = "navigation-std" version = "1.18.0" @@ -14305,6 +14332,27 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "slint-interpreter" +version = "1.18.0" +dependencies = [ + "derive_more", + "generativity", + "i-slint-backend-selector", + "i-slint-backend-winit", + "i-slint-common", + "i-slint-compiler", + "i-slint-core", + "i-slint-core-macros", + "itertools 0.14.0", + "lyon_path", + "once_cell", + "smol_str 0.3.6", + "unicode-segmentation", + "vtable", + "web-sys", +] + [[package]] name = "slint-macros" version = "1.18.0" @@ -15137,7 +15185,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", diff --git a/examples/Cargo.toml b/examples/Cargo.toml index c319c99fcd0..e0b3b4a5132 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -23,6 +23,7 @@ members = [ 'mcu-board-support', 'memory', 'navigation', + 'navigation-federated', 'navigation-std', 'native-gestures', 'opengl_texture', diff --git a/examples/navigation-federated/Cargo.toml b/examples/navigation-federated/Cargo.toml new file mode 100644 index 00000000000..c983438fbba --- /dev/null +++ b/examples/navigation-federated/Cargo.toml @@ -0,0 +1,20 @@ +# Copyright © SixtyFPS GmbH +# SPDX-License-Identifier: MIT + +[package] +name = "navigation-federated" +version = "1.18.0" +authors = ["Slint Developers "] +edition.workspace = true +publish = false +license = "MIT" +rust-version.workspace = true + +[[bin]] +path = "main.rs" +name = "navigation-federated" + +[dependencies] +slint = { path = "../../api/rs/slint" } +slint-interpreter = { path = "../../internal/interpreter" } +spin_on = { version = "0.1" } diff --git a/examples/navigation-federated/README.md b/examples/navigation-federated/README.md new file mode 100644 index 00000000000..18afeee0371 --- /dev/null +++ b/examples/navigation-federated/README.md @@ -0,0 +1,43 @@ +# Navigation (federated, multi-team) + +A multi-screen app composed from **independently-authored feature modules** using +the experimental `navigator` federation seams. It mirrors how separate teams, +each on their own release cycle, plug features into one app that an integration +team assembles. + +## Who owns what + +| File | Owner | Role | +| --- | --- | --- | +| `ui/contract.slint` | platform team | the `FeatureNav` contract (`@version` / `@uri`) + `HostServices` capabilities | +| `ui/media.slint` | Media team | `MediaFeature` with `implement FeatureNav <=> self`, `needs HostServices`, its own navigator | +| `ui/settings.slint` | Settings team | `SettingsFeature`, same contract | +| `ui/app.slint` | integration team | mounts each feature at a shell route and binds the capabilities | +| `main.rs` | integration team | supplies the external plugin as a `ComponentFactory` | + +The example shows every federation seam: + +- **build-time mount** — `mount MediaFeature via FeatureNav { ... }` instantiates a + compile-time feature and binds the capabilities it `needs`. +- **external mount** — `mount extern via FeatureNav { component-factory: ... }` mounts + a plugin delivered at runtime. `main.rs` builds it via `slint_interpreter` and passes + it in as a `slint::ComponentFactory`; it could equally be shipped as its own binary. +- **versioned, deep-linkable contract** — `@version(1)` and `@uri("app://feature")` on + the contract's routes. + +No team edits another team's file. + +## Experimental features + +`navigator` and its federation seams are experimental. `build.rs` enables them +for the `slint!` macro: + +```rust +println!("cargo:rustc-env=SLINT_ENABLE_EXPERIMENTAL_FEATURES=1"); +``` + +Run it with: + +```sh +cargo run -p navigation-federated +``` diff --git a/examples/navigation-federated/build.rs b/examples/navigation-federated/build.rs new file mode 100644 index 00000000000..5a89deada0d --- /dev/null +++ b/examples/navigation-federated/build.rs @@ -0,0 +1,6 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: MIT + +fn main() { + println!("cargo:rustc-env=SLINT_ENABLE_EXPERIMENTAL_FEATURES=1"); +} diff --git a/examples/navigation-federated/main.rs b/examples/navigation-federated/main.rs new file mode 100644 index 00000000000..4acd601e486 --- /dev/null +++ b/examples/navigation-federated/main.rs @@ -0,0 +1,41 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: MIT + +slint::slint!(export { App } from "ui/app.slint";); + +// The external plugin is an independently-delivered part. Here it is loaded from +// source at runtime via the interpreter, but it could equally be a separately +// compiled binary. The shell mounts it through `mount extern via FeatureNav`. +fn plugin_factory() -> slint::ComponentFactory { + slint::ComponentFactory::new(|ctx| { + let compiler = slint_interpreter::Compiler::new(); + let def = spin_on::spin_on( + compiler.build_from_source( + r#" +export component Plugin inherits Rectangle { + background: #2b2b40; + VerticalLayout { + alignment: center; + Text { + text: "External plugin\n(loaded via ComponentFactory)"; + color: white; + horizontal-alignment: center; + } + } +} +"# + .into(), + std::path::PathBuf::from("plugin.slint"), + ), + ) + .component("Plugin") + .unwrap(); + def.create_embedded(ctx).ok() + }) +} + +pub fn main() { + let app = App::new().unwrap(); + app.set_plugin_factory(plugin_factory()); + app.run().unwrap(); +} diff --git a/examples/navigation-federated/ui/app.slint b/examples/navigation-federated/ui/app.slint new file mode 100644 index 00000000000..bbfdbe873b7 --- /dev/null +++ b/examples/navigation-federated/ui/app.slint @@ -0,0 +1,66 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: MIT + +import { FeatureNav, HostServices } from "contract.slint"; +import { MediaFeature } from "media.slint"; +import { SettingsFeature } from "settings.slint"; +import { Button, VerticalBox } from "std-widgets.slint"; + +enum Shell { + Home, + Media, + Settings, + Plugin, +} + +component HomeScreen inherits VerticalBox { + callback open-media(); + callback open-settings(); + callback open-plugin(); + Text { text: "Federated App"; font-size: 26px; horizontal-alignment: center; } + Button { text: "Open Media"; clicked => { root.open-media(); } } + Button { text: "Open Settings"; clicked => { root.open-settings(); } } + Button { text: "Open Plugin (external)"; clicked => { root.open-plugin(); } } +} + +export component App inherits Window { + preferred-width: 360px; + preferred-height: 480px; + title: "Slint Federated Navigation"; + + // Supplied by the host (main.rs) as a ComponentFactory, so the plugin is + // delivered at runtime rather than as a compile-time dependency. + in property plugin-factory; + + in-out property current: Shell.Home; + + // The navigator must be a direct child of the component that declares it. + navigator (current) { + Shell.Home: HomeScreen { + open-media => { root.navigate(Shell.Media); } + open-settings => { root.navigate(Shell.Settings); } + open-plugin => { root.navigate(Shell.Plugin); } + } + Shell.Media: mount MediaFeature via FeatureNav { + log(message) => { debug(message); } + go-home => { root.navigate(Shell.Home); } + } + Shell.Settings: mount SettingsFeature via FeatureNav { + log(message) => { debug(message); } + go-home => { root.navigate(Shell.Home); } + } + Shell.Plugin: mount extern via FeatureNav { + component-factory: root.plugin-factory; + } + } + + // Persistent home control, so the external plugin (which has no capability + // wiring back to the shell) can still be exited. + if root.current != Shell.Home: Button { + text: "⌂ Home"; + width: 96px; + x: parent.width - self.width - 8px; + y: 8px; + clicked => { root.navigate(Shell.Home); } + } +} diff --git a/examples/navigation-federated/ui/contract.slint b/examples/navigation-federated/ui/contract.slint new file mode 100644 index 00000000000..1f999faf16c --- /dev/null +++ b/examples/navigation-federated/ui/contract.slint @@ -0,0 +1,12 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: MIT + +export interface FeatureNav { + @version(1) + @uri("app://feature") route Main; +} + +export interface HostServices { + callback log(string); + callback go-home(); +} diff --git a/examples/navigation-federated/ui/media.slint b/examples/navigation-federated/ui/media.slint new file mode 100644 index 00000000000..6de4c31847f --- /dev/null +++ b/examples/navigation-federated/ui/media.slint @@ -0,0 +1,45 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: MIT + +import { FeatureNav, HostServices } from "contract.slint"; +import { Button, VerticalBox } from "std-widgets.slint"; + +enum MediaRoute { + Main, + Player, +} + +component Library inherits VerticalBox { + callback play(); + callback home(); + Text { text: "Media"; font-size: 22px; horizontal-alignment: center; } + Button { text: "Play"; clicked => { root.play(); } } + Button { text: "Back to home"; clicked => { root.home(); } } +} + +component Player inherits VerticalBox { + in property can-go-back; + callback go-back(); + Text { text: "Now Playing"; font-size: 22px; horizontal-alignment: center; } + Button { text: "Stop"; enabled: can-go-back; clicked => { root.go-back(); } } +} + +export component MediaFeature inherits Rectangle { + implement FeatureNav <=> self; + needs HostServices; + + in-out property current-route: MediaRoute.Main; + + init => { root.log("media: mounted"); } + + navigator (current-route) { + MediaRoute.Main: Library { + play => { root.navigate(MediaRoute.Player); } + home => { root.go-home(); } + } + MediaRoute.Player: Player { + can-go-back: root.can-go-back; + go-back => { root.back(); } + } + } +} diff --git a/examples/navigation-federated/ui/settings.slint b/examples/navigation-federated/ui/settings.slint new file mode 100644 index 00000000000..deb84cb60f2 --- /dev/null +++ b/examples/navigation-federated/ui/settings.slint @@ -0,0 +1,31 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: MIT + +import { FeatureNav, HostServices } from "contract.slint"; +import { Button, CheckBox, VerticalBox } from "std-widgets.slint"; + +enum SettingsRoute { + Main, +} + +component SettingsPage inherits VerticalBox { + callback home(); + Text { text: "Settings"; font-size: 22px; horizontal-alignment: center; } + CheckBox { text: "Dark mode"; } + Button { text: "Back to home"; clicked => { root.home(); } } +} + +export component SettingsFeature inherits Rectangle { + implement FeatureNav <=> self; + needs HostServices; + + in-out property current-route: SettingsRoute.Main; + + init => { root.log("settings: mounted"); } + + navigator (current-route) { + SettingsRoute.Main: SettingsPage { + home => { root.go-home(); } + } + } +} diff --git a/internal/compiler/object_tree.rs b/internal/compiler/object_tree.rs index 522f9f7a598..21db2d22b47 100644 --- a/internal/compiler/object_tree.rs +++ b/internal/compiler/object_tree.rs @@ -495,6 +495,46 @@ pub struct Component { /// True if this component is imported from an external library. pub from_library: Cell, + + /// The navigation contract declared by an `interface`'s `route` members, if + /// any. Kept apart from the interface's property/callback/function members so + /// interface conformance never sees routes (see object_tree/interfaces.rs). + pub navigation_contract: RefCell>, +} + +/// One `route` member of a navigation contract (an `interface`'s `route` +/// declarations). Collected in declaration order by `Component::from_node`. +#[derive(Debug, Clone)] +pub struct ContractRoute { + /// The route name (the `DeclaredIdentifier`, e.g. `Home`). + pub name: SmolStr, + /// Typed parameters declared with the route (e.g. `id: int`). + pub params: Vec<(SmolStr, Type)>, + /// The deep-link URI declared with `@uri("...")`, if any. + pub uri: Option, +} + +/// A versionable navigation boundary declared as the `route` members of an +/// `interface`. Reuses the `interface`/export machinery so the contract exports +/// and imports across files like any other interface type. +#[derive(Debug, Clone, Default)] +pub struct NavigationContract { + /// The declared routes, in source order. + pub routes: Vec, + /// The compile-time contract version from `@version(n)`; absent means 1. + pub version: Option, +} + +/// The record of one `needs `: the interface named and the member +/// names (callbacks/functions) pulled onto the component as unbound members. The +/// federated-mount verifier requires each be bound at the integration site. +#[derive(Debug, Clone)] +pub struct NeededCapability { + /// The capability interface named by the `needs` specifier. + pub interface_name: SmolStr, + /// The interface members declared unbound on the component, to be bound by + /// the host. In interface declaration order. + pub members: Vec, } impl Component { @@ -544,9 +584,75 @@ impl Component { *qualified_id = format_smolstr!("{}::{}", c.id, qualified_id); } }); + // An interface's `route` members form a navigation contract, collected on + // the Component (not its member lists) so conformance never sees routes. + // Misplaced/ungated routes are diagnosed in `Element::from_node`. + if c.is_interface() { + let routes = node + .Element() + .RouteDeclaration() + .filter_map(|route| { + let name = parser::identifier_text(&route.DeclaredIdentifier())?; + let params = route + .ArgumentDeclaration() + .filter_map(|a| { + Some(( + parser::identifier_text(&a.DeclaredIdentifier())?, + type_from_node(a.Type(), diag, tr), + )) + }) + .collect(); + // `@uri("...")`: unescape the string literal; last wins. + let uri = route.AtUri().last().and_then(|attr| { + let raw = attr.text().to_string(); + match crate::literals::unescape_string(raw.trim()) { + Some(s) => Some(s), + None => { + diag.push_error( + "@uri expects a string literal argument".into(), + &attr, + ); + None + } + } + }); + Some(ContractRoute { name, params, uri }) + }) + .collect::>(); + // `@version(n)`: an interface-level integer literal; last wins. + let version = node.Element().AtVersion().last().and_then(|attr| { + let raw = attr.text().to_string(); + match raw.trim().parse::() { + Ok(v) => Some(v), + Err(_) => { + diag.push_error( + "@version expects an integer literal argument".into(), + &attr, + ); + None + } + } + }); + if !routes.is_empty() || version.is_some() { + *c.navigation_contract.borrow_mut() = Some(NavigationContract { routes, version }); + } + } c } + /// Read-only view of the navigation contract declared by this component's + /// `route` members, if it is an `interface` declaring any. + pub fn navigation_contract(&self) -> Option { + self.navigation_contract.borrow().clone() + } + + /// The capabilities this component declares via `needs` on its root, if any. + /// Read by the federated-mount verifier to require each be bound at the mount + /// site. Empty when the component needs nothing from its host. + pub fn needed_capabilities(&self) -> Vec { + self.root_element.borrow().needed_capabilities.clone() + } + /// This component is a global component introduced with the "global" keyword pub fn is_global(&self) -> bool { match &self.root_element.borrow().base_type { @@ -877,6 +983,10 @@ pub struct Element { pub repeated: Option, /// The resolved `navigator` route table, in declaration order. pub navigator_routes: Vec, + /// Capabilities this element declares via `needs `. Populated + /// when the element is a component root; read by the federated-mount verifier + /// off the mounted component's root. Empty for elements with no `needs`. + pub needed_capabilities: Vec, /// This element is a placeholder to embed an Component at pub is_component_placeholder: bool, @@ -1135,6 +1245,28 @@ pub struct RepeatedElementInfo { pub struct NavigatorRoute { pub route: syntax_nodes::Expression, pub component: ElementRc, + /// Set when this route is a build-time federated mount + /// (`mount Impl via Contract`); distinguishes a mounted sub-graph from a + /// plain screen for tooling. `None` for a plain route destination. + pub mount: Option, +} + +/// A route destination that is a federated mount against a named navigation +/// contract. Metadata only. `impl_name = None` marks an external mount whose +/// implementation is host-supplied at runtime rather than in this build. +#[derive(Debug, Clone)] +pub struct FederatedMount { + /// The mounted component's name, or `None` for an external mount. + pub impl_name: Option, + pub contract_name: SmolStr, + /// The contract's `@version`, if declared. Metadata, not a compat check. + pub contract_version: Option, +} + +impl FederatedMount { + pub fn is_external(&self) -> bool { + self.impl_name.is_none() + } } impl Element { @@ -1250,6 +1382,22 @@ impl Element { tr.empty_type() }; let is_interface = base_type == ElementType::Interface; + // `route` members declare a navigation contract; they are experimental + // and valid only in an `interface` body. Valid routes are collected onto + // the Component in `Component::from_node`; here we only reject misuse. + for route in node.RouteDeclaration() { + if !diag.enable_experimental && !tr.expose_internal_types { + diag.push_error( + "navigation 'route' members are an experimental feature".into(), + &route, + ); + } else if !is_interface { + diag.push_error( + "'route' members are only allowed inside an 'interface'".into(), + &route, + ); + } + } // This isn't truly qualified yet, the enclosing component is added at the end of Component::from_node let qualified_id = (!id.is_empty()).then(|| id.clone()); if let ElementType::Component(c) = &base_type { @@ -1558,6 +1706,11 @@ impl Element { interfaces::apply_callbacks(&mut r, &implemented_interfaces, diag); + // `needs ` declares its callbacks as unbound members here, bound + // by the host at the mount site. + let needed_interfaces = interfaces::get_needed_interfaces(&r, &node, tr, diag); + r.needed_capabilities = interfaces::apply_needs(&mut r, &needed_interfaces, diag); + for func in node.Function() { #[cfg(feature = "slint-sc")] diag.slint_sc_error("Function declarations are", &func); @@ -2028,6 +2181,9 @@ impl Element { interfaces::validate_function_implementations(&r.borrow(), &implemented_interfaces, diag); interfaces::apply_child_implement_statements(&r, child_implements, diag); + // Late (after children built, so the navigator route table is populated): + // a component implementing a navigation contract must cover its routes. + interfaces::validate_navigation_contract_conformance(&r, &implemented_interfaces, diag); r } @@ -2256,9 +2412,18 @@ impl Element { let mut cases: Vec = Vec::new(); let mut routes: Vec = Vec::new(); for route in node.Route() { - let Some(sub_element) = route.SubElement() else { - continue; - }; + // A destination is a plain sub-element, a local mount, or an external + // (cross-process) mount; each desugars to a conditional child. + let mount_node = route.MountDestination(); + let external = mount_node.as_ref().is_some_and(Self::mount_is_external); + let (site, sub_element): (SyntaxNode, syntax_nodes::SubElement) = + if let Some(mount_node) = &mount_node { + (mount_node.clone().into(), mount_node.SubElement()) + } else if let Some(sub_element) = route.SubElement() { + (sub_element.clone().into(), sub_element) + } else { + continue; + }; let rei = RepeatedElementInfo { model: Expression::BinaryExpression { lhs: Box::new(Expression::Uncompiled(expr.clone().into())), @@ -2270,27 +2435,43 @@ impl Element { is_conditional_element: true, is_listview: None, }; - let e = Element::from_sub_element_node( - sub_element.clone(), - parent_type.clone(), - component_child_insertion_point, - is_in_legacy_component, - diag, - tr, - ); + // External mounts build a ComponentContainer directly; others resolve + // their base name. + let e = if external { + Self::from_external_mount_node(&sub_element.Element(), tr, diag) + } else { + Element::from_sub_element_node( + sub_element, + parent_type.clone(), + component_child_insertion_point, + is_in_legacy_component, + diag, + tr, + ) + }; match &e.borrow().base_type { ElementType::Component(_) | ElementType::Error => {} + ElementType::Builtin(b) if external && b.name == "ComponentContainer" => {} other => { diag.push_error( format!( "navigator route destination must be a component, but '{other}' is not" ), - &sub_element, + &site, ); } } + // A local mount verifies the impl against the contract; an external one + // only checks a `component-factory` is bound. `None` for a plain screen. + let mount = mount_node.and_then(|mount_node| { + if external { + Self::verify_external_mount(&mount_node, &e, &parent_type, tr, diag) + } else { + Self::verify_federated_mount(&mount_node, &e, &parent_type, tr, diag) + } + }); e.borrow_mut().repeated = Some(rei); - routes.push(NavigatorRoute { route: route.Expression(), component: e.clone() }); + routes.push(NavigatorRoute { route: route.Expression(), component: e.clone(), mount }); cases.push(e); } // Members must be declared before expression resolution so chrome can bind them. @@ -2301,6 +2482,181 @@ impl Element { cases } + /// Verify a federated mount: the mounted component must satisfy the contract + /// named after `via`. Returns the mount edge, or `None` after a diagnostic. + fn verify_federated_mount( + mount_node: &syntax_nodes::MountDestination, + dest: &ElementRc, + parent_type: &ElementType, + tr: &TypeRegister, + diag: &mut BuildDiagnostics, + ) -> Option { + // `Error` means the name failed to resolve, already reported upstream. + let impl_comp = match &dest.borrow().base_type { + ElementType::Component(c) => c.clone(), + _ => return None, + }; + let (contract_name, contract) = + Self::resolve_mount_contract(mount_node, parent_type, tr, diag)?; + let impl_name = impl_comp.id.clone(); + let conforms = interfaces::validate_mount_conformance( + &impl_name, + &impl_comp.root_element, + &contract_name, + &contract, + mount_node, + diag, + ); + let all_bound = + Self::verify_mount_capabilities(&impl_name, &impl_comp, dest, mount_node, diag); + (conforms && all_bound).then(|| FederatedMount { + impl_name: Some(impl_name), + contract_name, + contract_version: contract.version, + }) + } + + /// True for `mount extern via C { ... }`: the `extern` soft keyword sits as a + /// direct token of the MountDestination (a local mount has none). + fn mount_is_external(mount_node: &syntax_nodes::MountDestination) -> bool { + mount_node.children_with_tokens().any(|n| { + n.as_token().is_some_and(|t| t.kind() == SyntaxKind::Identifier && t.text() == "extern") + }) + } + + /// Resolve the contract named after `via` to a navigation-contract interface. + /// Shared by local and external mounts. + fn resolve_mount_contract( + mount_node: &syntax_nodes::MountDestination, + parent_type: &ElementType, + tr: &TypeRegister, + diag: &mut BuildDiagnostics, + ) -> Option<(SmolStr, NavigationContract)> { + let Some(contract_node) = + mount_node.SubElement().Element().MountVia().map(|via| via.QualifiedName()) + else { + // Missing MountVia means a malformed mount, already diagnosed. + return None; + }; + let contract_name = QualifiedTypeName::from_node(contract_node.clone()).to_smolstr(); + let contract_comp = match parent_type.lookup_type_for_child_element(&contract_name, tr) { + Ok(ElementType::Component(c)) if c.is_interface() => c, + Ok(_) => { + diag.push_error( + format!("mount contract '{contract_name}' is not an interface"), + &contract_node, + ); + return None; + } + Err(err) => { + diag.push_error(err, &contract_node); + return None; + } + }; + let Some(contract) = contract_comp.navigation_contract().filter(|c| !c.routes.is_empty()) + else { + diag.push_error( + format!("'{contract_name}' is not a navigation contract: it declares no routes"), + &contract_node, + ); + return None; + }; + Some((contract_name, contract)) + } + + /// Verify an external mount: the contract resolves and a `component-factory` + /// is bound. There is no compile-time impl to conformance-check. + fn verify_external_mount( + mount_node: &syntax_nodes::MountDestination, + dest: &ElementRc, + parent_type: &ElementType, + tr: &TypeRegister, + diag: &mut BuildDiagnostics, + ) -> Option { + let (contract_name, contract) = + Self::resolve_mount_contract(mount_node, parent_type, tr, diag)?; + // The host sets this factory at runtime; without it the seam has nothing. + if !dest.borrow().bindings.contains_key("component-factory") { + diag.push_error( + format!("external mount via '{contract_name}' requires a 'component-factory'"), + mount_node, + ); + return None; + } + Some(FederatedMount { impl_name: None, contract_name, contract_version: contract.version }) + } + + /// Build an external mount's destination: a `ComponentContainer` the host fills + /// at runtime. No base name, so the container is looked up directly. + fn from_external_mount_node( + element_node: &syntax_nodes::Element, + tr: &TypeRegister, + diag: &mut BuildDiagnostics, + ) -> ElementRc { + let base_type = tr.lookup_builtin_element("ComponentContainer").unwrap_or_else(|| { + debug_assert!(false, "ComponentContainer builtin missing"); + ElementType::Error + }); + let r = Element { + id: SmolStr::default(), + base_type: base_type.clone(), + debug: vec![ElementDebugInfo { + qualified_id: None, + element_hash: 0, + type_name: base_type.type_name().unwrap_or_default().to_string(), + node: element_node.clone(), + layout: None, + element_boundary: false, + }], + ..Default::default() + } + .make_rc(); + apply_default_type_properties(&mut r.borrow_mut()); + // Attach the block's bindings through the shared path so the usual + // unknown-property and type checks run. + r.borrow_mut().parse_bindings( + element_node.Binding().filter_map(|b| { + Some((b.child_token(SyntaxKind::Identifier)?, b.BindingExpression().into())) + }), + false, + diag, + ); + r + } + + /// Verify that the mount block binds every capability the mounted module + /// declares via `needs`. A need with no binding is a compile error at the + /// mount site. Returns true when all needs are bound. + fn verify_mount_capabilities( + impl_name: &SmolStr, + impl_comp: &Rc, + dest: &ElementRc, + mount_node: &syntax_nodes::MountDestination, + diag: &mut BuildDiagnostics, + ) -> bool { + let needs = impl_comp.needed_capabilities(); + if needs.is_empty() { + return true; + } + let dest = dest.borrow(); + let mut all_bound = true; + for cap in &needs { + for member in &cap.members { + if !dest.bindings.contains_key(member) { + diag.push_error( + format!( + "mount of '{impl_name}' does not bind required capability '{member}' (from '{}')", + cap.interface_name + ), + mount_node, + ); + all_bound = false; + } + } + } + all_bound + } + /// Declare the navigator's public members; `lower_navigator` fills the bodies later. fn declare_navigator_members(elem: &ElementRc, routes: &[NavigatorRoute], tr: &TypeRegister) { // Recover the route enum from a route case for navigate(route). diff --git a/internal/compiler/object_tree/interfaces.rs b/internal/compiler/object_tree/interfaces.rs index 70b450e48ca..09f0fcfd50e 100644 --- a/internal/compiler/object_tree/interfaces.rs +++ b/internal/compiler/object_tree/interfaces.rs @@ -4,7 +4,7 @@ //! Module containing interfaces related types and functions. use std::cell::RefCell; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; use std::fmt::Display; use std::rc::Rc; @@ -16,7 +16,8 @@ use crate::expression_tree::{BindingExpression, Callable, Expression}; use crate::langtype::{ElementType, Function, PropertyLookupResult, Type}; use crate::namedreference::NamedReference; use crate::object_tree::{ - Element, ElementRc, PropertyDeclaration, QualifiedTypeName, find_element_by_id, + Element, ElementRc, NavigationContract, NeededCapability, PropertyDeclaration, + QualifiedTypeName, find_element_by_id, recurse_elem, }; use crate::parser; use crate::parser::{SyntaxKind, syntax_nodes}; @@ -641,3 +642,210 @@ fn apply_uses_statement_function_binding( let body = Expression::CodeBlock(vec![call_expr]); element.borrow_mut().bindings.insert(name.clone(), RefCell::new(BindingExpression::from(body))) } + +pub(super) struct NeededInterface { + needs_specifier: syntax_nodes::NeedsSpecifier, + interface: ElementRc, + interface_name: SmolStr, +} + +/// Resolve the `needs` specifiers on `node` to capability interfaces. +pub(super) fn get_needed_interfaces( + e: &Element, + node: &syntax_nodes::Element, + tr: &TypeRegister, + diag: &mut BuildDiagnostics, +) -> Vec { + let specifiers: Vec = node.NeedsSpecifier().collect(); + if specifiers.is_empty() { + return Vec::new(); + } + + #[cfg(feature = "slint-sc")] + for specifier in &specifiers { + diag.slint_sc_error("'needs' is", specifier); + } + + if !diag.enable_experimental && !tr.expose_internal_types { + for specifier in &specifiers { + diag.push_error("'needs' is an experimental feature".into(), specifier); + } + return Vec::new(); + } + + let mut needed = Vec::new(); + for needs_specifier in specifiers { + let interface_name = + QualifiedTypeName::from_node(needs_specifier.QualifiedName()).to_smolstr(); + match e.base_type.lookup_type_for_child_element(&interface_name, tr) { + Ok(ElementType::Component(c)) if c.is_interface() => { + c.used.set(true); + needed.push(NeededInterface { + needs_specifier, + interface: c.root_element.clone(), + interface_name, + }); + } + Ok(_) => diag.push_error( + format!("Cannot use '{interface_name}' as a capability. It is not an interface"), + &needs_specifier.QualifiedName(), + ), + Err(err) => diag.push_error(err, &needs_specifier.QualifiedName()), + } + } + needed +} + +/// Declare each needed interface's members as unbound on `e` (the host binds them +/// at the mount site) and return the recorded needs for the mount verifier. +pub(super) fn apply_needs( + e: &mut Element, + needed: &[NeededInterface], + diag: &mut BuildDiagnostics, +) -> Vec { + let mut capabilities = Vec::new(); + for NeededInterface { needs_specifier, interface, interface_name } in needed { + let mut members = Vec::new(); + for (name, prop_decl) in interface.borrow().property_declarations.iter().filter(|(_, d)| { + matches!(d.property_type, Type::Callback { .. } | Type::Function { .. }) + }) { + if apply_needed_member(e, name, prop_decl, needs_specifier, interface_name, diag) { + members.push(name.clone()); + } + } + capabilities.push(NeededCapability { interface_name: interface_name.clone(), members }); + } + capabilities +} + +/// Declare one capability member as unbound; false on a name conflict (diagnostic emitted). +fn apply_needed_member( + e: &mut Element, + name: &SmolStr, + prop_decl: &PropertyDeclaration, + needs_specifier: &syntax_nodes::NeedsSpecifier, + interface_name: &SmolStr, + diag: &mut BuildDiagnostics, +) -> bool { + if matches!(prop_decl.property_type, Type::Invalid) { + return false; + } + + let lookup_result = e.lookup_property(name); + if lookup_result.property_type != Type::Invalid && lookup_result.is_local_to_component { + diag.push_error( + format!( + "Conflict with '{interface_name}' which declares a capability member '{name}' with the same name" + ), + &needs_specifier.QualifiedName(), + ); + return false; + } + + // No declaration of its own: point diagnostics at the `needs` specifier. + let mut prop_decl = prop_decl.clone(); + prop_decl.node = Some(needs_specifier.QualifiedName().into()); + e.property_declarations.insert(name.clone(), prop_decl); + true +} + +/// Last `.`-segment of a qualified enum value, e.g. `Home` from `Route.Home`. +fn navigator_route_name(route: &syntax_nodes::Expression) -> SmolStr { + route.text().to_string().rsplit('.').next().unwrap_or_default().trim().into() +} + +/// Route names of the first `navigator` in `root` (one per component by +/// convention), or `None` if none. +fn navigator_route_names(root: &ElementRc) -> Option> { + let mut names: Option> = None; + recurse_elem(root, &(), &mut |elem, _| { + if names.is_none() { + let elem = elem.borrow(); + let routes = elem.navigator_routes(); + if !routes.is_empty() { + names = Some(routes.iter().map(|r| navigator_route_name(&r.route)).collect()); + } + } + }); + names +} + +/// Verify a federated mount: `impl_root` must cover every `contract` route by name. +pub(super) fn validate_mount_conformance( + impl_name: &str, + impl_root: &ElementRc, + contract_name: &str, + contract: &NavigationContract, + mount_site: &dyn crate::diagnostics::Spanned, + diag: &mut BuildDiagnostics, +) -> bool { + let Some(names) = navigator_route_names(impl_root) else { + diag.push_error( + format!( + "mount of '{impl_name}' does not satisfy contract '{contract_name}': it declares no navigator" + ), + mount_site, + ); + return false; + }; + // Extra routes are allowed; only missing contract routes are an error. + let missing: Vec<&str> = + contract.routes.iter().map(|r| r.name.as_str()).filter(|n| !names.contains(*n)).collect(); + if !missing.is_empty() { + diag.push_error( + format!( + "mount of '{impl_name}' does not satisfy contract '{contract_name}': missing route(s) {}", + missing.join(", ") + ), + mount_site, + ); + return false; + } + true +} + +/// Verify each self-implemented navigation-contract interface has its routes +/// covered by this component's navigator (route-name coverage only; param-type +/// conformance and `<=> child` contracts are deferred). +pub(super) fn validate_navigation_contract_conformance( + root: &ElementRc, + implemented_interfaces: &[ImplementedInterface], + diag: &mut BuildDiagnostics, +) { + for implemented in implemented_interfaces { + let Some(contract) = implemented + .interface + .borrow() + .enclosing_component + .upgrade() + .and_then(|c| c.navigation_contract()) + else { + continue; + }; + if contract.routes.is_empty() { + continue; + } + let interface_name = &implemented.interface_name; + let Some(names) = navigator_route_names(root) else { + diag.push_error( + format!( + "component implements navigation contract '{interface_name}' but declares no navigator" + ), + &implemented.node.QualifiedName(), + ); + continue; + }; + // Extra navigator routes are allowed; only missing contract routes error. + for route in &contract.routes { + if !names.contains(&route.name) { + diag.push_error( + format!( + "component implements '{interface_name}' but its navigator has no route for '{}'", + route.name + ), + &implemented.node.QualifiedName(), + ); + } + } + } +} diff --git a/internal/compiler/parser.rs b/internal/compiler/parser.rs index 391fa1c46d8..22e154a3bf7 100644 --- a/internal/compiler/parser.rs +++ b/internal/compiler/parser.rs @@ -352,9 +352,9 @@ declare_syntax! { /// `id := Element { ... }` SubElement -> [ Element ], Element -> [ ?QualifiedName, *PropertyDeclaration, *Binding, *CallbackConnection, - *CallbackDeclaration, *ConditionalElement, *MatchElement, *Navigator, *Function, *SubElement, + *CallbackDeclaration, *ConditionalElement, *MatchElement, *Navigator, *Function, *RouteDeclaration, *SubElement, *RepeatedElement, *PropertyAnimation, *PropertyChangedCallback, - *TwoWayBinding, *States, *Transitions, *ImplementStatement, ?ChildrenPlaceholder ], + *TwoWayBinding, *States, *Transitions, *ImplementStatement, ?ChildrenPlaceholder, *AtVersion, ?MountVia, *NeedsSpecifier ], RepeatedElement -> [ ?DeclaredIdentifier, ?RepeatedIndex, Expression , SubElement], RepeatedIndex -> [], ConditionalElement -> [ Expression , SubElement], @@ -366,8 +366,16 @@ declare_syntax! { WildcardMatchCase -> [ ?SubElement ], /// navigator (current-route) { Route.Home: HomeScreen { } } Navigator -> [ Expression, *Route ], - /// Route.Home: HomeScreen { } - Route -> [ Expression, ?SubElement ], + /// Route.Home: HomeScreen { } or Route.Media: mount MediaFeature via FeatureNav { } + Route -> [ Expression, ?SubElement, ?MountDestination ], + /// A route member of a navigation contract: `route Main;` or `@uri("...") route Details(id: int);` + RouteDeclaration -> [ DeclaredIdentifier, *ArgumentDeclaration, *AtUri ], + /// The `mount Impl via Contract { ... }` destination of a route. + MountDestination -> [ SubElement ], + /// `via Contract` inside a mount destination. + MountVia -> [ QualifiedName ], + /// `needs Capability;` on a module, requiring host-bound callbacks. + NeedsSpecifier -> [ QualifiedName ], CallbackDeclaration -> [ DeclaredIdentifier, *CallbackDeclarationParameter, ?ReturnType, ?TwoWayBinding ], // `foo: type` or just `type` CallbackDeclarationParameter -> [ ?DeclaredIdentifier, Type], @@ -480,6 +488,10 @@ declare_syntax! { EnumValue -> [], /// `@rust-attr(...)` AtRustAttr -> [], + /// `@version(n)` on a navigation contract interface + AtVersion -> [], + /// `@uri("...")` on a contract route + AtUri -> [], } } diff --git a/internal/compiler/parser/element.rs b/internal/compiler/parser/element.rs index addcd7285ca..dd83caff9db 100644 --- a/internal/compiler/parser/element.rs +++ b/internal/compiler/parser/element.rs @@ -170,6 +170,13 @@ pub fn parse_element_content(p: &mut impl Parser) { SyntaxKind::Identifier if p.peek().as_str() == "implement" => { parse_implement_statement(&mut *p); } + // Contract route member; the experimental gate is applied at lowering. + SyntaxKind::Identifier if p.peek().as_str() == "route" => { + parse_route_declaration(&mut *p, None); + } + SyntaxKind::Identifier if p.peek().as_str() == "needs" => { + parse_needs_specifier(&mut *p); + } _ => { if p.peek().as_str() == "changed" { // Try to recover some errors @@ -183,18 +190,34 @@ pub fn parse_element_content(p: &mut impl Parser) { } } }, - SyntaxKind::At => { - let checkpoint = p.checkpoint(); - p.consume(); - if p.peek().as_str() == "children" { - let mut p = - p.start_node_at(checkpoint.clone(), SyntaxKind::ChildrenPlaceholder); - p.consume() - } else { + SyntaxKind::At => match p.nth(1).as_str() { + "children" => { + let checkpoint = p.checkpoint(); + p.consume(); // "@" + let mut p = p.start_node_at(checkpoint, SyntaxKind::ChildrenPlaceholder); + p.consume() // "children" + } + "version" => { + parse_navigation_attribute(&mut *p, "version", SyntaxKind::AtVersion); + } + "uri" => { + let checkpoint = p.checkpoint(); + parse_navigation_attribute(&mut *p, "uri", SyntaxKind::AtUri); + while p.peek().as_str() == "@" && p.nth(1).as_str() == "uri" { + parse_navigation_attribute(&mut *p, "uri", SyntaxKind::AtUri); + } + if p.peek().as_str() == "route" { + parse_route_declaration(&mut *p, Some(checkpoint)); + } else { + p.error("Parse error: Expected 'route' after @uri"); + } + } + _ => { + p.consume(); // "@" p.test(SyntaxKind::Identifier); p.error("Parse error: Expected @children") } - } + }, _ => { if !had_parse_error { p.error("Parse error"); @@ -222,6 +245,79 @@ fn parse_sub_element(p: &mut impl Parser) -> bool { parse_element(&mut *p) } +#[cfg_attr(test, parser_test)] +/// ```test,RouteDeclaration +/// route Home; +/// route Details(id: int); +/// route Details(id: int, name: string); +/// route Details(id: int,); +/// ``` +/// The optional `checkpoint` lets a preceding `@uri(...)` attribute be wrapped +/// into the RouteDeclaration node (mirrors struct/enum + `@rust-attr`). +fn parse_route_declaration(p: &mut P, checkpoint: Option) { + debug_assert_eq!(p.peek().as_str(), "route"); + let mut p = p.start_node_at(checkpoint, SyntaxKind::RouteDeclaration); + p.expect(SyntaxKind::Identifier); // "route" + { + let mut p = p.start_node(SyntaxKind::DeclaredIdentifier); + p.expect(SyntaxKind::Identifier); + } + // Optional typed parameters, reusing the function argument grammar. + if p.test(SyntaxKind::LParent) { + while p.peek().kind() != SyntaxKind::RParent { + let mut p_arg = p.start_node(SyntaxKind::ArgumentDeclaration); + { + let mut p = p_arg.start_node(SyntaxKind::DeclaredIdentifier); + p.expect(SyntaxKind::Identifier); + } + p_arg.expect(SyntaxKind::Colon); + parse_type(&mut *p_arg); + drop(p_arg); + if !p.test(SyntaxKind::Comma) { + break; + } + } + p.expect(SyntaxKind::RParent); + } + if !p.test(SyntaxKind::Semicolon) { + p.error("Expected ';' after route declaration"); + } +} + +/// Parse a navigation attribute `@name()`, wrapping the argument tokens +/// in `kind`. Mirrors `parse_rustattr` so the read-out reuses `attr.text()`. The +/// caller has verified the attribute name at `nth(1)`. +fn parse_navigation_attribute(p: &mut impl Parser, name: &str, kind: SyntaxKind) -> bool { + debug_assert_eq!(p.peek().as_str(), "@"); + p.consume(); // "@" + p.consume(); // attribute name + if !p.expect(SyntaxKind::LParent) { + return false; + } + { + let mut p = p.start_node(kind); + let mut level = 1; + loop { + match p.peek().kind() { + SyntaxKind::LParent => level += 1, + SyntaxKind::RParent => { + level -= 1; + if level == 0 { + break; + } + } + SyntaxKind::Eof => { + p.error(format!("unmatched parentheses in @{name}")); + return false; + } + _ => {} + } + p.consume() + } + } + p.expect(SyntaxKind::RParent) +} + #[cfg_attr(test, parser_test)] /// ```test,RepeatedElement /// for xx in mm: Elem { } @@ -401,6 +497,11 @@ fn parse_route(p: &mut impl Parser) { p.consume(); } } + // A federated mount destination: `mount Impl via Contract { }`. + if p.peek().as_str() == "mount" { + parse_mount_destination(&mut *p); + return; + } if p.peek().kind() == SyntaxKind::LBrace { // empty route p.expect(SyntaxKind::LBrace); @@ -414,6 +515,61 @@ fn parse_route(p: &mut impl Parser) { parse_sub_element(&mut *p); } +#[cfg_attr(test, parser_test)] +/// ```test,MountDestination +/// mount ModuleA via AppNavV1 { } +/// mount M via C {} +/// mount ModuleA via AppNavV1 { open-settings => {} } +/// mount extern via AppNavV1 { component-factory: root.f; } +/// mount extern via C {} +/// ``` +fn parse_mount_destination(p: &mut impl Parser) { + debug_assert_eq!(p.peek().as_str(), "mount"); + let mut p = p.start_node(SyntaxKind::MountDestination); + p.expect(SyntaxKind::Identifier); // "mount" + // `extern` (soft keyword) marks an external destination: no base name, so + // object_tree can tell it from a local mount. + let external = p.peek().as_str() == "extern"; + if external { + p.consume(); // "extern" + } + // The impl is a normal `Impl { }` wrapped as a SubElement so the + // mount-block bindings flow through the ordinary binding path; `via ` + // nests in a MountVia node. + let mut sub = p.start_node(SyntaxKind::SubElement); + let mut el = sub.start_node(SyntaxKind::Element); + if !external { + parse_qualified_name(&mut *el); // the mounted implementation + } + { + let mut via = el.start_node(SyntaxKind::MountVia); + if via.peek().as_str() != "via" { + via.error("Expected 'via ' after 'mount '"); + } else { + via.consume(); // "via" + } + parse_qualified_name(&mut *via); // the navigation contract to satisfy + } + if !el.expect(SyntaxKind::LBrace) { + return; + } + parse_element_content(&mut *el); + el.expect(SyntaxKind::RBrace); +} + +#[cfg_attr(test, parser_test)] +/// ```test,NeedsSpecifier +/// needs AppServices; +/// needs Fully.Qualified.Services; +/// ``` +fn parse_needs_specifier(p: &mut impl Parser) { + debug_assert_eq!(p.peek().as_str(), "needs"); + let mut p = p.start_node(SyntaxKind::NeedsSpecifier); + p.expect(SyntaxKind::Identifier); // "needs" + parse_qualified_name(&mut *p); + p.expect(SyntaxKind::Semicolon); +} + #[cfg_attr(test, parser_test)] /// ```test,Binding /// foo: bar; diff --git a/internal/compiler/passes/inlining.rs b/internal/compiler/passes/inlining.rs index 1781bd5d9e4..05cc1305fae 100644 --- a/internal/compiler/passes/inlining.rs +++ b/internal/compiler/passes/inlining.rs @@ -400,6 +400,7 @@ fn duplicate_element_with_mapping( .collect(), repeated: elem.repeated.clone(), navigator_routes: elem.navigator_routes.clone(), + needed_capabilities: elem.needed_capabilities.clone(), is_component_placeholder: elem.is_component_placeholder, debug: elem.debug.clone(), enclosing_component: Rc::downgrade(root_component), @@ -488,6 +489,7 @@ fn duplicate_sub_component( private_properties: Default::default(), inherits_popup_window: core::cell::Cell::new(false), from_library: core::cell::Cell::new(false), + navigation_contract: Default::default(), }; let new_component = Rc::new(new_component); diff --git a/internal/compiler/passes/repeater_component.rs b/internal/compiler/passes/repeater_component.rs index 4450231c477..723d41d6e6d 100644 --- a/internal/compiler/passes/repeater_component.rs +++ b/internal/compiler/passes/repeater_component.rs @@ -48,6 +48,7 @@ fn create_repeater_components(component: &Rc) { named_references: Default::default(), repeated: None, navigator_routes: Default::default(), + needed_capabilities: Default::default(), is_component_placeholder: false, debug: original_elem.debug.clone(), enclosing_component: Default::default(), diff --git a/internal/compiler/passes/windows.rs b/internal/compiler/passes/windows.rs index f58ece82775..e2ca4f8c02e 100644 --- a/internal/compiler/passes/windows.rs +++ b/internal/compiler/passes/windows.rs @@ -51,6 +51,7 @@ pub fn ensure_window( named_references: Default::default(), repeated: Default::default(), navigator_routes: Default::default(), + needed_capabilities: Default::default(), states: Default::default(), transitions: Default::default(), child_of_layout: false, diff --git a/internal/compiler/typeloader.rs b/internal/compiler/typeloader.rs index 2059c6950e6..174c38de85b 100644 --- a/internal/compiler/typeloader.rs +++ b/internal/compiler/typeloader.rs @@ -391,6 +391,7 @@ impl Snapshotter { root_constraints, root_element, from_library: core::cell::Cell::new(false), + navigation_contract: Default::default(), } }); self.keep_alive.push((component.clone(), result.clone())); diff --git a/internal/interpreter/api.rs b/internal/interpreter/api.rs index 74b1a7dfe57..d5d43160b7b 100644 --- a/internal/interpreter/api.rs +++ b/internal/interpreter/api.rs @@ -6,7 +6,6 @@ use crate::dynamic_item_tree::{ErasedItemTreeBox, WindowOptions}; use i_slint_compiler::langtype::Type as LangType; use i_slint_core::PathData; use i_slint_core::component_factory::ComponentFactory; -#[cfg(feature = "internal")] use i_slint_core::component_factory::FactoryContext; use i_slint_core::graphics::euclid::approxeq::ApproxEq as _; use i_slint_core::items::*; @@ -1134,9 +1133,10 @@ impl ComponentDefinition { Ok(instance) } - /// Creates a new instance of the component and returns a shared handle to it. - #[doc(hidden)] - #[cfg(feature = "internal")] + /// Instantiate the component for embedding, for use inside a `slint::ComponentFactory::new` + /// closure. `ctx` is the `FactoryContext` the closure receives. This fills a + /// `ComponentContainer` with a dynamically-loaded component, including a navigator's + /// `mount extern via` route. pub fn create_embedded(&self, ctx: FactoryContext) -> Result { self.create_with_options(WindowOptions::Embed { parent_item_tree: ctx.parent_item_tree, diff --git a/internal/interpreter/tests.rs b/internal/interpreter/tests.rs index 8fc105a0d23..b205fbdc564 100644 --- a/internal/interpreter/tests.rs +++ b/internal/interpreter/tests.rs @@ -363,6 +363,224 @@ export component TestCase inherits Window { ); } +// A navigation contract is an `interface` carrying `route` members; a component +// conforms with master's `implement <=> self;` statement, and its +// navigator must cover every contract route. +#[cfg(feature = "internal")] +#[test] +fn navigation_contract_conformance() { + i_slint_backend_testing::init_no_event_loop(); + use crate::Compiler; + + let build = |code: &str| { + let mut compiler = Compiler::default(); + compiler.set_style("fluent".into()); + compiler.compiler_configuration(i_slint_core::InternalToken).enable_experimental = true; + spin_on::spin_on(compiler.build_from_source(code.into(), Default::default())) + }; + + // Covers the contract route -> compiles. + let ok = r#" +export interface FeatureNav { + @version(1) + @uri("app://feature") route Main; +} +enum MediaRoute { Main, Player } +component Library inherits Rectangle { } +component PlayerScreen inherits Rectangle { } +export component MediaFeature inherits Rectangle { + implement FeatureNav <=> self; + in-out property current-route: MediaRoute.Main; + navigator (current-route) { + MediaRoute.Main: Library { } + MediaRoute.Player: PlayerScreen { } + } +} +"#; + let result = build(ok); + assert!(!result.has_errors(), "{:?}", result.diagnostics().collect::>()); + + // Navigator does not cover the contract route -> conformance error. + let missing = r#" +export interface FeatureNav { + route Main; +} +enum MediaRoute { Other } +component OtherScreen inherits Rectangle { } +export component MediaFeature inherits Rectangle { + implement FeatureNav <=> self; + in-out property current-route: MediaRoute.Other; + navigator (current-route) { + MediaRoute.Other: OtherScreen { } + } +} +"#; + let result = build(missing); + assert!(result.has_errors(), "missing contract route must be rejected"); + assert!( + result.diagnostics().any(|d| d.message().contains("no route for 'Main'")), + "expected the conformance diagnostic, got: {:?}", + result.diagnostics().map(|d| d.message().to_owned()).collect::>() + ); +} + +// A shell mounts a feature via a contract (`mount Impl via Contract`), binding +// the capabilities the feature `needs`; an external mount supplies a runtime +// `component-factory` instead of a compile-time impl. +#[cfg(feature = "internal")] +#[test] +fn navigation_federated_mount() { + i_slint_backend_testing::init_no_event_loop(); + use crate::Compiler; + + let build = |code: &str| { + let mut compiler = Compiler::default(); + compiler.set_style("fluent".into()); + compiler.compiler_configuration(i_slint_core::InternalToken).enable_experimental = true; + spin_on::spin_on(compiler.build_from_source(code.into(), Default::default())) + }; + + let feature = r#" +export interface FeatureNav { route Main; } +export interface HostServices { callback log(string); } +enum InnerRoute { Main, Detail } +component InnerMain inherits Rectangle { } +component InnerDetail inherits Rectangle { } +export component Feature inherits Rectangle { + implement FeatureNav <=> self; + needs HostServices; + in-out property current-route: InnerRoute.Main; + navigator (current-route) { + InnerRoute.Main: InnerMain { } + InnerRoute.Detail: InnerDetail { } + } +} +enum Shell { Home, Feature } +component HomeScreen inherits Rectangle { } +"#; + + // Local mount that binds the needed capability -> compiles. + let ok = format!( + r#"{feature} +export component App inherits Window {{ + in-out property current: Shell.Home; + navigator (current) {{ + Shell.Home: HomeScreen {{ }} + Shell.Feature: mount Feature via FeatureNav {{ + log(message) => {{ debug(message); }} + }} + }} +}} +"# + ); + let result = build(&ok); + assert!(!result.has_errors(), "{:?}", result.diagnostics().collect::>()); + + // Local mount that does NOT bind the needed capability -> error. + let unbound = format!( + r#"{feature} +export component App inherits Window {{ + in-out property current: Shell.Home; + navigator (current) {{ + Shell.Home: HomeScreen {{ }} + Shell.Feature: mount Feature via FeatureNav {{ }} + }} +}} +"# + ); + let result = build(&unbound); + assert!(result.has_errors(), "unbound capability must be rejected"); + assert!( + result + .diagnostics() + .any(|d| d.message().contains("does not bind required capability 'log'")), + "expected the capability diagnostic, got: {:?}", + result.diagnostics().map(|d| d.message().to_owned()).collect::>() + ); + + // External mount without a component-factory -> error. + let extern_missing = r#" +export interface FeatureNav { route Main; } +enum Shell { Home, Plugin } +component HomeScreen inherits Rectangle { } +export component App inherits Window { + in-out property current: Shell.Home; + navigator (current) { + Shell.Home: HomeScreen { } + Shell.Plugin: mount extern via FeatureNav { } + } +} +"#; + let result = build(extern_missing); + assert!(result.has_errors(), "external mount without component-factory must be rejected"); + assert!( + result.diagnostics().any(|d| d.message().contains("requires a 'component-factory'")), + "expected the external-mount diagnostic, got: {:?}", + result.diagnostics().map(|d| d.message().to_owned()).collect::>() + ); + + // External mount supplying a component-factory -> compiles. + let extern_ok = r#" +export interface FeatureNav { route Main; } +enum Shell { Home, Plugin } +component HomeScreen inherits Rectangle { } +export component App inherits Window { + in property plugin-factory; + in-out property current: Shell.Home; + navigator (current) { + Shell.Home: HomeScreen { } + Shell.Plugin: mount extern via FeatureNav { component-factory: root.plugin-factory; } + } +} +"#; + let result = build(extern_ok); + assert!(!result.has_errors(), "{:?}", result.diagnostics().collect::>()); +} + +// A navigator whose route property is internal (a non-root sub-component) must +// still be writable by navigate()/back(); regression for the route being wrongly +// classified constant (which panicked "Constant property being changed"). +#[cfg(feature = "internal")] +#[test] +fn navigation_internal_route_is_writable() { + i_slint_backend_testing::init_no_event_loop(); + use crate::{Compiler, Value}; + let code = r#" +enum Route { Main, Detail } +component Screen inherits Rectangle { } +component Feature inherits Rectangle { + in-out property route: Route.Main; + public function push() { root.navigate(Route.Detail); } + public function pop() { root.back(); } + navigator (route) { + Route.Main: Screen { } + Route.Detail: Screen { } + } +} +export component TestCase inherits Window { + width: 100px; + height: 100px; + feature := Feature { } + out property index: feature.current-route-index; + public function push() { feature.push(); } + public function pop() { feature.pop(); } +} +"#; + let mut compiler = Compiler::default(); + compiler.set_style("fluent".into()); + compiler.compiler_configuration(i_slint_core::InternalToken).enable_experimental = true; + let result = spin_on::spin_on(compiler.build_from_source(code.into(), Default::default())); + assert!(!result.has_errors(), "{:?}", result.diagnostics().collect::>()); + let instance = result.component("TestCase").unwrap().create().unwrap(); + + let idx = || instance.get_property("index").unwrap(); + assert_eq!(idx(), Value::Number(0.)); + instance.invoke("push", &[]).unwrap(); + assert_eq!(idx(), Value::Number(1.)); + instance.invoke("pop", &[]).unwrap(); + assert_eq!(idx(), Value::Number(0.)); +} + #[cfg(feature = "internal")] #[test] fn navigator_back_stack() { diff --git a/tests/cases/navigation/mount.slint b/tests/cases/navigation/mount.slint new file mode 100644 index 00000000000..738d033a591 --- /dev/null +++ b/tests/cases/navigation/mount.slint @@ -0,0 +1,67 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +export interface FeatureNav { + route Main; +} + +enum InnerRoute { Main, Detail } + +component InnerMain inherits Rectangle { callback go(); } +component InnerDetail inherits Rectangle { callback go-back(); } + +component Feature inherits Rectangle { + implement FeatureNav <=> self; + in-out property current-route: InnerRoute.Main; + navigator (current-route) { + InnerRoute.Main: InnerMain { go => { root.navigate(InnerRoute.Detail); } } + InnerRoute.Detail: InnerDetail { go-back => { root.back(); } } + } +} + +enum Shell { Home, Feature } + +component HomeScreen inherits Rectangle { } + +export component TestCase inherits Window { + width: 100px; + height: 100px; + + in-out property current: Shell.Home; + + navigator (current) { + Shell.Home: HomeScreen { } + Shell.Feature: mount Feature via FeatureNav { } + } +} + +/* +```rust +let instance = TestCase::new().unwrap(); +assert_eq!(instance.get_current_route_index(), 0); +instance.invoke_navigate_index(1); +assert_eq!(instance.get_current_route_index(), 1); +``` + +```cpp +auto handle = TestCase::create(); +const TestCase &instance = *handle; +assert_eq(instance.get_current_route_index(), 0); +instance.invoke_navigate_index(1); +assert_eq(instance.get_current_route_index(), 1); +``` + +```js +var instance = new slint.TestCase(); +assert.equal(instance.current_route_index, 0); +instance.navigate_index(1); +assert.equal(instance.current_route_index, 1); +``` + +```python +instance = TestCase() +assert instance.current_route_index == 0 +instance.navigate_index(1) +assert instance.current_route_index == 1 +``` +*/