diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2ce4112b79f..f693956fc16 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -213,10 +213,11 @@ jobs: python-version: "3.12" - name: Install uv uses: astral-sh/setup-uv@v7 - - uses: fjwillemsen/setup-nox2@v3.0.0 - name: Run python tests working-directory: api/python/slint - run: nox --default-venv-backend uv + run: uv run pytest tests + env: + MATURIN_PEP517_ARGS: --profile=dev - name: Run mypy working-directory: api/python/slint run: uv run mypy -p tests -p slint diff --git a/api/python/briefcase/src/briefcasex_slint/__init__.py b/api/python/briefcase/src/briefcasex_slint/__init__.py index 738fc8754e2..c9532b89850 100644 --- a/api/python/briefcase/src/briefcasex_slint/__init__.py +++ b/api/python/briefcase/src/briefcasex_slint/__init__.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 from pathlib import Path +import inspect from briefcase.bootstraps.base import BaseGuiBootstrap @@ -10,91 +11,92 @@ class SlintGuiBootstrap(BaseGuiBootstrap): display_name_annotation = "does not support Android/Web deployment" def app_source(self): - return """\ -import slint + return inspect.cleandoc(""" + import slint -class {{ cookiecutter.class_name }}(slint.loader.{{ cookiecutter.module_name }}.resources.app_window.AppWindow): - @slint.callback - def request_increase_value(self): - self.counter = self.counter + 1 + class {{ cookiecutter.class_name }}(slint.loader.{{ cookiecutter.module_name }}.resources.app_window.AppWindow): + @slint.callback + def request_increase_value(self): + self.counter = self.counter + 1 -def main(): - main_window = {{ cookiecutter.class_name }}() - main_window.show() - main_window.run() -""" + def main(): + main_window = {{ cookiecutter.class_name }}() + main_window.show() + main_window.run() + """) def app_start_source(self): - return """\ -from {{ cookiecutter.module_name }}.app import main + return inspect.cleandoc(""" + from {{ cookiecutter.module_name }}.app import main -if __name__ == "__main__": - main() -""" + if __name__ == "__main__": + main() + """) def pyproject_table_briefcase_app_extra_content(self): - return """ -requires = [ -] -test_requires = [ -{% if cookiecutter.test_framework == "pytest" %} - "pytest", -{% endif %} -] -""" + return inspect.cleandoc(""" + requires = [ + ] + test_requires = [ + {% if cookiecutter.test_framework == "pytest" %} + "pytest", + {% endif %} + ] + """) def pyproject_table_macOS(self): - return """\ -universal_build = false -requires = [ - "slint", -] -""" + return inspect.cleandoc(""" + universal_build = false + requires = [ + "slint", + ] + """) def pyproject_table_linux(self): - return """\ -requires = [ - "slint", -] -""" + return inspect.cleandoc(""" + requires = [ + "slint", + ] + """) def pyproject_table_windows(self): - return """\ -requires = [ - "slint", -] -""" + return inspect.cleandoc(""" + requires = [ + "slint", + ] + """) def pyproject_table_iOS(self): - return """\ -requires = [ - "slint", -] -""" + return inspect.cleandoc(""" + requires = [ + "slint", + ] + """) def post_generate(self, base_path: Path) -> None: target_dir = base_path / self.context["source_dir"] / "resources" target_dir.mkdir(parents=True, exist_ok=True) with open(target_dir / "app-window.slint", "w") as slint_file: - slint_file.write(r""" -import { Button, VerticalBox, AboutSlint } from "std-widgets.slint"; - -export component AppWindow inherits Window { - in-out property counter: 42; - callback request-increase-value(); - VerticalBox { - alignment: center; - AboutSlint {} - Text { - text: "Counter: \{root.counter}"; - } - Button { - text: "Increase value"; - clicked => { - root.request-increase-value(); - } - } - } -} -""") + content = inspect.cleandoc(""" + import { Button, VerticalBox, AboutSlint } from "std-widgets.slint"; + + export component AppWindow inherits Window { + in-out property counter: 42; + callback request-increase-value(); + VerticalBox { + alignment: center; + AboutSlint {} + Text { + text: "Counter: {root.counter}"; + } + Button { + text: "Increase value"; + clicked => { + root.request-increase-value(); + } + } + } + } + """) + slint_file.write(content) diff --git a/api/python/slint/Cargo.toml b/api/python/slint/Cargo.toml index 7a2d3117cd6..b18c22b8e5f 100644 --- a/api/python/slint/Cargo.toml +++ b/api/python/slint/Cargo.toml @@ -14,6 +14,7 @@ publish = false rust-version.workspace = true [lib] +name = "core" path = "lib.rs" crate-type = ["cdylib", "rlib"] @@ -39,11 +40,13 @@ renderer-skia-opengl = ["slint-interpreter/renderer-skia-opengl"] renderer-skia-vulkan = ["slint-interpreter/renderer-skia-vulkan"] renderer-software = ["slint-interpreter/renderer-software"] accessibility = ["slint-interpreter/accessibility"] +stubgen = [] [dependencies] i-slint-backend-selector = { workspace = true } i-slint-core = { workspace = true, features = ["tr"] } +i-slint-common = { workspace = true } slint-interpreter = { workspace = true, features = ["default", "display-diagnostics", "internal"] } i-slint-compiler = { workspace = true } pyo3 = { version = "0.26", features = ["extension-module", "indexmap", "chrono", "abi3-py311"] } @@ -51,8 +54,6 @@ indexmap = { version = "2.1.0" } chrono = "0.4" spin_on = { workspace = true } css-color-parser2 = { workspace = true } -pyo3-stub-gen = { version = "0.9.0", default-features = false } +pyo3-stub-gen = { git = "https://github.com/GreyElaina/pyo3-stub-gen", branch = "main", default-features = false, features = ["infer_signature"] } smol = { version = "2.0.0" } - -[package.metadata.maturin] -python-source = "slint" +smol_str = { workspace = true } diff --git a/api/python/slint/README.md b/api/python/slint/README.md index db725e9efc3..e5465bcc0c4 100644 --- a/api/python/slint/README.md +++ b/api/python/slint/README.md @@ -75,6 +75,84 @@ app.run() 5. Run it with `uv run main.py` +## Code Generation CLI + +Use the bundled code generator to derive Python bindings and type stubs from your `.slint` files. The generator lives in the `slint.codegen` module and exposes a CLI entry point. + +Run it with `uv`: + +```bash +uv run -m slint.codegen generate --input path/to/app.slint --output generated +``` + +When `--output` is omitted the generated files are written next to each input source. + +You can also call the generator from Python: + +```python +from pathlib import Path +from slint.codegen.generator import generate_project +from slint.codegen.models import GenerationConfig + +slint_file = Path("ui/app.slint") +generate_project( + inputs=[slint_file], + output_dir=Path("generated"), + config=GenerationConfig( + include_paths=[slint_file.parent], + library_paths={}, + style=None, + translation_domain=None, + quiet=True, + ), +) +``` + +It's strongly recommended to run the code generator as part of your build process (e.g., `hatch` build hooks), so that the generated bindings are always in sync with your `.slint` files. + +## Component Libraries and `library_paths` + +Slint supports importing third-party component libraries such as the Material 3 component set under the `@material` namespace. Follow the instructions on [material.slint.dev/getting-started](https://material.slint.dev/getting-started/) to download the library bundle into your project (for example into `material-1.0/`). + +When you load a `.slint` file from Python, pass a `library_paths` dictionary that maps the library name to the `.slint` entry file in that bundle: + +```python +from pathlib import Path +import slint + +root = Path(__file__).parent +ui = slint.load_file( + root / "ui" / "main.slint", + library_paths={ + "material": root / "material-1.0" / "material.slint", + }, +) +``` + +The same mapping can be supplied to the code generator via `GenerationConfig` so the generated bindings reference the library correctly: + +```python +from pathlib import Path +from slint.codegen.generator import generate_project +from slint.codegen.models import GenerationConfig + +slint_file = Path("ui/app.slint") + +generate_project( + inputs=[slint_file], + output_dir=Path("generated"), + config=GenerationConfig( + include_paths=[slint_file.parent], + library_paths={"material": Path("material-1.0/material.slint")}, + style=None, + translation_domain=None, + quiet=True, + ), +) +``` + +Any library referenced with `import { ... } from "@library-name"` must appear in `library_paths`, and the value should point to the `.slint` file that serves as the library's root entry point. + ## API Overview ### Instantiating a Component diff --git a/api/python/slint/brush.rs b/api/python/slint/brush.rs index a427af3665e..12d8eb58844 100644 --- a/api/python/slint/brush.rs +++ b/api/python/slint/brush.rs @@ -2,7 +2,10 @@ // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 use pyo3::prelude::*; -use pyo3_stub_gen::{derive::gen_stub_pyclass, derive::gen_stub_pymethods, impl_stub_type}; +use pyo3_stub_gen::{ + derive::gen_stub_pyclass, derive::gen_stub_pyclass_complex_enum, derive::gen_stub_pymethods, + impl_stub_type, +}; use crate::errors::PyColorParseError; @@ -33,6 +36,7 @@ struct RgbColor { } #[derive(FromPyObject)] +#[gen_stub_pyclass_complex_enum] #[pyclass] enum PyColorInput { ColorStr(String), @@ -57,8 +61,6 @@ enum PyColorInput { }, } -impl_stub_type!(PyColorInput = String | RgbaColor | RgbColor); - /// A Color object represents a color in the RGB color space with an alpha. Each color channel and the alpha is represented /// as an 8-bit integer. The alpha channel is 0 for fully transparent and 255 for fully opaque. /// diff --git a/api/python/slint/interpreter.rs b/api/python/slint/interpreter.rs index 04a2dccb228..8412b04fc04 100644 --- a/api/python/slint/interpreter.rs +++ b/api/python/slint/interpreter.rs @@ -2,12 +2,13 @@ // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 use std::cell::RefCell; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::rc::Rc; +use std::sync::OnceLock; use pyo3::IntoPyObjectExt; -use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pyclass_enum, gen_stub_pymethods}; +use pyo3_stub_gen::derive::*; use slint_interpreter::{ComponentHandle, Value}; use i_slint_compiler::langtype::Type; @@ -18,6 +19,7 @@ use pyo3::gc::PyVisit; use pyo3::prelude::*; use pyo3::types::PyTuple; use pyo3::PyTraverseError; +use smol_str::SmolStr; use crate::errors::{ PyGetPropertyError, PyInvokeError, PyPlatformError, PySetCallbackError, PySetPropertyError, @@ -68,7 +70,6 @@ impl Compiler { self.compiler.set_library_paths(libraries) } - #[setter] fn set_translation_domain(&mut self, domain: String) { self.compiler.set_translation_domain(domain) } @@ -216,6 +217,11 @@ impl CompilationResult { fn named_exports(&self) -> Vec<(String, String)> { self.result.named_exports(i_slint_core::InternalToken {}).cloned().collect::>() } + + #[getter] + fn resource_paths(&self) -> Vec { + self.result.dependencies().cloned().collect() + } } #[gen_stub_pyclass] @@ -225,6 +231,7 @@ pub struct ComponentDefinition { type_collection: TypeCollection, } +#[gen_stub_pymethods] #[pymethods] impl ComponentDefinition { #[getter] @@ -255,6 +262,39 @@ impl ComponentDefinition { self.definition.globals().collect() } + fn property_infos(&self) -> Vec { + self.definition + .properties_and_callbacks() + .filter_map(|(name, (ty, _))| { + if ty.is_property_type() { + Some(PyPropertyInfo::new(name, &ty)) + } else { + None + } + }) + .collect() + } + + fn callback_infos(&self) -> Vec { + self.definition + .properties_and_callbacks() + .filter_map(|(name, (ty, _))| match ty { + Type::Callback(function) => Some(PyCallbackInfo::new(name, &function)), + _ => None, + }) + .collect() + } + + fn function_infos(&self) -> Vec { + self.definition + .properties_and_callbacks() + .filter_map(|(name, (ty, _))| match ty { + Type::Function(function) => Some(PyFunctionInfo::new(name, &function)), + _ => None, + }) + .collect() + } + fn global_properties(&self, name: &str) -> Option> { self.definition.global_properties_and_callbacks(name).map(|propiter| { propiter @@ -271,6 +311,39 @@ impl ComponentDefinition { self.definition.global_functions(name).map(|functioniter| functioniter.collect()) } + fn global_property_infos(&self, global_name: &str) -> Option> { + self.definition.global_properties_and_callbacks(global_name).map(|iter| { + iter.filter_map(|(name, (ty, _))| { + if ty.is_property_type() { + Some(PyPropertyInfo::new(name, &ty)) + } else { + None + } + }) + .collect() + }) + } + + fn global_callback_infos(&self, global_name: &str) -> Option> { + self.definition.global_properties_and_callbacks(global_name).map(|iter| { + iter.filter_map(|(name, (ty, _))| match ty { + Type::Callback(function) => Some(PyCallbackInfo::new(name, &function)), + _ => None, + }) + .collect() + }) + } + + fn global_function_infos(&self, global_name: &str) -> Option> { + self.definition.global_properties_and_callbacks(global_name).map(|iter| { + iter.filter_map(|(name, (ty, _))| match ty { + Type::Function(function) => Some(PyFunctionInfo::new(name, &function)), + _ => None, + }) + .collect() + }) + } + fn callback_returns_void(&self, callback_name: &str) -> bool { let callback_name = normalize_identifier(callback_name); self.definition @@ -357,6 +430,192 @@ impl From for PyValueType { } } +fn is_python_keyword(name: &str) -> bool { + static PYTHON_KEYWORDS: OnceLock> = OnceLock::new(); + let keywords = PYTHON_KEYWORDS.get_or_init(|| { + let keywords: HashSet<&str> = HashSet::from([ + "False", "await", "else", "import", "pass", "None", "break", "except", "in", "raise", + "True", "class", "finally", "is", "return", "and", "continue", "for", "lambda", "try", + "as", "def", "from", "nonlocal", "while", "assert", "del", "global", "not", "with", + "async", "elif", "if", "or", "yield", + ]); + keywords + }); + keywords.contains(name) +} + +fn python_identifier(name: &str) -> String { + if name.is_empty() { + return String::new(); + } + let mut ident = name.replace('-', "_"); + if ident.chars().next().is_some_and(|c| c.is_ascii_digit()) { + ident.insert(0, '_'); + } + if is_python_keyword(&ident) { + ident.push('_'); + } + ident +} + +fn type_to_python_hint(ty: &i_slint_compiler::langtype::Type) -> String { + use i_slint_compiler::langtype::Type::*; + + match ty { + Void => "None".into(), + Bool => "bool".into(), + Int32 => "int".into(), + Float32 | Duration | PhysicalLength | LogicalLength | Rem | Angle | Percent + | UnitProduct(_) => "float".into(), + String => "str".into(), + Brush | Color => "slint.Brush".into(), + Image => "slint.Image".into(), + Model => "slint.Model".into(), + Array(inner) => format!("slint.ListModel[{}]", type_to_python_hint(inner)), + Struct(struct_ty) => struct_to_python_hint(struct_ty), + // Enumeration(enum_ty) => { + // let name = enum_ty.name.as_str(); + // let tail = name.rsplit("::").next().unwrap_or(name); + // format!("slint.{}", python_identifier(tail)) + // } + Callback(function) | Function(function) => function_to_python_hint(function), + // ComponentFactory => "slint.ComponentFactory".into(), // TODO + // PathData | Easing | ElementReference | LayoutCache | InferredProperty + // | InferredCallback | Invalid => "Any".into(), + _ => "Any".into(), + } +} + +fn struct_to_python_hint(struct_ty: &Rc) -> String { + if let Some(inner_ty) = optional_struct_inner(struct_ty) { + return format!("Optional[{}]", type_to_python_hint(inner_ty)); + } + + if let Some(name) = &struct_ty.name { + let full = name.as_str(); + let tail = full.rsplit("::").next().unwrap_or(full); + if full.starts_with("slint::") { + return format!("slint.{}", python_identifier(tail)); + } + return python_identifier(tail); + } + + "dict[str, Any]".into() +} + +fn optional_struct_inner( + struct_ty: &Rc, +) -> Option<&i_slint_compiler::langtype::Type> { + let name = struct_ty.name.as_ref()?; + let tail = name.as_str().rsplit("::").next().unwrap_or(name.as_str()); + let tail_lower = tail.to_ascii_lowercase(); + if !tail_lower.starts_with("optional") { + return None; + } + + if let Some(value_ty) = + struct_ty.fields.get("value").or_else(|| struct_ty.fields.get("maybe_value")) + { + return Some(value_ty); + } + + struct_ty.fields.values().next() +} + +fn function_to_python_hint(function: &Rc) -> String { + let args: Vec = function.args.iter().map(type_to_python_hint).collect(); + let return_type = type_to_python_hint(&function.return_type); + + if args.is_empty() { + if function.return_type == i_slint_compiler::langtype::Type::Void { + "Callable[..., Any]".into() + } else { + format!("Callable[[], {}]", return_type) + } + } else { + format!("Callable[[{}], {}]", args.join(", "), return_type) + } +} + +#[gen_stub_pyclass] +#[pyclass(module = "slint.core", name = "PropertyInfo")] +#[derive(Clone)] +pub struct PyPropertyInfo { + #[pyo3(get)] + pub name: String, + #[pyo3(get)] + pub python_type: String, +} + +impl PyPropertyInfo { + fn new(name: String, ty: &i_slint_compiler::langtype::Type) -> Self { + Self { name, python_type: type_to_python_hint(ty) } + } +} + +#[gen_stub_pyclass] +#[pyclass(module = "slint.core", name = "CallbackParameter")] +#[derive(Clone)] +pub struct PyCallbackParameter { + #[pyo3(get)] + pub name: Option, + #[pyo3(get)] + pub python_type: String, +} + +impl PyCallbackParameter { + fn new(name: Option, ty: &i_slint_compiler::langtype::Type) -> Self { + let name = name.and_then(|n| if n.is_empty() { None } else { Some(n.into()) }); + Self { name, python_type: type_to_python_hint(ty) } + } +} + +#[gen_stub_pyclass] +#[pyclass(module = "slint.core", name = "CallbackInfo")] +#[derive(Clone)] +pub struct PyCallbackInfo { + #[pyo3(get)] + pub name: String, + #[pyo3(get)] + pub parameters: Vec, + #[pyo3(get)] + pub return_type: String, +} + +impl PyCallbackInfo { + fn new(name: String, function: &Rc) -> Self { + let mut parameters = Vec::with_capacity(function.args.len()); + for (idx, arg_ty) in function.args.iter().enumerate() { + let arg_name = function.arg_names.get(idx).cloned(); + parameters.push(PyCallbackParameter::new(arg_name, arg_ty)); + } + Self { name, parameters, return_type: type_to_python_hint(&function.return_type) } + } +} + +#[gen_stub_pyclass] +#[pyclass(module = "slint.core", name = "FunctionInfo")] +#[derive(Clone)] +pub struct PyFunctionInfo { + #[pyo3(get)] + pub name: String, + #[pyo3(get)] + pub parameters: Vec, + #[pyo3(get)] + pub return_type: String, +} + +impl PyFunctionInfo { + fn new(name: String, function: &Rc) -> Self { + let mut parameters = Vec::with_capacity(function.args.len()); + for (idx, arg_ty) in function.args.iter().enumerate() { + let arg_name = function.arg_names.get(idx).cloned(); + parameters.push(PyCallbackParameter::new(arg_name, arg_ty)); + } + Self { name, parameters, return_type: type_to_python_hint(&function.return_type) } + } +} + #[gen_stub_pyclass] #[pyclass(unsendable, weakref)] pub struct ComponentInstance { @@ -366,6 +625,7 @@ pub struct ComponentInstance { type_collection: TypeCollection, } +#[gen_stub_pymethods] #[pymethods] impl ComponentInstance { #[getter] @@ -376,6 +636,7 @@ impl ComponentInstance { } } + #[gen_stub(override_return_type(type_repr = "typing.Any", imports = ("typing",)))] fn get_property(&self, name: &str) -> Result { Ok(self.type_collection.to_py_value(self.instance.get_property(name)?)) } @@ -386,6 +647,7 @@ impl ComponentInstance { Ok(self.instance.set_property(name, pv).map_err(|e| PySetPropertyError(e))?) } + #[gen_stub(override_return_type(type_repr = "typing.Any", imports = ("typing",)))] fn get_global_property( &self, global_name: &str, @@ -411,6 +673,7 @@ impl ComponentInstance { } #[pyo3(signature = (callback_name, *args))] + #[gen_stub(override_return_type(type_repr = "typing.Any", imports = ("typing",)))] fn invoke(&self, callback_name: &str, args: Bound<'_, PyTuple>) -> PyResult { let mut rust_args = vec![]; for arg in args.iter() { @@ -424,6 +687,7 @@ impl ComponentInstance { } #[pyo3(signature = (global_name, callback_name, *args))] + #[gen_stub(override_return_type(type_repr = "typing.Any", imports = ("typing",)))] fn invoke_global( &self, global_name: &str, @@ -473,6 +737,7 @@ impl ComponentInstance { Ok(self.instance.hide()?) } + #[gen_stub(skip)] fn __traverse__(&self, visit: PyVisit<'_>) -> Result<(), PyTraverseError> { self.callbacks.__traverse__(&visit)?; for global_callbacks in self.global_callbacks.values() { diff --git a/api/python/slint/lib.rs b/api/python/slint/lib.rs index 2489a32572d..8a662559ede 100644 --- a/api/python/slint/lib.rs +++ b/api/python/slint/lib.rs @@ -158,8 +158,8 @@ impl Translator for PyGettextTranslator { use pyo3::prelude::*; -#[pymodule] -fn slint(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { +#[pymodule(name = "core")] +fn slint_core(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { i_slint_backend_selector::with_platform(|_b| { // Nothing to do, just make sure a backend was created Ok(()) @@ -180,6 +180,10 @@ fn slint(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_function(wrap_pyfunction!(run_event_loop, m)?)?; m.add_function(wrap_pyfunction!(quit_event_loop, m)?)?; diff --git a/api/python/slint/models.rs b/api/python/slint/models.rs index c07225d6a54..e379e3d5e02 100644 --- a/api/python/slint/models.rs +++ b/api/python/slint/models.rs @@ -6,10 +6,11 @@ use std::rc::Rc; use i_slint_core::model::{Model, ModelNotify, ModelRc}; -use pyo3::exceptions::PyIndexError; +use pyo3::exceptions::{PyIndexError, PyNotImplementedError}; use pyo3::gc::PyVisit; use pyo3::prelude::*; use pyo3::PyTraverseError; +use pyo3_stub_gen::derive::*; use crate::value::{SlintToPyValue, TypeCollection}; @@ -43,6 +44,8 @@ impl PyModelShared { } #[derive(Clone)] +#[gen_stub_pyclass] +#[gen_stub(abstract_class)] #[pyclass(unsendable, weakref, subclass)] pub struct PyModelBase { inner: Rc, @@ -54,6 +57,7 @@ impl PyModelBase { } } +#[gen_stub_pymethods] #[pymethods] impl PyModelBase { #[new] @@ -71,18 +75,46 @@ impl PyModelBase { *self.inner.self_ref.borrow_mut() = Some(self_ref); } + /// Call this method from a sub-class to notify the views that + /// `count` rows have been added starting at `index`. fn notify_row_added(&self, index: usize, count: usize) { self.inner.notify.row_added(index, count) } + /// Call this method from a sub-class to notify the views that a row has changed. fn notify_row_changed(&self, index: usize) { self.inner.notify.row_changed(index) } + /// Call this method from a sub-class to notify the views that + /// `count` rows have been removed starting at `index`. fn notify_row_removed(&self, index: usize, count: usize) { self.inner.notify.row_removed(index, count) } + /// Returns the number of rows available in the model. + #[gen_stub(abstractmethod)] + fn row_count(&self) -> PyResult { + Err(PyNotImplementedError::new_err("Model subclasses must override row_count()")) + } + + /// Returns the data for the given row in the model. + #[gen_stub(abstractmethod)] + fn row_data(&self, _row: usize) -> PyResult>> { + Err(PyNotImplementedError::new_err("Model subclasses must override row_data()")) + } + + /// Call this method on mutable models to change the data for the given row. + /// The UI will also call this method when modifying a model's data. + /// Re-implement this method in a sub-class to handle the change. + #[gen_stub(abstractmethod)] + fn set_row_data(&self, _row: usize, _value: Py) -> PyResult<()> { + Err(PyNotImplementedError::new_err( + "Model subclasses must override set_row_data() when mutation is required", + )) + } + + #[gen_stub(skip)] fn __traverse__(&self, visit: PyVisit<'_>) -> Result<(), PyTraverseError> { self.inner.__traverse__(&visit) } @@ -214,18 +246,21 @@ impl PyModelShared { } } +#[gen_stub_pyclass] #[pyclass(unsendable)] pub struct ReadOnlyRustModel { pub model: ModelRc, pub type_collection: TypeCollection, } +#[gen_stub_pymethods] #[pymethods] impl ReadOnlyRustModel { fn row_count(&self) -> usize { self.model.row_count() } + #[gen_stub(override_return_type(type_repr = "typing.Any", imports = ("typing",)))] fn row_data(&self, row: usize) -> Option { self.model.row_data(row).map(|value| self.type_collection.to_py_value(value)) } @@ -242,11 +277,13 @@ impl ReadOnlyRustModel { } } + #[gen_stub(override_return_type(type_repr = "typing.Any", imports = ("typing",)))] fn __getitem__(&self, index: usize) -> Option { self.row_data(index) } } +#[gen_stub_pyclass] #[pyclass(unsendable)] struct ReadOnlyRustModelIterator { model: ModelRc, @@ -254,12 +291,14 @@ struct ReadOnlyRustModelIterator { type_collection: TypeCollection, } +#[gen_stub_pymethods] #[pymethods] impl ReadOnlyRustModelIterator { fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { slf } + #[gen_stub(override_return_type(type_repr = "typing.Any", imports = ("typing",)))] fn __next__(&mut self) -> Option { if self.row >= self.model.row_count() { return None; diff --git a/api/python/slint/noxfile.py b/api/python/slint/noxfile.py deleted file mode 100644 index 078dc8cd118..00000000000 --- a/api/python/slint/noxfile.py +++ /dev/null @@ -1,11 +0,0 @@ -# Copyright © SixtyFPS GmbH -# SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 - -import nox - - -@nox.session(python="3.12") -def python(session: nox.Session): - session.env["MATURIN_PEP517_ARGS"] = "--profile=dev" - session.install(".[dev]") - session.run("pytest", "-s", "-v") diff --git a/api/python/slint/pyproject.toml b/api/python/slint/pyproject.toml index 8f3555da214..8eae04a756d 100644 --- a/api/python/slint/pyproject.toml +++ b/api/python/slint/pyproject.toml @@ -24,10 +24,10 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: User Interfaces", "Topic :: Software Development :: Widget Sets", - "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", ] +dependencies = ["libcst>=1.8.5"] [project.urls] Homepage = "https://slint.dev" @@ -36,9 +36,6 @@ Repository = "https://github.com/slint-ui/slint" Changelog = "https://github.com/slint-ui/slint/blob/master/CHANGELOG.md" Tracker = "https://github.com/slint-ui/slint/issues" -[project.optional-dependencies] -dev = ["pytest", "numpy>=2.3.2", "pillow>=11.3.0", "aiohttp>=3.12.15"] - [dependency-groups] dev = [ "mypy>=1.15.0", @@ -49,13 +46,20 @@ dev = [ "pillow>=11.3.0", "numpy>=2.3.2", "aiohttp>=3.12.15", + "maturin>=1.9.6", + "pytest-cov>=7.0.0", ] +[tool.maturin] +module-name = "slint.core" +python-packages = ["slint"] +features = ["pyo3/extension-module"] + [tool.uv] # Rebuild package when any rust files change cache-keys = [{ file = "pyproject.toml" }, { file = "Cargo.toml" }, { file = "**/*.rs" }] -# Uncomment to build rust code in development mode -# config-settings = { build-args = '--profile=dev' } +# Build rust code in development mode +config-settings = { build-args = '--profile=dev' } [tool.mypy] strict = true diff --git a/api/python/slint/slint/__init__.py b/api/python/slint/slint/__init__.py index b90e134339c..881e593459b 100644 --- a/api/python/slint/slint/__init__.py +++ b/api/python/slint/slint/__init__.py @@ -1,553 +1,22 @@ # Copyright © SixtyFPS GmbH # SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 -r""" -.. include:: ../README.md -""" - -import os -import sys -from . import slint as native -import types -import logging -import copy -import typing -from typing import Any -import pathlib -from .models import ListModel, Model -from .slint import Image, Color, Brush, Timer, TimerMode -from .loop import SlintEventLoop -from pathlib import Path -from collections.abc import Coroutine -import asyncio -import gettext - -Struct = native.PyStruct - - -class CompileError(Exception): - message: str - """The error message that produced this compile error.""" - - diagnostics: list[native.PyDiagnostic] - """A list of detailed diagnostics that were produced as part of the compilation.""" - - def __init__(self, message: str, diagnostics: list[native.PyDiagnostic]): - """@private""" - super().__init__(message) - self.message = message - self.diagnostics = diagnostics - for diag in self.diagnostics: - self.add_note(str(diag)) - - -class Component: - """Component is the base class for all instances of Slint components. Use the member functions to show or hide the - window, or spin the event loop.""" - - __instance__: native.ComponentInstance - - def show(self) -> None: - """Shows the window on the screen.""" - - self.__instance__.show() - - def hide(self) -> None: - """Hides the window from the screen.""" - - self.__instance__.hide() - - def run(self) -> None: - """Shows the window, runs the event loop, hides it when the loop is quit, and returns.""" - self.show() - run_event_loop() - self.hide() - - -def _normalize_prop(name: str) -> str: - return name.replace("-", "_") - - -def _build_global_class(compdef: native.ComponentDefinition, global_name: str) -> Any: - properties_and_callbacks = {} - - for prop_name in compdef.global_properties(global_name).keys(): - python_prop = _normalize_prop(prop_name) - if python_prop in properties_and_callbacks: - logging.warning(f"Duplicated property {prop_name}") - continue - - def mk_setter_getter(prop_or_callback_name: str) -> property: - def getter(self: Component) -> Any: - return self.__instance__.get_global_property( - global_name, prop_or_callback_name - ) - - def setter(self: Component, value: Any) -> None: - self.__instance__.set_global_property( - global_name, prop_or_callback_name, value - ) - - return property(getter, setter) - - properties_and_callbacks[python_prop] = mk_setter_getter(prop_name) - - for callback_name in compdef.global_callbacks(global_name): - python_prop = _normalize_prop(callback_name) - if python_prop in properties_and_callbacks: - logging.warning(f"Duplicated property {prop_name}") - continue - - def mk_setter_getter(prop_or_callback_name: str) -> property: - def getter(self: Component) -> typing.Callable[..., Any]: - def call(*args: Any) -> Any: - return self.__instance__.invoke_global( - global_name, prop_or_callback_name, *args - ) - - return call - - def setter(self: Component, value: typing.Callable[..., Any]) -> None: - self.__instance__.set_global_callback( - global_name, prop_or_callback_name, value - ) - - return property(getter, setter) - - properties_and_callbacks[python_prop] = mk_setter_getter(callback_name) - - for function_name in compdef.global_functions(global_name): - python_prop = _normalize_prop(function_name) - if python_prop in properties_and_callbacks: - logging.warning(f"Duplicated function {prop_name}") - continue - - def mk_getter(function_name: str) -> property: - def getter(self: Component) -> typing.Callable[..., Any]: - def call(*args: Any) -> Any: - return self.__instance__.invoke_global( - global_name, function_name, *args - ) - - return call - - return property(getter) - - properties_and_callbacks[python_prop] = mk_getter(function_name) - - return type("SlintGlobalClassWrapper", (), properties_and_callbacks) - - -def _build_class( - compdef: native.ComponentDefinition, -) -> typing.Callable[..., Component]: - def cls_init(self: Component, **kwargs: Any) -> Any: - self.__instance__ = compdef.create() - for name, value in self.__class__.__dict__.items(): - if hasattr(value, "slint.callback"): - callback_info = getattr(value, "slint.callback") - name = callback_info["name"] - - is_async = getattr(value, "slint.async", False) - if is_async: - if "global_name" in callback_info: - global_name = callback_info["global_name"] - if not compdef.global_callback_returns_void(global_name, name): - raise RuntimeError( - f"Callback '{name}' in global '{global_name}' cannot be used with a callback decorator for an async function, as it doesn't return void" - ) - else: - if not compdef.callback_returns_void(name): - raise RuntimeError( - f"Callback '{name}' cannot be used with a callback decorator for an async function, as it doesn't return void" - ) - - def mk_callback( - self: Any, callback: typing.Callable[..., Any] - ) -> typing.Callable[..., Any]: - def invoke(*args: Any, **kwargs: Any) -> Any: - return callback(self, *args, **kwargs) - - return invoke - - if "global_name" in callback_info: - self.__instance__.set_global_callback( - callback_info["global_name"], name, mk_callback(self, value) - ) - else: - self.__instance__.set_callback(name, mk_callback(self, value)) - - for prop, val in kwargs.items(): - setattr(self, prop, val) - - properties_and_callbacks: dict[Any, Any] = {"__init__": cls_init} - - for prop_name in compdef.properties.keys(): - python_prop = _normalize_prop(prop_name) - if python_prop in properties_and_callbacks: - logging.warning(f"Duplicated property {prop_name}") - continue - - def mk_setter_getter(prop_or_callback_name: str) -> property: - def getter(self: Component) -> Any: - return self.__instance__.get_property(prop_or_callback_name) - - def setter(self: Component, value: Any) -> None: - self.__instance__.set_property(prop_or_callback_name, value) - - return property(getter, setter) - - properties_and_callbacks[python_prop] = mk_setter_getter(prop_name) - - for callback_name in compdef.callbacks: - python_prop = _normalize_prop(callback_name) - if python_prop in properties_and_callbacks: - logging.warning(f"Duplicated property {prop_name}") - continue - - def mk_setter_getter(prop_or_callback_name: str) -> property: - def getter(self: Component) -> typing.Callable[..., Any]: - def call(*args: Any) -> Any: - return self.__instance__.invoke(prop_or_callback_name, *args) - - return call - - def setter(self: Component, value: typing.Callable[..., Any]) -> None: - self.__instance__.set_callback(prop_or_callback_name, value) - - return property(getter, setter) - - properties_and_callbacks[python_prop] = mk_setter_getter(callback_name) - - for function_name in compdef.functions: - python_prop = _normalize_prop(function_name) - if python_prop in properties_and_callbacks: - logging.warning(f"Duplicated function {prop_name}") - continue - - def mk_getter(function_name: str) -> property: - def getter(self: Component) -> typing.Callable[..., Any]: - def call(*args: Any) -> Any: - return self.__instance__.invoke(function_name, *args) - - return call - - return property(getter) - - properties_and_callbacks[python_prop] = mk_getter(function_name) - - for global_name in compdef.globals: - global_class = _build_global_class(compdef, global_name) - - def mk_global(global_class: typing.Callable[..., Any]) -> property: - def global_getter(self: Component) -> Any: - wrapper = global_class() - setattr(wrapper, "__instance__", self.__instance__) - return wrapper - - return property(global_getter) - - properties_and_callbacks[global_name] = mk_global(global_class) - - return type("SlintClassWrapper", (Component,), properties_and_callbacks) - - -def _build_struct(name: str, struct_prototype: native.PyStruct) -> type: - def new_struct(cls: Any, *args: Any, **kwargs: Any) -> native.PyStruct: - inst = copy.copy(struct_prototype) - - for prop, val in kwargs.items(): - setattr(inst, prop, val) - - return inst - - type_dict = { - "__new__": new_struct, - } - - return type(name, (), type_dict) - - -def load_file( - path: str | os.PathLike[Any] | pathlib.Path, - quiet: bool = False, - style: typing.Optional[str] = None, - include_paths: typing.Optional[typing.List[os.PathLike[Any] | pathlib.Path]] = None, - library_paths: typing.Optional[ - typing.Dict[str, os.PathLike[Any] | pathlib.Path] - ] = None, - translation_domain: typing.Optional[str] = None, -) -> types.SimpleNamespace: - """This function is the low-level entry point into Slint for instantiating components. It loads the `.slint` file at - the specified `path` and returns a namespace with all exported components as Python classes, as well as enums, and structs. - - * `quiet`: Set to true to prevent any warnings during compilation from being printed to stderr. - * `style`: Specify a widget style. - * `include_paths`: Additional include paths used to look up `.slint` files imported from other `.slint` files. - * `library_paths`: A dictionary that maps library names to their location in the file system. This is then used to look up - library imports, such as `import { MyButton } from "@mylibrary";`. - * `translation_domain`: The domain to use for looking up the catalogue run-time translations. This must match the - translation domain used when extracting translations with `slint-tr-extractor`. - - """ - - compiler = native.Compiler() - - if style is not None: - compiler.style = style - if include_paths is not None: - compiler.include_paths = include_paths - if library_paths is not None: - compiler.library_paths = library_paths - if translation_domain is not None: - compiler.translation_domain = translation_domain - - result = compiler.build_from_path(Path(path)) - - diagnostics = result.diagnostics - if diagnostics: - if not quiet: - for diag in diagnostics: - if diag.level == native.DiagnosticLevel.Warning: - logging.warning(diag) - - errors = [ - diag for diag in diagnostics if diag.level == native.DiagnosticLevel.Error - ] - if errors: - raise CompileError(f"Could not compile {path}", diagnostics) - - module = types.SimpleNamespace() - for comp_name in result.component_names: - wrapper_class = _build_class(result.component(comp_name)) - - setattr(module, comp_name, wrapper_class) - - structs, enums = result.structs_and_enums - - for name, struct_prototype in structs.items(): - name = _normalize_prop(name) - struct_wrapper = _build_struct(name, struct_prototype) - setattr(module, name, struct_wrapper) - - for name, enum_class in enums.items(): - name = _normalize_prop(name) - setattr(module, name, enum_class) - - for orig_name, new_name in result.named_exports: - orig_name = _normalize_prop(orig_name) - new_name = _normalize_prop(new_name) - setattr(module, new_name, getattr(module, orig_name)) - - return module - - -class SlintAutoLoader: - def __init__(self, base_dir: Path | None = None): - self.local_dirs: typing.List[Path] | None = None - if base_dir: - self.local_dirs = [base_dir] - - def __getattr__(self, name: str) -> Any: - for path in self.local_dirs or sys.path: - dir_candidate = Path(path) / name - if os.path.isdir(dir_candidate): - loader = SlintAutoLoader(dir_candidate) - setattr(self, name, loader) - return loader - - file_candidate = dir_candidate.with_suffix(".slint") - if os.path.isfile(file_candidate): - type_namespace = load_file(file_candidate) - setattr(self, name, type_namespace) - return type_namespace - - dir_candidate = Path(path) / name.replace("_", "-") - file_candidate = dir_candidate.with_suffix(".slint") - if os.path.isfile(file_candidate): - type_namespace = load_file(file_candidate) - setattr(self, name, type_namespace) - return type_namespace - - return None - - -loader = SlintAutoLoader() -"""Use the global `loader` object to load Slint files from the file system. It exposes two stages of attributes: -1. Any lookup of an attribute in the loader tries to match a file in `sys.path` with the `.slint` extension. For example - `loader.my_component` looks for a file `my_component.slint` in the directories in `sys.path`. -2. Any lookup in the object returned by the first stage tries to match an exported component in the loaded file, or a - struct, or enum. For example `loader.my_component.MyComponent` looks for an *exported* component named `MyComponent` - in the file `my_component.slint`. - -**Note:** The first entry in the module search path `sys.path` is the directory that contains the input script. - -Example: -```python -import slint -# Look for a file `main.slint` in the current directory, -# #load & compile it, and instantiate the exported `MainWindow` component -main_window = slint.loader.main_window.MainWindow() -main_window.show() -... -``` -""" - - -def _callback_decorator( - callable: typing.Callable[..., Any], info: typing.Dict[str, Any] -) -> typing.Callable[..., Any]: - if "name" not in info: - info["name"] = callable.__name__ - setattr(callable, "slint.callback", info) - - try: - import inspect - - if inspect.iscoroutinefunction(callable): - - def run_as_task(*args, **kwargs) -> None: # type: ignore - loop = asyncio.get_event_loop() - loop.create_task(callable(*args, **kwargs)) - - setattr(run_as_task, "slint.callback", info) - setattr(run_as_task, "slint.async", True) - return run_as_task - except ImportError: - pass - - return callable - - -def callback( - global_name: str | None = None, name: str | None = None -) -> typing.Callable[..., Any]: - """Use the callback decorator to mark a method as a callback that can be invoked from the Slint component. - - For the decorator to work, the method must be a member of a class that is Slint component. - - Example: - ```python - import slint - - class AppMainWindow(slint.loader.main_window.MainWindow): - - # Automatically connected to a callback button_clicked() - # in main_window.slint's MainWindow. - @slint.callback() - def button_clicked(self): - print("Button clicked") - - ... - ``` - - If your Python method has a different name from the Slint component's callback, use the `name` parameter to specify - the correct name. Similarly, use the `global_name` parameter to specify the name of the correct global singleton in - the Slint component. - - **Note:** The callback decorator can also be used with async functions. They will be run as task in the asyncio event loop. - This is only supported for callbacks that don't return any value, and requires Python >= 3.13. - """ - - if callable(global_name): - callback = global_name - return _callback_decorator(callback, {}) - else: - info = {} - if name: - info["name"] = name - if global_name: - info["global_name"] = global_name - return lambda callback: _callback_decorator(callback, info) - - -def set_xdg_app_id(app_id: str) -> None: - """Sets the application id for use on Wayland or X11 with [xdg](https://specifications.freedesktop.org/desktop-entry-spec/latest/) - compliant window managers. This id must be set before the window is shown; it only applies to Wayland or X11.""" - - native.set_xdg_app_id(app_id) - - -quit_event = asyncio.Event() - - -def run_event_loop( - main_coro: typing.Optional[Coroutine[None, None, None]] = None, -) -> None: - """Runs the main Slint event loop. If specified, the coroutine `main_coro` is run in parallel. The event loop doesn't - terminate when the coroutine finishes, it terminates when calling `quit_event_loop()`. - - Example: - ```python - import slint - - ... - image_model: slint.ListModel[slint.Image] = slint.ListModel() - ... - - async def main_receiver(image_model: slint.ListModel) -> None: - async with aiohttp.ClientSession() as session: - async with session.get("http://some.server/svg-image") as response: - svg = await response.read() - image = slint.Image.from_svg_data(svg) - image_model.append(image) - - ... - slint.run_event_loop(main_receiver(image_model)) - ``` - - """ - - async def run_inner() -> None: - global quit_event - loop = typing.cast(SlintEventLoop, asyncio.get_event_loop()) - - quit_task = asyncio.ensure_future(quit_event.wait(), loop=loop) - - tasks: typing.List[asyncio.Task[typing.Any]] = [quit_task] - - main_task = None - if main_coro: - main_task = loop.create_task(main_coro) - tasks.append(main_task) - - done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) - - if main_task is not None and main_task in done: - main_task.result() # propagate exception if thrown - if quit_task in pending: - await quit_event.wait() - - global quit_event - quit_event = asyncio.Event() - asyncio.run(run_inner(), debug=False, loop_factory=SlintEventLoop) - - -def quit_event_loop() -> None: - """Quits the running event loop in the next event processing cycle. This will make an earlier call to `run_event_loop()` - return.""" - global quit_event - quit_event.set() - - -def init_translations(translations: typing.Optional[gettext.GNUTranslations]) -> None: - """Installs the specified translations object to handle translations originating from the Slint code. - - Example: - ```python - import gettext - import slint - - translations_dir = os.path.join(os.path.dirname(__file__), "lang") - try: - translations = gettext.translation("my_app", translations_dir, ["de"]) - slint.install_translations(translations) - except OSError: - pass - ``` - """ - native.init_translations(translations) - +from .api import Brush as Brush +from .api import Color as Color +from .api import CompileError as CompileError +from .api import Component as Component +from .api import Image as Image +from .api import ListModel as ListModel +from .api import Model as Model +from .api import Timer as Timer +from .api import TimerMode as TimerMode +from .api import callback as callback +from .api import init_translations as init_translations +from .api import load_file as load_file +from .api import loader as loader +from .api import quit_event_loop as quit_event_loop +from .api import run_event_loop as run_event_loop +from .api import set_xdg_app_id as set_xdg_app_id __all__ = [ "CompileError", diff --git a/api/python/slint/slint/api.py b/api/python/slint/slint/api.py new file mode 100644 index 00000000000..cfa0085b990 --- /dev/null +++ b/api/python/slint/slint/api.py @@ -0,0 +1,638 @@ +# Copyright © SixtyFPS GmbH +# SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +r""" +.. include:: ../README.md +""" + +import asyncio +import copy +import gettext +import keyword +import logging +import os +import pathlib +import sys +import types +import typing +from collections.abc import Coroutine +from pathlib import Path +from typing import Any, Callable, TypeVar, overload + +from .core import ( + Brush, + Color, + Compiler, + ComponentDefinition, + ComponentInstance, + DiagnosticLevel, + Image, + PyDiagnostic, + PyStruct, + Timer, + TimerMode, +) +from .core import ( + init_translations as _slint_init_translations, +) +from .loop import SlintEventLoop +from .models import ListModel, Model + + +class CompileError(Exception): + message: str + """The error message that produced this compile error.""" + + diagnostics: list[PyDiagnostic] + """A list of detailed diagnostics that were produced as part of the compilation.""" + + def __init__(self, message: str, diagnostics: list[PyDiagnostic]): + """@private""" + super().__init__(message) + self.message = message + self.diagnostics = diagnostics + for diag in self.diagnostics: + self.add_note(str(diag)) + + +class Component: + """Component is the base class for all instances of Slint components. Use the member functions to show or hide the + window, or spin the event loop.""" + + __instance__: ComponentInstance + + def show(self) -> None: + """Shows the window on the screen.""" + + self.__instance__.show() + + def hide(self) -> None: + """Hides the window from the screen.""" + + self.__instance__.hide() + + def run(self) -> None: + """Shows the window, runs the event loop, hides it when the loop is quit, and returns.""" + self.show() + run_event_loop() + self.hide() + + +def _normalize_prop(name: str) -> str: + ident = name.replace("-", "_") + if ident and ident[0].isdigit(): + ident = f"_{ident}" + if keyword.iskeyword(ident): + ident = f"{ident}_" + return ident + + +def _build_global_class(compdef: ComponentDefinition, global_name: str) -> Any: + properties_and_callbacks = {} + + global_props = compdef.global_properties(global_name) + global_callbacks = compdef.global_callbacks(global_name) + global_functions = compdef.global_functions(global_name) + + assert global_props is not None + assert global_callbacks is not None + assert global_functions is not None + + for prop_name in global_props.keys(): + python_prop = _normalize_prop(prop_name) + if python_prop in properties_and_callbacks: + logging.warning(f"Duplicated property {prop_name}") + continue + + def mk_setter_getter(prop_or_callback_name: str) -> property: + def getter(self: Component) -> Any: + return self.__instance__.get_global_property( + global_name, prop_or_callback_name + ) + + def setter(self: Component, value: Any) -> None: + self.__instance__.set_global_property( + global_name, prop_or_callback_name, value + ) + + return property(getter, setter) + + properties_and_callbacks[python_prop] = mk_setter_getter(prop_name) + + for callback_name in global_callbacks: + python_prop = _normalize_prop(callback_name) + if python_prop in properties_and_callbacks: + logging.warning(f"Duplicated property {prop_name}") + continue + + def mk_setter_getter(prop_or_callback_name: str) -> property: + def getter(self: Component) -> typing.Callable[..., Any]: + def call(*args: Any) -> Any: + return self.__instance__.invoke_global( + global_name, prop_or_callback_name, *args + ) + + return call + + def setter(self: Component, value: typing.Callable[..., Any]) -> None: + self.__instance__.set_global_callback( + global_name, prop_or_callback_name, value + ) + + return property(getter, setter) + + properties_and_callbacks[python_prop] = mk_setter_getter(callback_name) + + for function_name in global_functions: + python_prop = _normalize_prop(function_name) + if python_prop in properties_and_callbacks: + logging.warning(f"Duplicated function {prop_name}") + continue + + def mk_getter(function_name: str) -> property: + def getter(self: Component) -> typing.Callable[..., Any]: + def call(*args: Any) -> Any: + return self.__instance__.invoke_global( + global_name, function_name, *args + ) + + return call + + return property(getter) + + properties_and_callbacks[python_prop] = mk_getter(function_name) + + return type("SlintGlobalClassWrapper", (), properties_and_callbacks) + + +def _build_class( + compdef: ComponentDefinition, +) -> typing.Callable[..., Component]: + def cls_init(self: Component, **kwargs: Any) -> Any: + self.__instance__ = compdef.create() + for name, value in self.__class__.__dict__.items(): + if hasattr(value, "slint.callback"): + callback_info = getattr(value, "slint.callback") + name = callback_info["name"] + + is_async = getattr(value, "slint.async", False) + if is_async: + if "global_name" in callback_info: + global_name = callback_info["global_name"] + if not compdef.global_callback_returns_void(global_name, name): + raise RuntimeError( + f"Callback '{name}' in global '{global_name}' cannot be used with a callback decorator for an async function, as it doesn't return void" + ) + else: + if not compdef.callback_returns_void(name): + raise RuntimeError( + f"Callback '{name}' cannot be used with a callback decorator for an async function, as it doesn't return void" + ) + + def mk_callback( + self: Any, callback: typing.Callable[..., Any] + ) -> typing.Callable[..., Any]: + def invoke(*args: Any, **kwargs: Any) -> Any: + return callback(self, *args, **kwargs) + + return invoke + + if "global_name" in callback_info: + self.__instance__.set_global_callback( + callback_info["global_name"], name, mk_callback(self, value) + ) + else: + self.__instance__.set_callback(name, mk_callback(self, value)) + + for prop, val in kwargs.items(): + setattr(self, prop, val) + + properties_and_callbacks: dict[Any, Any] = {"__init__": cls_init} + + for prop_name in compdef.properties.keys(): + python_prop = _normalize_prop(prop_name) + if python_prop in properties_and_callbacks: + logging.warning(f"Duplicated property {prop_name}") + continue + + def mk_setter_getter(prop_or_callback_name: str) -> property: + def getter(self: Component) -> Any: + return self.__instance__.get_property(prop_or_callback_name) + + def setter(self: Component, value: Any) -> None: + self.__instance__.set_property(prop_or_callback_name, value) + + return property(getter, setter) + + properties_and_callbacks[python_prop] = mk_setter_getter(prop_name) + + for callback_name in compdef.callbacks: + python_prop = _normalize_prop(callback_name) + if python_prop in properties_and_callbacks: + logging.warning(f"Duplicated property {prop_name}") + continue + + def mk_setter_getter(prop_or_callback_name: str) -> property: + def getter(self: Component) -> typing.Callable[..., Any]: + def call(*args: Any) -> Any: + return self.__instance__.invoke(prop_or_callback_name, *args) + + return call + + def setter(self: Component, value: typing.Callable[..., Any]) -> None: + self.__instance__.set_callback(prop_or_callback_name, value) + + return property(getter, setter) + + properties_and_callbacks[python_prop] = mk_setter_getter(callback_name) + + for function_name in compdef.functions: + python_prop = _normalize_prop(function_name) + if python_prop in properties_and_callbacks: + logging.warning(f"Duplicated function {prop_name}") + continue + + def mk_getter(function_name: str) -> property: + def getter(self: Component) -> typing.Callable[..., Any]: + def call(*args: Any) -> Any: + return self.__instance__.invoke(function_name, *args) + + return call + + return property(getter) + + properties_and_callbacks[python_prop] = mk_getter(function_name) + + for global_name in compdef.globals: + global_class = _build_global_class(compdef, global_name) + + def mk_global(global_class: typing.Callable[..., Any]) -> property: + def global_getter(self: Component) -> Any: + wrapper = global_class() + setattr(wrapper, "__instance__", self.__instance__) + return wrapper + + return property(global_getter) + + properties_and_callbacks[global_name] = mk_global(global_class) + + return type("SlintClassWrapper", (Component,), properties_and_callbacks) + + +def _build_struct(name: str, struct_prototype: PyStruct) -> type: + field_names = {field_name for field_name, _ in struct_prototype} + + def new_struct(cls: Any, *args: Any, **kwargs: Any) -> PyStruct: + if args: + raise TypeError(f"{name}() accepts keyword arguments only") + + unexpected = set(kwargs) - field_names + if unexpected: + formatted = ", ".join(sorted(unexpected)) + raise TypeError(f"{name}() got unexpected keyword argument(s): {formatted}") + + inst = copy.copy(struct_prototype) + + for prop, val in kwargs.items(): + setattr(inst, prop, val) + + return inst + + type_dict = { + "__new__": new_struct, + } + + return type(name, (), type_dict) + + +def load_file( + path: str | os.PathLike[Any] | pathlib.Path, + quiet: bool = False, + style: typing.Optional[str] = None, + include_paths: typing.Optional[typing.List[os.PathLike[Any] | pathlib.Path]] = None, + library_paths: typing.Optional[ + typing.Dict[str, os.PathLike[Any] | pathlib.Path] + ] = None, + translation_domain: typing.Optional[str] = None, +) -> types.SimpleNamespace: + """This function is the low-level entry point into Slint for instantiating components. It loads the `.slint` file at + the specified `path` and returns a namespace with all exported components as Python classes, as well as enums, and structs. + + * `quiet`: Set to true to prevent any warnings during compilation from being printed to stderr. + * `style`: Specify a widget style. + * `include_paths`: Additional include paths used to look up `.slint` files imported from other `.slint` files. + * `library_paths`: A dictionary that maps library names to their location in the file system. This is then used to look up + library imports, such as `import { MyButton } from "@mylibrary";`. + * `translation_domain`: The domain to use for looking up the catalogue run-time translations. This must match the + translation domain used when extracting translations with `slint-tr-extractor`. + + """ + + compiler = Compiler() + + if style is not None: + compiler.style = style + if include_paths is not None: + compiler.include_paths = include_paths # type: ignore[assignment] + if library_paths is not None: + compiler.library_paths = library_paths # type: ignore[assignment] + if translation_domain is not None: + compiler.set_translation_domain(translation_domain) + + result = compiler.build_from_path(Path(path)) + + diagnostics = result.diagnostics + if diagnostics: + if not quiet: + for diag in diagnostics: + if diag.level == DiagnosticLevel.Warning: + logging.warning(diag) + + errors = [diag for diag in diagnostics if diag.level == DiagnosticLevel.Error] + if errors: + raise CompileError(f"Could not compile {path}", diagnostics) + + module = types.SimpleNamespace() + for comp_name in result.component_names: + comp = result.component(comp_name) + + if comp is None: + continue + + wrapper_class = _build_class(comp) + + setattr(module, comp_name, wrapper_class) + + structs, enums = result.structs_and_enums + + for name, struct_prototype in structs.items(): + name = _normalize_prop(name) + struct_wrapper = _build_struct(name, struct_prototype) + setattr(module, name, struct_wrapper) + + for name, enum_class in enums.items(): + name = _normalize_prop(name) + setattr(module, name, enum_class) + + for orig_name, new_name in result.named_exports: + orig_name = _normalize_prop(orig_name) + new_name = _normalize_prop(new_name) + setattr(module, new_name, getattr(module, orig_name)) + + return module + + +class SlintAutoLoader: + def __init__(self, base_dir: Path | None = None): + self.local_dirs: typing.List[Path] | None = None + if base_dir: + self.local_dirs = [base_dir] + + def __getattr__(self, name: str) -> Any: + for path in self.local_dirs or sys.path: + dir_candidate = Path(path) / name + if os.path.isdir(dir_candidate): + loader = SlintAutoLoader(dir_candidate) + setattr(self, name, loader) + return loader + + file_candidate = dir_candidate.with_suffix(".slint") + if os.path.isfile(file_candidate): + type_namespace = load_file(file_candidate) + setattr(self, name, type_namespace) + return type_namespace + + dir_candidate = Path(path) / name.replace("_", "-") + file_candidate = dir_candidate.with_suffix(".slint") + if os.path.isfile(file_candidate): + type_namespace = load_file(file_candidate) + setattr(self, name, type_namespace) + return type_namespace + + return None + + +loader = SlintAutoLoader() +"""Use the global `loader` object to load Slint files from the file system. It exposes two stages of attributes: +1. Any lookup of an attribute in the loader tries to match a file in `sys.path` with the `.slint` extension. For example + `loader.my_component` looks for a file `my_component.slint` in the directories in `sys.path`. +2. Any lookup in the object returned by the first stage tries to match an exported component in the loaded file, or a + struct, or enum. For example `loader.my_component.MyComponent` looks for an *exported* component named `MyComponent` + in the file `my_component.slint`. + +**Note:** The first entry in the module search path `sys.path` is the directory that contains the input script. + +Example: +```python +import slint +# Look for a file `main.slint` in the current directory, +# #load & compile it, and instantiate the exported `MainWindow` component +main_window = slint.loader.main_window.MainWindow() +main_window.show() +... +``` +""" + + +def _callback_decorator( + callable: typing.Callable[..., Any], info: typing.Dict[str, Any] +) -> typing.Callable[..., Any]: + if "name" not in info: + info["name"] = callable.__name__ + setattr(callable, "slint.callback", info) + + try: + import inspect + + if inspect.iscoroutinefunction(callable): + + def run_as_task(*args, **kwargs) -> None: # type: ignore + loop = asyncio.get_event_loop() + loop.create_task(callable(*args, **kwargs)) + + setattr(run_as_task, "slint.callback", info) + setattr(run_as_task, "slint.async", True) + return run_as_task + except ImportError: + pass + + return callable + + +_T_Callback = TypeVar("_T_Callback", bound=Callable[..., Any]) + + +@overload +def callback(__func: _T_Callback, /) -> _T_Callback: ... + + +@overload +def callback( + *, global_name: str | None = ..., name: str | None = ... +) -> Callable[[_T_Callback], _T_Callback]: ... + + +@overload +def callback( + __func: _T_Callback, /, *, global_name: str | None = ..., name: str | None = ... +) -> _T_Callback: ... + + +def callback( + __func: _T_Callback | None = None, + /, + *, + global_name: str | None = None, + name: str | None = None, +) -> typing.Union[_T_Callback, typing.Callable[[_T_Callback], _T_Callback]]: + """Use the callback decorator to mark a method as a callback that can be invoked from the Slint component. + + For the decorator to work, the method must be a member of a class that is Slint component. + + Example: + ```python + import slint + + class AppMainWindow(slint.loader.main_window.MainWindow): + + # Automatically connected to a callback button_clicked() + # in main_window.slint's MainWindow. + @slint.callback() + def button_clicked(self): + print("Button clicked") + + ... + ``` + + If your Python method has a different name from the Slint component's callback, use the `name` parameter to specify + the correct name. Similarly, use the `global_name` parameter to specify the name of the correct global singleton in + the Slint component. + + **Note:** The callback decorator can also be used with async functions. They will be run as task in the asyncio event loop. + This is only supported for callbacks that don't return any value, and requires Python >= 3.13. + """ + + # If used as @callback without args: __func is the callable + if __func is not None and callable(__func): + return _callback_decorator(__func, {}) + + info: dict[str, str] = {} + if name: + info["name"] = name + if global_name: + info["global_name"] = global_name + + def _wrapper(fn: _T_Callback) -> _T_Callback: + return typing.cast(_T_Callback, _callback_decorator(fn, info)) + + return _wrapper + + +def set_xdg_app_id(app_id: str) -> None: + """Sets the application id for use on Wayland or X11 with [xdg](https://specifications.freedesktop.org/desktop-entry-spec/latest/) + compliant window managers. This id must be set before the window is shown; it only applies to Wayland or X11.""" + + set_xdg_app_id(app_id) + + +quit_event = asyncio.Event() + + +def run_event_loop( + main_coro: typing.Optional[Coroutine[None, None, None]] = None, +) -> None: + """Runs the main Slint event loop. If specified, the coroutine `main_coro` is run in parallel. The event loop doesn't + terminate when the coroutine finishes, it terminates when calling `quit_event_loop()`. + + Example: + ```python + import slint + + ... + image_model: slint.ListModel[slint.Image] = slint.ListModel() + ... + + async def main_receiver(image_model: slint.ListModel) -> None: + async with aiohttp.ClientSession() as session: + async with session.get("http://some.server/svg-image") as response: + svg = await response.read() + image = slint.Image.from_svg_data(svg) + image_model.append(image) + + ... + slint.run_event_loop(main_receiver(image_model)) + ``` + + """ + + async def run_inner() -> None: + global quit_event + loop = typing.cast(SlintEventLoop, asyncio.get_event_loop()) + + quit_task = asyncio.ensure_future(quit_event.wait(), loop=loop) + + tasks: typing.List[asyncio.Task[typing.Any]] = [quit_task] + + main_task = None + if main_coro: + main_task = loop.create_task(main_coro) + tasks.append(main_task) + + done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + + if main_task is not None and main_task in done: + main_task.result() # propagate exception if thrown + if quit_task in pending: + await quit_event.wait() + + global quit_event + quit_event = asyncio.Event() + + with asyncio.Runner(loop_factory=SlintEventLoop) as runner: + runner.run(run_inner()) + + +def quit_event_loop() -> None: + """Quits the running event loop in the next event processing cycle. This will make an earlier call to `run_event_loop()` + return.""" + global quit_event + quit_event.set() + + +def init_translations(translations: typing.Optional[gettext.GNUTranslations]) -> None: + """Installs the specified translations object to handle translations originating from the Slint code. + + Example: + ```python + import gettext + import slint + + translations_dir = os.path.join(os.path.dirname(__file__), "lang") + try: + translations = gettext.translation("my_app", translations_dir, ["de"]) + slint.install_translations(translations) + except OSError: + pass + ``` + """ + _slint_init_translations(translations) + + +__all__ = [ + "CompileError", + "Component", + "load_file", + "loader", + "Image", + "Color", + "Brush", + "Model", + "ListModel", + "Timer", + "TimerMode", + "set_xdg_app_id", + "callback", + "run_event_loop", + "quit_event_loop", + "init_translations", +] diff --git a/api/python/slint/slint/codegen/__init__.py b/api/python/slint/slint/codegen/__init__.py new file mode 100644 index 00000000000..55c735ca1ff --- /dev/null +++ b/api/python/slint/slint/codegen/__init__.py @@ -0,0 +1,2 @@ +# Copyright © SixtyFPS GmbH +# SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 diff --git a/api/python/slint/slint/codegen/__main__.py b/api/python/slint/slint/codegen/__main__.py new file mode 100644 index 00000000000..28b0ab52cdc --- /dev/null +++ b/api/python/slint/slint/codegen/__main__.py @@ -0,0 +1,11 @@ +# Copyright © SixtyFPS GmbH +# SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +from __future__ import annotations + +import sys + +from .cli import main + +if __name__ == "__main__": # pragma: no cover - CLI entry point + sys.exit(main()) diff --git a/api/python/slint/slint/codegen/cli.py b/api/python/slint/slint/codegen/cli.py new file mode 100644 index 00000000000..a3d33cd69c8 --- /dev/null +++ b/api/python/slint/slint/codegen/cli.py @@ -0,0 +1,146 @@ +# Copyright © SixtyFPS GmbH +# SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +from __future__ import annotations + +import argparse +from pathlib import Path + +from .generator import generate_project +from .models import GenerationConfig + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="slint.codegen", + description=("Generate Python source and stub files from Slint .slint inputs."), + ) + + subparsers = parser.add_subparsers(dest="command", required=False) + + gen_parser = subparsers.add_parser( + "generate", + help="Generate Python modules for the provided .slint inputs.", + ) + _add_generate_arguments(gen_parser) + + # Allow invoking the root command without specifying the subcommand by + # mirroring the generate options onto the root parser. This keeps the CLI + # ergonomic (`python -m slint.codegen --input ...`). + _add_generate_arguments(parser) + + return parser + + +def _add_generate_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--input", + "-i", + dest="inputs", + action="append", + type=Path, + required=False, + help=( + "Path to a .slint file or directory containing .slint files. " + "May be supplied multiple times. Defaults to the current working " + "directory if omitted." + ), + ) + parser.add_argument( + "--output", + "-o", + dest="output", + type=Path, + default=None, + help=( + "Directory that will receive the generated Python sources. " + "When omitted, files are generated next to each input .slint file." + ), + ) + parser.add_argument( + "--include", + dest="include_paths", + action="append", + type=Path, + default=None, + help=( + "Additional include paths to pass to the Slint compiler. " + "May be provided multiple times." + ), + ) + parser.add_argument( + "--library", + dest="library_paths", + action="append", + default=None, + metavar="NAME=PATH", + help=( + "Library import mapping passed to the Slint compiler in the form " + "@mylib=path/to/lib." + ), + ) + parser.add_argument( + "--style", + dest="style", + default=None, + help="Widget style to apply when compiling (for example, 'material').", + ) + parser.add_argument( + "--translation-domain", + dest="translation_domain", + default=None, + help="Translation domain to embed into generated modules.", + ) + parser.add_argument( + "--quiet", + dest="quiet", + action="store_true", + help="Suppress compiler warnings during generation.", + ) + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + # The CLI accepts either the explicit "generate" subcommand or the root + # invocation. Determine which parser branch was taken. + command = getattr(args, "command", None) + if command not in (None, "generate"): + parser.error(f"Unknown command: {command}") + return 1 + + if not args.inputs: + print("error: At least one --input must be provided.") + return 1 + + inputs: list[Path] = args.inputs + config = GenerationConfig( + include_paths=args.include_paths or [], + library_paths=_parse_library_paths(args.library_paths or []), + style=args.style, + translation_domain=args.translation_domain, + quiet=bool(args.quiet), + ) + + generate_project(inputs=inputs, output_dir=args.output, config=config) + return 0 + + +def _parse_library_paths(values: list[str]) -> dict[str, Path]: + mapping: dict[str, Path] = {} + for raw in values: + if "=" not in raw: + raise SystemExit( + f"Library mapping '{raw}' must be provided in the form NAME=PATH" + ) + name, path_str = raw.split("=", maxsplit=1) + name = name.strip() + if not name: + raise SystemExit("Library mapping requires a non-empty name before '='") + path = Path(path_str.strip()) + mapping[name] = path + return mapping + + +__all__ = ["main", "build_parser"] diff --git a/api/python/slint/slint/codegen/emitters.py b/api/python/slint/slint/codegen/emitters.py new file mode 100644 index 00000000000..c7243d27f5b --- /dev/null +++ b/api/python/slint/slint/codegen/emitters.py @@ -0,0 +1,1003 @@ +# Copyright © SixtyFPS GmbH +# SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +from __future__ import annotations + +import inspect +import os +import re +from pathlib import Path + +import libcst as cst + +from ..api import _normalize_prop +from .models import CallbackMeta, GenerationConfig, ModuleArtifacts, StructMeta + + +def module_relative_path_expr(module_dir: Path, target: Path) -> str: + try: + rel = os.path.relpath(target, module_dir) + except ValueError: + return f"Path({repr(str(target))})" + + if rel in (".", ""): + return "_MODULE_DIR" + + parts = Path(rel).parts + expr = "_MODULE_DIR" + for part in parts: + if part == ".": + continue + expr += f" / {repr(part)}" + return expr + + +def _collect_export_bindings( + artifacts: ModuleArtifacts, *, include_builtin_enums: bool +) -> dict[str, str]: + bindings: dict[str, str] = {} + + for component in artifacts.components: + bindings[component.name] = component.py_name + + for struct in artifacts.structs: + bindings[struct.name] = struct.py_name + + for enum in artifacts.enums: + if enum.is_builtin and not include_builtin_enums: + continue + if not enum.is_builtin: + bindings[enum.name] = enum.py_name + elif include_builtin_enums: + bindings[enum.name] = enum.py_name + + return bindings + + +def _format_import(module: str, names: list[str]) -> str: + if not names: + raise ValueError("Expected at least one name to import") + + if len(names) == 1: + return f"from {module} import {names[0]}" + + joined = ",\n ".join(names) + return f"from {module} import (\n {joined},\n)" + + +def _unique_preserve_order(values: list[str]) -> list[str]: + seen: set[str] = set() + result: list[str] = [] + for value in values: + if value in seen: + continue + seen.add(value) + result.append(value) + return result + + +def _collect_named_aliases( + artifacts: ModuleArtifacts, + export_bindings: dict[str, str], + *, + allowed_originals: set[str] | None = None, +) -> tuple[list[str], list[str]]: + alias_names: list[str] = [] + alias_statements: list[str] = [] + + for original, alias in artifacts.named_exports: + if allowed_originals is not None and original not in allowed_originals: + continue + alias_name = _normalize_prop(alias) + target = export_bindings.get(original, _normalize_prop(original)) + alias_names.append(alias_name) + alias_statements.append(f"{alias_name} = {target}") + + return alias_names, alias_statements + + +def _write_struct_python_module( + path: Path, + *, + source_relative: str, + resource_name: str, + export_items: list[str], + include_expr_code: str, + library_expr_code: str, + style_expr: str, + domain_expr: str, + artifacts: ModuleArtifacts, + export_bindings: dict[str, str], +) -> None: + def _stmt(code: str) -> cst.BaseStatement: + return cst.parse_statement(code) + + header: list[cst.CSTNode] = [ + cst.EmptyLine( + comment=cst.Comment(f"# Generated by slint.codegen from {source_relative}") + ) + ] + + body: list[cst.CSTNode] = [ + _stmt("from __future__ import annotations"), + cst.EmptyLine(), + _stmt("import importlib.resources as _resources"), + _stmt("import os"), + _stmt("import types"), + _stmt("from contextlib import nullcontext as _nullcontext"), + _stmt("from pathlib import Path"), + _stmt("from typing import Any"), + cst.EmptyLine(), + _stmt("import slint"), + _stmt("from slint import core as _core"), + _stmt("from slint.api import _build_struct"), + cst.EmptyLine(), + ] + + all_list = cst.List( + elements=[cst.Element(cst.SimpleString(repr(item))) for item in export_items] + ) + body.append( + cst.SimpleStatementLine( + [ + cst.Assign( + targets=[cst.AssignTarget(cst.Name("__all__"))], value=all_list + ) + ] + ) + ) + body.append(cst.EmptyLine()) + + body.append(_stmt("_MODULE_DIR = Path(__file__).parent")) + body.append(cst.EmptyLine()) + + body.append( + cst.SimpleStatementLine( + [ + cst.Assign( + targets=[cst.AssignTarget(cst.Name("_SLINT_RESOURCE"))], + value=cst.SimpleString(repr(resource_name)), + ) + ] + ) + ) + body.append(cst.EmptyLine()) + + struct_lines: list[str] = [] + for struct in artifacts.structs: + struct_lines.append( + f" if {struct.name!r} in structs:\n" + f" module.{struct.py_name} = _build_struct({struct.py_name!r}, structs[{struct.name!r}])" + ) + + enum_lines: list[str] = [] + for enum in artifacts.enums: + if enum.is_builtin: + continue + enum_lines.append( + f" if {enum.name!r} in enums:\n" + f" module.{enum.py_name} = enums[{enum.name!r}]" + ) + + alias_lines: list[str] = [] + for orig, alias in artifacts.named_exports: + alias_name = _normalize_prop(alias) + target_name = export_bindings.get(orig, _normalize_prop(orig)) + alias_lines.append( + f" if hasattr(module, {target_name!r}):\n" + f" module.{alias_name} = getattr(module, {target_name!r})" + ) + + with_lines = [ + "with ctx as slint_path:", + f" include_paths: list[os.PathLike[Any] | Path] | None = {include_expr_code}", + f" library_paths: dict[str, os.PathLike[Any] | Path] | None = {library_expr_code}", + f" style = {style_expr}", + f" translation_domain = {domain_expr}", + " compiler = _core.Compiler()", + " if include_paths is not None:", + " compiler.include_paths = include_paths # type: ignore[assignment]", + " if library_paths is not None:", + " compiler.library_paths = library_paths # type: ignore[assignment]", + " if style is not None:", + " compiler.style = style", + " if translation_domain is not None:", + " compiler.set_translation_domain(translation_domain)", + " result = compiler.build_from_path(slint_path)", + " diagnostics = result.diagnostics", + " errors = [", + " diag", + " for diag in diagnostics", + " if diag.level == _core.DiagnosticLevel.Error", + " and diag.message != 'No component found'", + " ]", + " if errors:", + f" raise slint.CompileError({source_relative!r}, diagnostics)", + " module = types.SimpleNamespace()", + " structs, enums = result.structs_and_enums", + *struct_lines, + *enum_lines, + *alias_lines, + " return module", + ] + + load_statements: list[cst.BaseStatement] = [ + _stmt('"""Load struct and enum definitions for this package."""'), + _stmt("package = __package__ or (__spec__.parent if __spec__ else None)"), + _stmt( + "if package:\n" + " ctx = _resources.as_file(_resources.files(package).joinpath(_SLINT_RESOURCE))\n" + "else:\n" + " ctx = _nullcontext(Path(__file__).with_name(_SLINT_RESOURCE))" + ), + _stmt("\n".join(with_lines)), + ] + + load_func = cst.FunctionDef( + name=cst.Name("_load"), + params=cst.Parameters(), + returns=cst.Annotation( + annotation=cst.Attribute( + value=cst.Name("types"), + attr=cst.Name("SimpleNamespace"), + ) + ), + body=cst.IndentedBlock(body=load_statements), + ) + + body.append(load_func) + body.append(cst.EmptyLine()) + body.append(_stmt("_module = _load()")) + body.append(cst.EmptyLine()) + + for original, binding in export_bindings.items(): + module_attr = _normalize_prop(original) + body.append(_stmt(f"{binding} = _module.{module_attr}")) + + for orig, alias in artifacts.named_exports: + alias_name = _normalize_prop(alias) + target = export_bindings.get(orig, _normalize_prop(orig)) + body.append(_stmt(f"{alias_name} = {target}")) + + module = cst.Module(body=header + body) # type: ignore[arg-type] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(module.code, encoding="utf-8") + + +def write_python_module( + path: Path, + *, + source_relative: str, + resource_name: str, + config: GenerationConfig, + artifacts: ModuleArtifacts, +) -> None: + def _stmt(code: str) -> cst.BaseStatement: + return cst.parse_statement(code) + + module_dir = path.parent + + include_exprs = [ + module_relative_path_expr(module_dir, include_path) + for include_path in config.include_paths + ] + include_expr_code = f"[{', '.join(include_exprs)}]" if include_exprs else "None" + + library_items = [ + f"{repr(name)}: {module_relative_path_expr(module_dir, lib_path)}" + for name, lib_path in config.library_paths.items() + ] + library_expr_code = f"{{{', '.join(library_items)}}}" if library_items else "None" + + style_expr = repr(config.style) if config.style is not None else "None" + domain_expr = ( + repr(config.translation_domain) + if config.translation_domain is not None + else "None" + ) + + export_bindings = _collect_export_bindings(artifacts, include_builtin_enums=False) + + export_items = list(export_bindings.values()) + [ + _normalize_prop(alias) for _, alias in artifacts.named_exports + ] + + if not artifacts.components: + _write_struct_python_module( + path, + source_relative=source_relative, + resource_name=resource_name, + export_items=export_items, + include_expr_code=include_expr_code, + library_expr_code=library_expr_code, + style_expr=style_expr, + domain_expr=domain_expr, + artifacts=artifacts, + export_bindings=export_bindings, + ) + return + + header: list[cst.CSTNode] = [ + cst.EmptyLine( + comment=cst.Comment(f"# Generated by slint.codegen from {source_relative}") + ) + ] + + body: list[cst.CSTNode] = [ + _stmt("from __future__ import annotations"), + cst.EmptyLine(), + _stmt("import importlib.resources as _resources"), + _stmt("import os"), + _stmt("import types"), + _stmt("from contextlib import nullcontext as _nullcontext"), + _stmt("from pathlib import Path"), + _stmt("from typing import Any"), + cst.EmptyLine(), + _stmt("import slint"), + cst.EmptyLine(), + ] + + all_list = cst.List( + elements=[cst.Element(cst.SimpleString(repr(item))) for item in export_items] + ) + body.append( + cst.SimpleStatementLine( + [ + cst.Assign( + targets=[cst.AssignTarget(cst.Name("__all__"))], value=all_list + ) + ] + ) + ) + body.append(cst.EmptyLine()) + + body.append(_stmt("_MODULE_DIR = Path(__file__).parent")) + body.append(cst.EmptyLine()) + + body.append( + cst.SimpleStatementLine( + [ + cst.Assign( + targets=[cst.AssignTarget(cst.Name("_SLINT_RESOURCE"))], + value=cst.SimpleString(repr(resource_name)), + ) + ] + ) + ) + body.append(cst.EmptyLine()) + + load_source = inspect.cleandoc( + f''' + def _load() -> types.SimpleNamespace: + """Load the compiled Slint module for this package.""" + package = __package__ or (__spec__.parent if __spec__ else None) + if package: + ctx = _resources.as_file(_resources.files(package).joinpath(_SLINT_RESOURCE)) + else: + ctx = _nullcontext(Path(__file__).with_name(_SLINT_RESOURCE)) + with ctx as slint_path: + include_paths: list[os.PathLike[Any] | Path] | None = {include_expr_code} + library_paths: dict[str, os.PathLike[Any] | Path] | None = {library_expr_code} + return slint.load_file( + path=slint_path, + quiet=True, + style={style_expr}, + include_paths=include_paths, + library_paths=library_paths, + translation_domain={domain_expr}, + ) + ''' + ) + + load_func = cst.parse_module(load_source).body[0] + + body.append(load_func) + body.append(cst.EmptyLine()) + body.append(_stmt("_module = _load()")) + body.append(cst.EmptyLine()) + + for original, binding in export_bindings.items(): + module_attr = _normalize_prop(original) + body.append(_stmt(f"{binding} = _module.{module_attr}")) + for orig, alias in artifacts.named_exports: + alias_name = _normalize_prop(alias) + target = export_bindings.get(orig, _normalize_prop(orig)) + body.append(_stmt(f"{alias_name} = {target}")) + + module = cst.Module(body=header + body) # type: ignore[arg-type] + path.write_text(module.code, encoding="utf-8") + + +def write_stub_module(path: Path, *, artifacts: ModuleArtifacts) -> None: + def _stmt(code: str) -> cst.BaseStatement: + return cst.parse_statement(code) + + typing_imports: set[str] = set() + + def register_type(type_str: str) -> None: + if not type_str: + return + normalized = type_str.replace("typing.", "") + tokens = set(token for token in re.split(r"[^A-Za-z_]+", normalized) if token) + for token in ("Any", "Callable", "Literal", "Optional", "Union"): + if token in tokens: + typing_imports.add(token) + + post_body: list[cst.CSTNode] = [] + + needs_enum_import = any(not enum.is_builtin for enum in artifacts.enums) + + export_names = [component.py_name for component in artifacts.components] + export_names += [struct.py_name for struct in artifacts.structs] + export_names += [enum.py_name for enum in artifacts.enums if not enum.is_builtin] + export_names += [_normalize_prop(alias) for _, alias in artifacts.named_exports] + if export_names: + all_list = cst.List( + elements=[ + cst.Element(cst.SimpleString(repr(name))) for name in export_names + ] + ) + post_body.append( + cst.SimpleStatementLine( + [ + cst.Assign( + targets=[cst.AssignTarget(cst.Name("__all__"))], value=all_list + ) + ] + ) + ) + post_body.append(cst.EmptyLine()) + + def ann_assign(name: str, type_expr: str) -> cst.BaseStatement: + return cst.SimpleStatementLine( + [ + cst.AnnAssign( + target=cst.Name(name), + annotation=cst.Annotation( + annotation=cst.parse_expression(type_expr) + ), + value=None, + ) + ] + ) + + def ellipsis_line() -> cst.BaseStatement: + return cst.SimpleStatementLine([cst.Expr(value=cst.Ellipsis())]) + + def ellipsis_suite() -> cst.BaseSuite: + return cst.SimpleStatementSuite([cst.Expr(value=cst.Ellipsis())]) + + def struct_init_stub(struct: StructMeta) -> cst.FunctionDef: + params = [cst.Param(name=cst.Name("self"))] + kwonly_params: list[cst.Param] = [] + + for field in struct.fields: + register_type(field.type_hint) + kwonly_params.append( + cst.Param( + name=cst.Name(field.py_name), + annotation=cst.Annotation( + annotation=cst.parse_expression(field.type_hint) + ), + default=cst.Ellipsis(), + ) + ) + + parameters = cst.Parameters(params=params, kwonly_params=kwonly_params) + + if not struct.fields: + parameters = cst.Parameters(params=params) + + return cst.FunctionDef( + name=cst.Name("__init__"), + params=parameters, + returns=cst.Annotation(annotation=cst.Name("None")), + body=ellipsis_suite(), + ) + + def component_init_stub() -> cst.FunctionDef: + typing_imports.add("Any") + return cst.FunctionDef( + name=cst.Name("__init__"), + params=cst.Parameters( + params=[cst.Param(name=cst.Name("self"))], + star_kwarg=cst.Param( + name=cst.Name("kwargs"), + annotation=cst.Annotation(annotation=cst.Name("Any")), + ), + ), + returns=cst.Annotation(annotation=cst.Name("None")), + body=ellipsis_suite(), + ) + + for struct in artifacts.structs: + struct_body: list[cst.BaseStatement] = [struct_init_stub(struct)] + if struct.fields: + for field in struct.fields: + register_type(field.type_hint) + struct_body.append(ann_assign(field.py_name, field.type_hint)) + else: + struct_body.append(ellipsis_line()) + post_body.append( + cst.ClassDef( + name=cst.Name(struct.py_name), + bases=[], + body=cst.IndentedBlock(body=struct_body), + ) + ) + post_body.append(cst.EmptyLine()) + + for enum_meta in artifacts.enums: + if enum_meta.is_builtin: + continue + enum_body: list[cst.BaseStatement] = [] + if enum_meta.values: + for value in enum_meta.values: + enum_body.append( + cst.SimpleStatementLine( + [ + cst.Assign( + targets=[cst.AssignTarget(cst.Name(value.py_name))], + value=cst.SimpleString(repr(value.value)), + ) + ] + ) + ) + else: + enum_body.append(ellipsis_line()) + post_body.append( + cst.ClassDef( + name=cst.Name(enum_meta.py_name), + bases=[ + cst.Arg( + value=cst.Attribute( + value=cst.Name("enum"), attr=cst.Name("Enum") + ) + ) + ], + body=cst.IndentedBlock(body=enum_body), + ) + ) + post_body.append(cst.EmptyLine()) + + for component in artifacts.components: + component_body: list[cst.BaseStatement] = [component_init_stub()] + for prop in component.properties: + register_type(prop.type_hint) + component_body.append(ann_assign(prop.py_name, prop.type_hint)) + for callback in component.callbacks: + annotation = format_callable_annotation(callback) + register_type(annotation) + component_body.append(ann_assign(callback.py_name, annotation)) + for fn in component.functions: + annotation = format_callable_annotation(fn) + register_type(annotation) + component_body.append(ann_assign(fn.py_name, annotation)) + for global_meta in component.globals: + inner_body: list[cst.BaseStatement] = [] + if not ( + global_meta.properties or global_meta.callbacks or global_meta.functions + ): + inner_body.append(ellipsis_line()) + else: + for prop in global_meta.properties: + register_type(prop.type_hint) + inner_body.append(ann_assign(prop.py_name, prop.type_hint)) + for callback in global_meta.callbacks: + annotation = format_callable_annotation(callback) + register_type(annotation) + inner_body.append(ann_assign(callback.py_name, annotation)) + for fn in global_meta.functions: + annotation = format_callable_annotation(fn) + register_type(annotation) + inner_body.append(ann_assign(fn.py_name, annotation)) + component_body.append( + cst.ClassDef( + name=cst.Name(global_meta.py_name), + bases=[], + body=cst.IndentedBlock(body=inner_body), + ) + ) + + post_body.append( + cst.ClassDef( + name=cst.Name(component.py_name), + bases=[ + cst.Arg( + value=cst.Attribute( + value=cst.Name("slint"), attr=cst.Name("Component") + ) + ) + ], + body=cst.IndentedBlock(body=component_body), + ) + ) + post_body.append(cst.EmptyLine()) + + bindings: dict[str, str] = {} + for component in artifacts.components: + bindings[component.name] = component.py_name + for struct in artifacts.structs: + bindings[struct.name] = struct.py_name + for enum_meta in artifacts.enums: + if not enum_meta.is_builtin: + bindings[enum_meta.name] = enum_meta.py_name + + for orig, alias in artifacts.named_exports: + alias_name = _normalize_prop(alias) + target = bindings.get(orig, _normalize_prop(orig)) + post_body.append(_stmt(f"{alias_name} = {target}")) + post_body.append(cst.EmptyLine()) + + module_body: list[cst.CSTNode] = [ + _stmt("from __future__ import annotations"), + cst.EmptyLine(), + ] + + import_statements: list[cst.CSTNode] = [] + + if needs_enum_import: + import_statements.append(_stmt("import enum")) + + typing_names = sorted(typing_imports) + if typing_names: + import_statements.append(_stmt(_format_import("typing", typing_names))) + + if artifacts.components: + import_statements.append(_stmt("import slint")) + + if import_statements: + module_body.extend(import_statements) + module_body.append(cst.EmptyLine()) + + module_body.extend(post_body) + + module = cst.Module(body=module_body) # type: ignore[arg-type] + path.write_text(module.code, encoding="utf-8") + + +def write_package_init( + path: Path, + *, + source_relative: str, + artifacts: ModuleArtifacts, +) -> None: + def _stmt(code: str) -> cst.BaseStatement: + return cst.parse_statement(code) + + header: list[cst.CSTNode] = [ + cst.EmptyLine( + comment=cst.Comment(f"# Generated by slint.codegen from {source_relative}") + ) + ] + + body: list[cst.CSTNode] = [ + _stmt("from __future__ import annotations"), + cst.EmptyLine(), + _stmt("from . import enums, structs"), + ] + + export_bindings = _collect_export_bindings(artifacts, include_builtin_enums=False) + symbol_names = list(export_bindings.values()) + + if symbol_names: + body.append(cst.EmptyLine()) + body.append(_stmt(_format_import("._generated", symbol_names))) + + enum_names = {enum.name for enum in artifacts.enums if not enum.is_builtin} + struct_names = {struct.name for struct in artifacts.structs} + component_names = {component.name for component in artifacts.components} + allowed_for_init = enum_names | struct_names | component_names + + alias_names, alias_statements = _collect_named_aliases( + artifacts, + export_bindings, + allowed_originals=allowed_for_init, + ) + + if alias_statements: + body.append(cst.EmptyLine()) + for stmt in alias_statements: + body.append(_stmt(stmt)) + + export_names = _unique_preserve_order( + symbol_names + alias_names + ["enums", "structs"] + ) + body.append(cst.EmptyLine()) + all_list = cst.List( + elements=[cst.Element(cst.SimpleString(repr(name))) for name in export_names] + ) + body.append( + cst.SimpleStatementLine( + [ + cst.Assign( + targets=[cst.AssignTarget(cst.Name("__all__"))], + value=all_list, + ) + ] + ) + ) + + module = cst.Module(body=header + body) # type: ignore[arg-type] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(module.code, encoding="utf-8") + + +def write_package_init_stub(path: Path, *, artifacts: ModuleArtifacts) -> None: + def _stmt(code: str) -> cst.BaseStatement: + return cst.parse_statement(code) + + body: list[cst.CSTNode] = [ + _stmt("from __future__ import annotations"), + cst.EmptyLine(), + _stmt("from . import enums, structs"), + ] + + export_bindings = _collect_export_bindings(artifacts, include_builtin_enums=False) + symbol_names = list(export_bindings.values()) + + if symbol_names: + body.append(cst.EmptyLine()) + body.append(_stmt(_format_import("._generated", symbol_names))) + + enum_original = {enum.name for enum in artifacts.enums if not enum.is_builtin} + struct_original = {struct.name for struct in artifacts.structs} + component_original = {component.name for component in artifacts.components} + allowed_for_init = enum_original | struct_original | component_original + alias_names, alias_statements = _collect_named_aliases( + artifacts, + export_bindings, + allowed_originals=set(allowed_for_init), + ) + + if alias_statements: + body.append(cst.EmptyLine()) + for stmt in alias_statements: + body.append(_stmt(stmt)) + + export_names = _unique_preserve_order( + symbol_names + alias_names + ["enums", "structs"] + ) + body.append(cst.EmptyLine()) + all_list = cst.List( + elements=[cst.Element(cst.SimpleString(repr(name))) for name in export_names] + ) + body.append( + cst.SimpleStatementLine( + [ + cst.Assign( + targets=[cst.AssignTarget(cst.Name("__all__"))], + value=all_list, + ) + ] + ) + ) + + module = cst.Module(body=body) # type: ignore[arg-type] + path.write_text(module.code, encoding="utf-8") + + +def write_package_enums( + path: Path, *, source_relative: str, artifacts: ModuleArtifacts +) -> None: + def _stmt(code: str) -> cst.BaseStatement: + return cst.parse_statement(code) + + header: list[cst.CSTNode] = [ + cst.EmptyLine( + comment=cst.Comment(f"# Generated by slint.codegen from {source_relative}") + ) + ] + + export_bindings = _collect_export_bindings(artifacts, include_builtin_enums=False) + user_enum_names = [enum.py_name for enum in artifacts.enums if not enum.is_builtin] + + body: list[cst.CSTNode] = [ + _stmt("from __future__ import annotations"), + ] + + if user_enum_names: + body.append(cst.EmptyLine()) + body.append(_stmt(_format_import("._generated", user_enum_names))) + + enum_originals = {enum.name for enum in artifacts.enums if not enum.is_builtin} + alias_names, alias_statements = _collect_named_aliases( + artifacts, + export_bindings, + allowed_originals=enum_originals, + ) + + if alias_statements: + body.append(cst.EmptyLine()) + for stmt in alias_statements: + body.append(_stmt(stmt)) + + export_names = _unique_preserve_order(user_enum_names + alias_names) + body.append(cst.EmptyLine()) + all_list = cst.List( + elements=[cst.Element(cst.SimpleString(repr(name))) for name in export_names] + ) + body.append( + cst.SimpleStatementLine( + [ + cst.Assign( + targets=[cst.AssignTarget(cst.Name("__all__"))], + value=all_list, + ) + ] + ) + ) + + module = cst.Module(body=header + body) # type: ignore[arg-type] + path.write_text(module.code, encoding="utf-8") + + +def write_package_enums_stub(path: Path, *, artifacts: ModuleArtifacts) -> None: + def _stmt(code: str) -> cst.BaseStatement: + return cst.parse_statement(code) + + export_bindings = _collect_export_bindings(artifacts, include_builtin_enums=False) + user_enum_names = [enum.py_name for enum in artifacts.enums if not enum.is_builtin] + + body: list[cst.CSTNode] = [ + _stmt("from __future__ import annotations"), + ] + + if user_enum_names: + body.append(cst.EmptyLine()) + body.append(_stmt(_format_import("._generated", user_enum_names))) + + enum_originals = {enum.name for enum in artifacts.enums if not enum.is_builtin} + alias_names, alias_statements = _collect_named_aliases( + artifacts, + export_bindings, + allowed_originals=enum_originals, + ) + + if alias_statements: + body.append(cst.EmptyLine()) + for stmt in alias_statements: + body.append(_stmt(stmt)) + + export_names = _unique_preserve_order(user_enum_names + alias_names) + body.append(cst.EmptyLine()) + all_list = cst.List( + elements=[cst.Element(cst.SimpleString(repr(name))) for name in export_names] + ) + body.append( + cst.SimpleStatementLine( + [ + cst.Assign( + targets=[cst.AssignTarget(cst.Name("__all__"))], + value=all_list, + ) + ] + ) + ) + + module = cst.Module(body=body) # type: ignore[arg-type] + path.write_text(module.code, encoding="utf-8") + + +def write_package_structs( + path: Path, *, source_relative: str, artifacts: ModuleArtifacts +) -> None: + def _stmt(code: str) -> cst.BaseStatement: + return cst.parse_statement(code) + + header: list[cst.CSTNode] = [ + cst.EmptyLine( + comment=cst.Comment(f"# Generated by slint.codegen from {source_relative}") + ) + ] + + export_bindings = _collect_export_bindings(artifacts, include_builtin_enums=False) + struct_names = [struct.py_name for struct in artifacts.structs] + + body: list[cst.CSTNode] = [ + _stmt("from __future__ import annotations"), + ] + + if struct_names: + body.append(cst.EmptyLine()) + body.append(_stmt(_format_import("._generated", struct_names))) + + struct_originals = {struct.name for struct in artifacts.structs} + alias_names, alias_statements = _collect_named_aliases( + artifacts, + export_bindings, + allowed_originals=struct_originals, + ) + + if alias_statements: + body.append(cst.EmptyLine()) + for stmt in alias_statements: + body.append(_stmt(stmt)) + + export_names = _unique_preserve_order(struct_names + alias_names) + body.append(cst.EmptyLine()) + all_list = cst.List( + elements=[cst.Element(cst.SimpleString(repr(name))) for name in export_names] + ) + body.append( + cst.SimpleStatementLine( + [ + cst.Assign( + targets=[cst.AssignTarget(cst.Name("__all__"))], + value=all_list, + ) + ] + ) + ) + + module = cst.Module(body=header + body) # type: ignore[arg-type] + path.write_text(module.code, encoding="utf-8") + + +def write_package_structs_stub(path: Path, *, artifacts: ModuleArtifacts) -> None: + def _stmt(code: str) -> cst.BaseStatement: + return cst.parse_statement(code) + + export_bindings = _collect_export_bindings(artifacts, include_builtin_enums=False) + struct_names = [struct.py_name for struct in artifacts.structs] + + body: list[cst.CSTNode] = [ + _stmt("from __future__ import annotations"), + ] + + if struct_names: + body.append(cst.EmptyLine()) + body.append(_stmt(_format_import("._generated", struct_names))) + + struct_originals = {struct.name for struct in artifacts.structs} + alias_names, alias_statements = _collect_named_aliases( + artifacts, + export_bindings, + allowed_originals=struct_originals, + ) + + if alias_statements: + body.append(cst.EmptyLine()) + for stmt in alias_statements: + body.append(_stmt(stmt)) + + export_names = _unique_preserve_order(struct_names + alias_names) + body.append(cst.EmptyLine()) + all_list = cst.List( + elements=[cst.Element(cst.SimpleString(repr(name))) for name in export_names] + ) + body.append( + cst.SimpleStatementLine( + [ + cst.Assign( + targets=[cst.AssignTarget(cst.Name("__all__"))], + value=all_list, + ) + ] + ) + ) + + module = cst.Module(body=body) # type: ignore[arg-type] + path.write_text(module.code, encoding="utf-8") + + +def format_callable_annotation(callback: "CallbackMeta") -> str: + args = callback.arg_types + return_type = callback.return_type + + args_literal = ", ".join(args) + arg_repr = f"[{args_literal}]" if args_literal else "[]" + return f"Callable[{arg_repr}, {return_type}]" + + +__all__ = [ + "module_relative_path_expr", + "write_python_module", + "write_stub_module", + "write_package_init", + "write_package_init_stub", + "write_package_enums", + "write_package_enums_stub", + "write_package_structs", + "write_package_structs_stub", +] diff --git a/api/python/slint/slint/codegen/generator.py b/api/python/slint/slint/codegen/generator.py new file mode 100644 index 00000000000..45f87acf0fb --- /dev/null +++ b/api/python/slint/slint/codegen/generator.py @@ -0,0 +1,492 @@ +# Copyright © SixtyFPS GmbH +# SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +from __future__ import annotations + +import enum +import shutil +from collections import defaultdict +from pathlib import Path +from typing import TYPE_CHECKING, Iterable + +from ..api import _normalize_prop +from ..core import Brush, Color, CompilationResult, Compiler, DiagnosticLevel, Image +from .emitters import write_python_module, write_stub_module +from .models import ( + CallbackMeta, + ComponentMeta, + EnumMeta, + EnumValueMeta, + GenerationConfig, + GlobalMeta, + ModuleArtifacts, + PropertyMeta, + StructFieldMeta, + StructMeta, +) + +if TYPE_CHECKING: + from slint.core import CallbackInfo, FunctionInfo, PyDiagnostic + + +def generate_project( + *, + inputs: Iterable[Path], + output_dir: Path | None, + config: GenerationConfig, +) -> None: + source_roots = [Path(p).resolve() for p in inputs] + files = list(_discover_slint_files(source_roots)) + if not files: + raise SystemExit("No .slint files found in the supplied inputs") + + copied_slint: set[Path] = set() + generated_modules = 0 + struct_only_modules = 0 + failed_files: list[Path] = [] + + def copy_slint_file(source: Path, destination: Path) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + resolved = destination.resolve() + if resolved in copied_slint: + return + shutil.copy2(source, destination) + copied_slint.add(resolved) + + if output_dir is not None: + output_dir.mkdir(parents=True, exist_ok=True) + + compiler = Compiler() + + if config.style: + compiler.style = config.style + if config.include_paths: + compiler.include_paths = config.include_paths.copy() + if config.library_paths: + compiler.library_paths = config.library_paths.copy() + if config.translation_domain: + compiler.set_translation_domain(config.translation_domain) + + for source_path, root in files: + source_resolved = source_path.resolve() + relative = source_path.relative_to(root) + compilation = _compile_slint(compiler, root, source_path, config) + if compilation is None: + failed_files.append(relative) + continue + + source_descriptor = str(relative) + artifacts = _collect_metadata(compilation, source_descriptor) + + sanitized_stem = _normalize_prop(source_path.stem) + + if output_dir is None: + base_dir = source_path.parent + slint_destination = source_path + resource_name = source_path.name + source_descriptor = source_path.name + copy_slint = False + else: + base_dir = output_dir / relative.parent + base_dir.mkdir(parents=True, exist_ok=True) + _ensure_package_marker(base_dir) + slint_destination = base_dir / relative.name + resource_name = relative.name + source_descriptor = str(relative) + copy_slint = True + + target_stem = base_dir / sanitized_stem + write_python_module( + target_stem.with_suffix(".py"), + source_relative=source_descriptor, + resource_name=resource_name, + config=config, + artifacts=artifacts, + ) + write_stub_module(target_stem.with_suffix(".pyi"), artifacts=artifacts) + + if copy_slint and slint_destination != source_path: + copy_slint_file(source_path, slint_destination) + + if output_dir is not None: + for dependency in artifacts.resource_paths: + dep_path = Path(dependency) + if not dep_path.exists(): + continue + dep_resolved = dep_path.resolve() + if dep_resolved == source_resolved: + continue + relative_dep: Path | None = None + for source_root in source_roots: + try: + relative_dep = dep_resolved.relative_to(source_root) + break + except ValueError: + continue + if relative_dep is None: + continue + destination = output_dir / relative_dep + copy_slint_file(dep_resolved, destination) + + generated_modules += 1 + if not artifacts.components: + struct_only_modules += 1 + + summary_lines: list[str] = [] + struct_note = f" ({struct_only_modules} struct-only)" if struct_only_modules else "" + summary_lines.append( + f"info: Generated {generated_modules} Python module(s){struct_note}" + ) + + if output_dir is not None: + summary_lines.append( + f"info: Copied {len(copied_slint)} .slint file(s) into {output_dir}" + ) + + if failed_files: + sample = ", ".join(str(path) for path in failed_files[:3]) + if len(failed_files) > 3: + sample += ", ..." + summary_lines.append( + f"info: Skipped {len(failed_files)} file(s) due to errors ({sample})" + ) + + for line in summary_lines: + print(line) + + +def _discover_slint_files(inputs: Iterable[Path]) -> Iterable[tuple[Path, Path]]: + for path in inputs: + if path.is_file() and path.suffix == ".slint": + resolved = path.resolve() + yield resolved, resolved.parent + elif path.is_dir(): + root = path.resolve() + for file in sorted(root.rglob("*.slint")): + resolved = file.resolve() + yield resolved, root + + +def _compile_slint( + compiler: Compiler, + root: Path, + source_path: Path, + config: GenerationConfig, +) -> CompilationResult | None: + result = compiler.build_from_path(source_path) + + def is_error(diag: PyDiagnostic) -> bool: + return diag.level == DiagnosticLevel.Error + + errors: list[PyDiagnostic] = [] + warnings: list[PyDiagnostic] = [] + + for diag in result.diagnostics: + if is_error(diag): + errors.append(diag) + else: + warnings.append(diag) + + non_fatal_errors: list[PyDiagnostic] = [] + fatal_errors: list[PyDiagnostic] = [] + + for err in errors: + # Files that only export structs/globals yield this diagnostic. We can still collect + # metadata for them, so treat it as a warning for generation purposes. + if err.message == "No component found": + non_fatal_errors.append(err) + else: + fatal_errors.append(err) + + warnings.extend(non_fatal_errors) + + source_relative = str(source_path.relative_to(root)) + + if warnings and not config.quiet: + print(f"info: Compilation of {source_relative} completed with warnings:") + for warn in warnings: + print(f" warning: {warn}") + + if fatal_errors: + print(f"error: Compilation of {source_relative} failed & skipped with errors:") + for fatal in fatal_errors: + print(f" error: {fatal}") + return None + + return result + + +def _collect_metadata( + result: CompilationResult, source_descriptor: str +) -> ModuleArtifacts: + components: list[ComponentMeta] = [] + component_enum_props: dict[str, dict[str, str]] = defaultdict(dict) + global_enum_props: dict[tuple[str, str], dict[str, str]] = defaultdict(dict) + struct_enum_fields: dict[str, dict[str, str]] = defaultdict(dict) + used_enum_class_names: set[str] = set() + + for name in result.component_names: + comp = result.component(name) + + if comp is None: + continue + + property_info = {info.name: info for info in comp.property_infos()} + callback_info = {info.name: info for info in comp.callback_infos()} + function_info = {info.name: info for info in comp.function_infos()} + + properties: list[PropertyMeta] = [] + for key in comp.properties: + info = property_info[key] + type_hint = info.python_type + properties.append( + PropertyMeta( + name=key, + py_name=_normalize_prop(key), + type_hint=type_hint, + ) + ) + + callbacks = [_callback_meta(cb, callback_info[cb]) for cb in comp.callbacks] + functions = [_callback_meta(fn, function_info[fn]) for fn in comp.functions] + + globals_meta: list[GlobalMeta] = [] + for global_name in comp.globals: + global_property_info = { + info.name: info + for info in comp.global_property_infos(global_name) or [] + } + global_callback_info = { + info.name: info + for info in comp.global_callback_infos(global_name) or [] + } + global_function_info = { + info.name: info + for info in comp.global_function_infos(global_name) or [] + } + properties_meta: list[PropertyMeta] = [] + + for key in comp.global_properties(global_name) or []: + py_key = _normalize_prop(key) + info = global_property_info[key] + type_hint = info.python_type + properties_meta.append( + PropertyMeta( + name=key, + py_name=py_key, + type_hint=type_hint, + ) + ) + + callbacks_meta = [ + _callback_meta(cb, global_callback_info[cb]) + for cb in comp.global_callbacks(global_name) or [] + ] + + functions_meta = [ + _callback_meta(fn, global_function_info[fn]) + for fn in comp.global_functions(global_name) or [] + ] + + globals_meta.append( + GlobalMeta( + name=global_name, + py_name=_normalize_prop(global_name), + properties=properties_meta, + callbacks=callbacks_meta, + functions=functions_meta, + ) + ) + + components.append( + ComponentMeta( + name=name, + py_name=_normalize_prop(name), + properties=properties, + callbacks=callbacks, + functions=functions, + globals=globals_meta, + ) + ) + + try: + instance = comp.create() + except Exception: + instance = None + + if instance is not None: + for prop_name in comp.properties.keys(): + try: + value = instance.get_property(prop_name) + except Exception: + continue + if isinstance(value, enum.Enum): + used_enum_class_names.add(value.__class__.__name__) + component_enum_props[name][prop_name] = value.__class__.__name__ + + for global_meta in globals_meta: + for prop in global_meta.properties: + try: + value = instance.get_global_property( + global_meta.name, prop.name + ) + except Exception: + continue + if isinstance(value, enum.Enum): + used_enum_class_names.add(value.__class__.__name__) + global_enum_props[(name, global_meta.name)][prop.name] = ( + value.__class__.__name__ + ) + + structs_meta: list[StructMeta] = [] + enums_meta: list[EnumMeta] = [] + structs, enums = result.structs_and_enums + + for struct_name, struct_prototype in structs.items(): + fields: list[StructFieldMeta] = [] + for field_name, value in struct_prototype: + if isinstance(value, enum.Enum): + used_enum_class_names.add(value.__class__.__name__) + struct_enum_fields[struct_name][field_name] = value.__class__.__name__ + fields.append( + StructFieldMeta( + name=field_name, + py_name=_normalize_prop(field_name), + type_hint=_python_value_hint(value), + ) + ) + structs_meta.append( + StructMeta( + name=struct_name, + py_name=_normalize_prop(struct_name), + fields=fields, + is_builtin=False, + ) + ) + + for enum_name, enum_cls in enums.items(): + values: list[EnumValueMeta] = [] + for member, enum_member in enum_cls.__members__.items(): + values.append( + EnumValueMeta( + name=member, + py_name=_normalize_prop(member), + value=enum_member.name, + ) + ) + + is_used = enum_name in used_enum_class_names + + enums_meta.append( + EnumMeta( + name=enum_name, + py_name=_normalize_prop(enum_name), + values=values, + is_builtin=False, + is_used=is_used, + ) + ) + + enum_py_name_map = { + enum.name: enum.py_name for enum in enums_meta if not enum.is_builtin + } + + for component_meta in components: + overrides = component_enum_props.get(component_meta.name, {}) + for prop in component_meta.properties: + class_name = overrides.get(prop.name) + if class_name: + if class_name in enum_py_name_map: + prop.type_hint = enum_py_name_map[class_name] + else: + print( + f"warning: {source_descriptor}: property '{prop.name}' of component '{component_meta.name}' " + f"relies on enum '{class_name}' that is not declared in this module; falling back to Any", + ) + prop.type_hint = "Any" + + for global_meta in component_meta.globals: + overrides = global_enum_props.get( + (component_meta.name, global_meta.name), {} + ) + for prop in global_meta.properties: + class_name = overrides.get(prop.name) + if class_name: + if class_name in enum_py_name_map: + prop.type_hint = enum_py_name_map[class_name] + else: + print( + f"warning: {source_descriptor}: global '{global_meta.name}.{prop.name}' relies on enum '{class_name}' " + "that is not declared in this module; falling back to Any", + ) + prop.type_hint = "Any" + + for struct_meta in structs_meta: + overrides = struct_enum_fields.get(struct_meta.name, {}) + for field in struct_meta.fields: + class_name = overrides.get(field.name) + if class_name: + if class_name in enum_py_name_map: + field.type_hint = enum_py_name_map[class_name] + else: + print( + f"warning: {source_descriptor}: struct '{struct_meta.name}.{field.name}' relies on enum '{class_name}' " + "that is not declared in this module; falling back to Any", + ) + field.type_hint = "Any" + + named_exports = [(orig, alias) for orig, alias in result.named_exports] + resource_paths = [Path(path) for path in result.resource_paths] + + return ModuleArtifacts( + components=components, + structs=structs_meta, + enums=enums_meta, + named_exports=named_exports, + resource_paths=resource_paths, + ) + + +def _ensure_package_marker(module_dir: Path) -> None: + """Ensure the generated directory is recognised as a regular Python package.""" + + try: + module_dir.mkdir(parents=True, exist_ok=True) + init_file = module_dir / "__init__.py" + if not init_file.exists(): + init_file.touch() + except PermissionError: + # If we cannot create the file, leave the namespace untouched. The + # generated module can still be imported in environments that support + # namespace packages. + pass + + +__all__ = ["generate_project"] + + +def _python_value_hint(value: object) -> str: + if isinstance(value, bool): + return "bool" + if isinstance(value, int): + return "int" + if isinstance(value, float): + return "float" + if isinstance(value, str): + return "str" + if isinstance(value, Image): + return "slint.Image" + if isinstance(value, Brush): + return "slint.Brush" + if isinstance(value, Color): + return "slint.Color" + return "Any" + + +def _callback_meta(name: str, info: CallbackInfo | FunctionInfo) -> CallbackMeta: + return CallbackMeta( + name=name, + py_name=_normalize_prop(name), + arg_types=[param.python_type for param in info.parameters], + return_type=info.return_type, + ) diff --git a/api/python/slint/slint/codegen/models.py b/api/python/slint/slint/codegen/models.py new file mode 100644 index 00000000000..4b827ed8146 --- /dev/null +++ b/api/python/slint/slint/codegen/models.py @@ -0,0 +1,105 @@ +# Copyright © SixtyFPS GmbH +# SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, List + + +@dataclass(slots=True) +class GenerationConfig: + include_paths: List[Path] + library_paths: Dict[str, Path] + style: str | None + translation_domain: str | None + quiet: bool = False + + +@dataclass(slots=True) +class PropertyMeta: + name: str + py_name: str + type_hint: str + + +@dataclass(slots=True) +class CallbackMeta: + name: str + py_name: str + arg_types: List[str] + return_type: str + + +@dataclass(slots=True) +class ComponentMeta: + name: str + py_name: str + properties: List[PropertyMeta] + callbacks: List[CallbackMeta] + functions: List[CallbackMeta] + globals: List["GlobalMeta"] + + +@dataclass(slots=True) +class GlobalMeta: + name: str + py_name: str + properties: List[PropertyMeta] + callbacks: List[CallbackMeta] + functions: List[CallbackMeta] + + +@dataclass(slots=True) +class StructFieldMeta: + name: str + py_name: str + type_hint: str + + +@dataclass(slots=True) +class StructMeta: + name: str + py_name: str + fields: List[StructFieldMeta] + is_builtin: bool + + +@dataclass(slots=True) +class EnumValueMeta: + name: str + py_name: str + value: str + + +@dataclass(slots=True) +class EnumMeta: + name: str + py_name: str + values: List[EnumValueMeta] + is_builtin: bool + is_used: bool + + +@dataclass(slots=True) +class ModuleArtifacts: + components: List[ComponentMeta] + structs: List[StructMeta] + enums: List[EnumMeta] + named_exports: List[tuple[str, str]] + resource_paths: List[Path] + + +__all__ = [ + "GenerationConfig", + "PropertyMeta", + "CallbackMeta", + "ComponentMeta", + "GlobalMeta", + "StructFieldMeta", + "StructMeta", + "EnumValueMeta", + "EnumMeta", + "ModuleArtifacts", +] diff --git a/api/python/slint/slint/core.pyi b/api/python/slint/slint/core.pyi new file mode 100644 index 00000000000..bf9e92e515f --- /dev/null +++ b/api/python/slint/slint/core.pyi @@ -0,0 +1,618 @@ +# Copyright © SixtyFPS GmbH +# SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +# This file is automatically generated by pyo3_stub_gen +# ruff: noqa: E501, F401 + +import abc +import builtins +import datetime +import enum +import os +import pathlib +import typing +from typing import Self + +@typing.final +class AsyncAdapter: + def __new__(cls, fd: builtins.int) -> Self: ... + def wait_for_readable(self, callback: typing.Any) -> None: ... + def wait_for_writable(self, callback: typing.Any) -> None: ... + +@typing.final +class Brush: + r""" + A brush is a data structure that is used to describe how a shape, such as a rectangle, path or even text, + shall be filled. A brush can also be applied to the outline of a shape, that means the fill of the outline itself. + + Brushes can only be constructed from solid colors. + + **Note:** In future, we plan to reduce this constraint and allow for declaring graidient brushes programmatically. + """ + @property + def color(self) -> Color: + r""" + The brush's color. + """ + def __new__(cls, maybe_value: typing.Optional[Color] = None) -> Self: ... + def is_transparent(self) -> builtins.bool: + r""" + Returns true if this brush contains a fully transparent color (alpha value is zero). + """ + def is_opaque(self) -> builtins.bool: + r""" + Returns true if this brush is fully opaque. + """ + def brighter(self, factor: builtins.float) -> Brush: + r""" + Returns a new version of this brush that has the brightness increased + by the specified factor. This is done by calling `Color.brighter` on + all the colors of this brush. + """ + def darker(self, factor: builtins.float) -> Brush: + r""" + Returns a new version of this brush that has the brightness decreased + by the specified factor. This is done by calling `Color.darker` on + all the color of this brush. + """ + def transparentize(self, amount: builtins.float) -> Brush: + r""" + Returns a new version of this brush with the opacity decreased by `factor`. + + The transparency is obtained by multiplying the alpha channel by `(1 - factor)`. + + See also `Color.transparentize`. + """ + def with_alpha(self, alpha: builtins.float) -> Brush: + r""" + Returns a new version of this brush with the related color's opacities + set to `alpha`. + """ + def __eq__(self, other: object) -> builtins.bool: ... + +@typing.final +class CallbackInfo: + @property + def name(self) -> builtins.str: ... + @property + def parameters(self) -> builtins.list[CallbackParameter]: ... + @property + def return_type(self) -> builtins.str: ... + +@typing.final +class CallbackParameter: + @property + def name(self) -> typing.Optional[builtins.str]: ... + @property + def python_type(self) -> builtins.str: ... + +@typing.final +class Color: + r""" + A Color object represents a color in the RGB color space with an alpha. Each color channel and the alpha is represented + as an 8-bit integer. The alpha channel is 0 for fully transparent and 255 for fully opaque. + + Construct colors from a CSS color string, or by specifying the red, green, blue, and (optional) alpha channels in a dict. + """ + @property + def red(self) -> builtins.int: + r""" + The red channel. + """ + @property + def green(self) -> builtins.int: + r""" + The green channel. + """ + @property + def blue(self) -> builtins.int: + r""" + The blue channel. + """ + @property + def alpha(self) -> builtins.int: + r""" + The alpha channel. + """ + def __new__( + cls, + maybe_value: typing.Optional[ + builtins.str | PyColorInput.RgbaColor | PyColorInput.RgbColor + ] = None, + ) -> Self: ... + def brighter(self, factor: builtins.float) -> Color: + r""" + Returns a new color that is brighter than this color by the given factor. + """ + def darker(self, factor: builtins.float) -> Color: + r""" + Returns a new color that is darker than this color by the given factor. + """ + def transparentize(self, factor: builtins.float) -> Color: + r""" + Returns a new version of this color with the opacity decreased by `factor`. + + The transparency is obtained by multiplying the alpha channel by `(1 - factor)`. + """ + def mix(self, other: Color, factor: builtins.float) -> Color: + r""" + Returns a new color that is a mix of this color and `other`. The specified factor is + clamped to be between `0.0` and `1.0` and then applied to this color, while `1.0 - factor` + is applied to `other`. + """ + def with_alpha(self, alpha: builtins.float) -> Color: + r""" + Returns a new version of this color with the opacity set to `alpha`. + """ + def __str__(self) -> builtins.str: ... + def __eq__(self, other: object) -> builtins.bool: ... + +@typing.final +class CompilationResult: + @property + def component_names(self) -> builtins.list[builtins.str]: ... + @property + def diagnostics(self) -> builtins.list[PyDiagnostic]: ... + @property + def structs_and_enums( + self, + ) -> tuple[ + builtins.dict[builtins.str, typing.Any], builtins.dict[builtins.str, typing.Any] + ]: ... + @property + def named_exports(self) -> builtins.list[tuple[builtins.str, builtins.str]]: ... + @property + def resource_paths(self) -> builtins.list[pathlib.Path]: ... + def component(self, name: builtins.str) -> typing.Optional[ComponentDefinition]: ... + +@typing.final +class Compiler: + @property + def include_paths(self) -> builtins.list[pathlib.Path]: ... + @include_paths.setter + def include_paths(self, value: builtins.list[pathlib.Path]) -> None: ... + @property + def style(self) -> typing.Optional[builtins.str]: ... + @style.setter + def style(self, value: builtins.str) -> None: ... + @property + def library_paths(self) -> builtins.dict[builtins.str, pathlib.Path]: ... + @library_paths.setter + def library_paths( + self, value: builtins.dict[builtins.str, pathlib.Path] + ) -> None: ... + def __new__(cls) -> Self: ... + def set_translation_domain(self, domain: builtins.str) -> None: ... + def build_from_path( + self, path: builtins.str | os.PathLike[builtins.str] | pathlib.Path + ) -> CompilationResult: ... + def build_from_source( + self, + source_code: builtins.str, + path: builtins.str | os.PathLike[builtins.str] | pathlib.Path, + ) -> CompilationResult: ... + +@typing.final +class ComponentDefinition: + @property + def name(self) -> builtins.str: ... + @property + def properties(self) -> builtins.dict[builtins.str, ValueType]: ... + @property + def callbacks(self) -> builtins.list[builtins.str]: ... + @property + def functions(self) -> builtins.list[builtins.str]: ... + @property + def globals(self) -> builtins.list[builtins.str]: ... + def property_infos(self) -> builtins.list[PropertyInfo]: ... + def callback_infos(self) -> builtins.list[CallbackInfo]: ... + def function_infos(self) -> builtins.list[FunctionInfo]: ... + def global_properties( + self, name: builtins.str + ) -> typing.Optional[builtins.dict[builtins.str, ValueType]]: ... + def global_callbacks( + self, name: builtins.str + ) -> typing.Optional[builtins.list[builtins.str]]: ... + def global_functions( + self, name: builtins.str + ) -> typing.Optional[builtins.list[builtins.str]]: ... + def global_property_infos( + self, global_name: builtins.str + ) -> typing.Optional[builtins.list[PropertyInfo]]: ... + def global_callback_infos( + self, global_name: builtins.str + ) -> typing.Optional[builtins.list[CallbackInfo]]: ... + def global_function_infos( + self, global_name: builtins.str + ) -> typing.Optional[builtins.list[FunctionInfo]]: ... + def callback_returns_void(self, callback_name: builtins.str) -> builtins.bool: ... + def global_callback_returns_void( + self, global_name: builtins.str, callback_name: builtins.str + ) -> builtins.bool: ... + def create(self) -> ComponentInstance: ... + +@typing.final +class ComponentInstance: + @property + def definition(self) -> ComponentDefinition: ... + def get_property(self, name: builtins.str) -> typing.Any: ... + def set_property(self, name: builtins.str, value: typing.Any) -> None: ... + def get_global_property( + self, global_name: builtins.str, prop_name: builtins.str + ) -> typing.Any: ... + def set_global_property( + self, global_name: builtins.str, prop_name: builtins.str, value: typing.Any + ) -> None: ... + def invoke(self, callback_name: builtins.str, *args: typing.Any) -> typing.Any: ... + def invoke_global( + self, global_name: builtins.str, callback_name: builtins.str, *args: typing.Any + ) -> typing.Any: ... + def set_callback(self, name: builtins.str, callable: typing.Any) -> None: ... + def set_global_callback( + self, + global_name: builtins.str, + callback_name: builtins.str, + callable: typing.Any, + ) -> None: ... + def show(self) -> None: ... + def hide(self) -> None: ... + def __clear__(self) -> None: ... + +@typing.final +class FunctionInfo: + @property + def name(self) -> builtins.str: ... + @property + def parameters(self) -> builtins.list[CallbackParameter]: ... + @property + def return_type(self) -> builtins.str: ... + +@typing.final +class Image: + r""" + Image objects can be set on Slint Image elements for display. Use `Image.load_from_path` to construct Image + objects from a path to an image file on disk. + """ + @property + def size(self) -> tuple[builtins.int, builtins.int]: + r""" + The size of the image as tuple of `width` and `height`. + """ + @property + def width(self) -> builtins.int: + r""" + The width of the image in pixels. + """ + @property + def height(self) -> builtins.int: + r""" + The height of the image in pixels. + """ + @property + def path(self) -> typing.Optional[pathlib.Path]: + r""" + The path of the image if it was loaded from disk, or None. + """ + def __new__(cls) -> Self: ... + @staticmethod + def load_from_path( + path: builtins.str | os.PathLike[builtins.str] | pathlib.Path, + ) -> Image: + r""" + Loads the image from the specified path. Returns None if the image can't be loaded. + """ + @staticmethod + def load_from_svg_data(data: typing.Sequence[builtins.int]) -> Image: + r""" + Creates a new image from a string that describes the image in SVG format. + """ + @staticmethod + def load_from_array(array: typing.Any) -> Image: + r""" + Creates a new image from an array-like object that implements the [Buffer Protocol](https://docs.python.org/3/c-api/buffer.html). + Use this function to import images created by third-party modules such as matplotlib or Pillow. + + The array must satisfy certain contraints to represent an image: + + - The buffer's format needs to be `B` (unsigned char) + - The shape must be a tuple of (height, width, bytes-per-pixel) + - If a stride is defined, the row stride must be equal to width * bytes-per-pixel, and the column stride must equal the bytes-per-pixel. + - A value of 3 for bytes-per-pixel is interpreted as RGB image, a value of 4 means RGBA. + + The image is created by performing a deep copy of the array's data. Subsequent changes to the buffer are not automatically + reflected in a previously created Image. + + Example of importing a matplot figure into an image: + ```python + import slint + import matplotlib + + from matplotlib.backends.backend_agg import FigureCanvasAgg + from matplotlib.figure import Figure + + fig = Figure(figsize=(5, 4), dpi=100) + canvas = FigureCanvasAgg(fig) + ax = fig.add_subplot() + ax.plot([1, 2, 3]) + canvas.draw() + + buffer = canvas.buffer_rgba() + img = slint.Image.load_from_array(buffer) + ``` + + Example of loading an image with Pillow: + ```python + import slint + from PIL import Image + import numpy as np + + pil_img = Image.open("hello.jpeg") + array = np.array(pil_img) + img = slint.Image.load_from_array(array) + ``` + """ + +@typing.final +class PropertyInfo: + @property + def name(self) -> builtins.str: ... + @property + def python_type(self) -> builtins.str: ... + +class PyColorInput: + @typing.final + class ColorStr(PyColorInput): + __match_args__ = ("_0",) + @property + def _0(self) -> builtins.str: ... + def __new__(cls, _0: builtins.str) -> Self: ... + def __len__(self) -> builtins.int: ... + def __getitem__(self, key: builtins.int) -> typing.Any: ... + + class RgbaColor(typing.TypedDict): + red: builtins.int + green: builtins.int + blue: builtins.int + alpha: builtins.int + + class RgbColor(typing.TypedDict): + red: builtins.int + green: builtins.int + blue: builtins.int + + ... + +@typing.final +class PyDiagnostic: + @property + def level(self) -> DiagnosticLevel: ... + @property + def message(self) -> builtins.str: ... + @property + def column_number(self) -> builtins.int: ... + @property + def line_number(self) -> builtins.int: ... + @property + def source_file(self) -> typing.Optional[pathlib.Path]: ... + def __str__(self) -> builtins.str: ... + +class PyModelBase(abc.ABC): + def __new__(cls) -> Self: ... + def init_self(self, self_ref: typing.Any) -> None: ... + def notify_row_added(self, index: builtins.int, count: builtins.int) -> None: + r""" + Call this method from a sub-class to notify the views that + `count` rows have been added starting at `index`. + """ + def notify_row_changed(self, index: builtins.int) -> None: + r""" + Call this method from a sub-class to notify the views that a row has changed. + """ + def notify_row_removed(self, index: builtins.int, count: builtins.int) -> None: + r""" + Call this method from a sub-class to notify the views that + `count` rows have been removed starting at `index`. + """ + @abc.abstractmethod + def row_count(self) -> builtins.int: + r""" + Returns the number of rows available in the model. + """ + @abc.abstractmethod + def row_data(self, _row: builtins.int) -> typing.Optional[typing.Any]: + r""" + Returns the data for the given row in the model. + """ + @abc.abstractmethod + def set_row_data(self, _row: builtins.int, _value: typing.Any) -> None: + r""" + Call this method on mutable models to change the data for the given row. + The UI will also call this method when modifying a model's data. + Re-implement this method in a sub-class to handle the change. + """ + def __clear__(self) -> None: ... + +class PyStruct: + def __getattr__(self, key: builtins.str) -> typing.Any: ... + def __setattr__(self, key: builtins.str, value: typing.Any) -> None: ... + def __iter__(self) -> PyStructFieldIterator: ... + def __copy__(self) -> PyStruct: ... + def __clear__(self) -> None: ... + +@typing.final +class PyStructFieldIterator: + def __iter__(self) -> PyStructFieldIterator: ... + def __next__(self) -> typing.Any: ... + +@typing.final +class ReadOnlyRustModel: + def row_count(self) -> builtins.int: ... + def row_data(self, row: builtins.int) -> typing.Any: ... + def __len__(self) -> builtins.int: ... + def __iter__(self) -> ReadOnlyRustModelIterator: ... + def __getitem__(self, index: builtins.int) -> typing.Any: ... + +@typing.final +class ReadOnlyRustModelIterator: + def __iter__(self) -> ReadOnlyRustModelIterator: ... + def __next__(self) -> typing.Any: ... + +@typing.final +class RgbColor: + @property + def red(self) -> builtins.int: ... + @red.setter + def red(self, value: builtins.int) -> None: ... + @property + def green(self) -> builtins.int: ... + @green.setter + def green(self, value: builtins.int) -> None: ... + @property + def blue(self) -> builtins.int: ... + @blue.setter + def blue(self, value: builtins.int) -> None: ... + +@typing.final +class RgbaColor: + @property + def red(self) -> builtins.int: ... + @red.setter + def red(self, value: builtins.int) -> None: ... + @property + def green(self) -> builtins.int: ... + @green.setter + def green(self, value: builtins.int) -> None: ... + @property + def blue(self) -> builtins.int: ... + @blue.setter + def blue(self, value: builtins.int) -> None: ... + @property + def alpha(self) -> builtins.int: ... + @alpha.setter + def alpha(self, value: builtins.int) -> None: ... + +@typing.final +class SlintToPyValue: ... + +@typing.final +class Timer: + r""" + Timer is a handle to the timer system that triggers a callback after a specified + period of time. + + Use `Timer.start()` to create a timer that that repeatedly triggers a callback, or + `Timer.single_shot()` to trigger a callback only once. + + The timer will automatically stop when garbage collected. You must keep the Timer object + around for as long as you want the timer to keep firing. + + ```python + class AppWindow(...) + def __init__(self): + super().__init__() + self.my_timer = None + + @slint.callback + def button_clicked(self): + self.my_timer = slint.Timer() + self.my_timer.start(timedelta(seconds=1), self.do_something) + + def do_something(self): + pass + ``` + + Timers can only be used in the thread that runs the Slint event loop. They don't + fire if used in another thread. + """ + @property + def running(self) -> builtins.bool: + r""" + Set to true if the timer is running; false otherwise. + """ + @property + def interval(self) -> datetime.timedelta: ... + @interval.setter + def interval(self, value: datetime.timedelta) -> None: + r""" + The duration of timer. + + When setting this property and the timer is running (see `Timer.running`), + then the time when the callback will be next invoked is re-calculated to be in the + specified duration relative to when this property is set. + """ + def __new__(cls) -> Self: ... + def start( + self, mode: TimerMode, interval: datetime.timedelta, callback: typing.Any + ) -> None: + r""" + Starts the timer with the given mode and interval, in order for the callback to called when the + timer fires. If the timer has been started previously and not fired yet, then it will be restarted. + + Arguments: + * `mode`: The timer mode to apply, i.e. whether to repeatedly fire the timer or just once. + * `interval`: The duration from now until when the timer should firethe first time, and subsequently + for `TimerMode.Repeated` timers. + * `callback`: The function to call when the time has been reached or exceeded. + """ + @staticmethod + def single_shot(duration: datetime.timedelta, callback: typing.Any) -> None: + r""" + Starts the timer with the duration and the callback to called when the + timer fires. It is fired only once and then deleted. + + Arguments: + * `duration`: The duration from now until when the timer should fire. + * `callback`: The function to call when the time has been reached or exceeded. + """ + def stop(self) -> None: + r""" + Stops the previously started timer. Does nothing if the timer has never been started. + """ + def restart(self) -> None: + r""" + Restarts the timer. If the timer was previously started by calling `Timer.start()` + with a duration and callback, then the time when the callback will be next invoked + is re-calculated to be in the specified duration relative to when this function is called. + + Does nothing if the timer was never started. + """ + +@typing.final +class DiagnosticLevel(enum.Enum): + Error = ... + Warning = ... + +@typing.final +class TimerMode(enum.Enum): + r""" + The TimerMode specifies what should happen after the timer fired. + + Used by the `Timer.start()` function. + """ + + SingleShot = ... + r""" + A SingleShot timer is fired only once. + """ + Repeated = ... + r""" + A Repeated timer is fired repeatedly until it is stopped or dropped. + """ + +@typing.final +class ValueType(enum.Enum): + Void = ... + Number = ... + String = ... + Bool = ... + Model = ... + Struct = ... + Brush = ... + Image = ... + Enumeration = ... + +def init_translations(translations: typing.Any) -> None: ... +def invoke_from_event_loop(callable: typing.Any) -> None: ... +def quit_event_loop() -> None: ... +def run_event_loop() -> None: ... +def set_xdg_app_id(app_id: builtins.str) -> None: ... diff --git a/api/python/slint/slint/loop.py b/api/python/slint/slint/loop.py index b6a0e5bd39c..af5e7f249a7 100644 --- a/api/python/slint/slint/loop.py +++ b/api/python/slint/slint/loop.py @@ -1,21 +1,23 @@ # Copyright © SixtyFPS GmbH # SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 -from . import slint as native -import asyncio.selector_events import asyncio import asyncio.events +import asyncio.selector_events +import datetime import selectors +import socket import typing from collections.abc import Mapping -import datetime +from . import core -class HasFileno(typing.Protocol): + +class _HasFileno(typing.Protocol): def fileno(self) -> int: ... -def fd_for_fileobj(fileobj: int | HasFileno) -> int: +def _fd_for_fileobj(fileobj: int | _HasFileno) -> int: if isinstance(fileobj, int): return fileobj return int(fileobj.fileno()) @@ -29,11 +31,11 @@ def __len__(self) -> int: return len(self.slint_selector.fd_to_selector_key) def get(self, fileobj, default=None): # type: ignore - fd = fd_for_fileobj(fileobj) + fd = _fd_for_fileobj(fileobj) return self.slint_selector.fd_to_selector_key.get(fd, default) def __getitem__(self, fileobj: typing.Any) -> selectors.SelectorKey: - fd = fd_for_fileobj(fileobj) + fd = _fd_for_fileobj(fileobj) return self.slint_selector.fd_to_selector_key[fd] def __iter__(self): # type: ignore @@ -44,16 +46,21 @@ class _SlintSelector(selectors.BaseSelector): def __init__(self) -> None: self.fd_to_selector_key: typing.Dict[typing.Any, selectors.SelectorKey] = {} self.mapping = _SlintSelectorMapping(self) - self.adapters: typing.Dict[int, native.AsyncAdapter] = {} + self.adapters: typing.Dict[int, core.AsyncAdapter] = {} + self._base_selector = selectors.DefaultSelector() + self._wakeup_reader, self._wakeup_writer = socket.socketpair() + self._wakeup_reader.setblocking(False) + self._wakeup_writer.setblocking(False) + self._base_selector.register(self._wakeup_reader, selectors.EVENT_READ) def register( self, fileobj: typing.Any, events: typing.Any, data: typing.Any = None ) -> selectors.SelectorKey: - fd = fd_for_fileobj(fileobj) + fd = _fd_for_fileobj(fileobj) key = selectors.SelectorKey(fileobj, fd, events, data) self.fd_to_selector_key[fd] = key - adapter = native.AsyncAdapter(fd) + adapter = core.AsyncAdapter(fd) self.adapters[fd] = adapter if events & selectors.EVENT_READ: @@ -61,10 +68,15 @@ def register( if events & selectors.EVENT_WRITE: adapter.wait_for_writable(self.write_notify) + try: + self._base_selector.register(fileobj, events, data) + except KeyError: + self._base_selector.modify(fileobj, events, data) + return key def unregister(self, fileobj: typing.Any) -> selectors.SelectorKey: - fd = fd_for_fileobj(fileobj) + fd = _fd_for_fileobj(fileobj) key = self.fd_to_selector_key.pop(fd) try: @@ -72,12 +84,17 @@ def unregister(self, fileobj: typing.Any) -> selectors.SelectorKey: except KeyError: pass + try: + self._base_selector.unregister(fileobj) + except KeyError: + pass + return key def modify( self, fileobj: typing.Any, events: int, data: typing.Any = None ) -> selectors.SelectorKey: - fd = fd_for_fileobj(fileobj) + fd = _fd_for_fileobj(fileobj) key = self.fd_to_selector_key[fd] if key.events != events: @@ -87,54 +104,106 @@ def modify( key._replace(data=data) self.fd_to_selector_key[fd] = key + try: + self._base_selector.modify(fileobj, events, data) + except KeyError: + self._base_selector.register(fileobj, events, data) + return key def select( self, timeout: float | None = None ) -> typing.List[typing.Tuple[selectors.SelectorKey, int]]: - raise NotImplementedError + events = self._base_selector.select(timeout) + ready: typing.List[typing.Tuple[selectors.SelectorKey, int]] = [] + for key, mask in events: + if key.fileobj is self._wakeup_reader: + self._drain_wakeup() + continue + + fd = _fd_for_fileobj(key.fileobj) + slint_key = self.fd_to_selector_key.get(fd) + if slint_key is not None: + ready.append((slint_key, mask)) + + return ready def close(self) -> None: - pass + try: + self._base_selector.unregister(self._wakeup_reader) + except Exception: + pass + self._base_selector.close() + self._wakeup_reader.close() + self._wakeup_writer.close() - def get_map(self) -> Mapping[int | HasFileno, selectors.SelectorKey]: + def get_map(self) -> Mapping[int | _HasFileno, selectors.SelectorKey]: return self.mapping def read_notify(self, fd: int) -> None: key = self.fd_to_selector_key[fd] (reader, writer) = key.data reader._run() + self._wakeup() def write_notify(self, fd: int) -> None: key = self.fd_to_selector_key[fd] (reader, writer) = key.data writer._run() + self._wakeup() + + def _wakeup(self) -> None: + try: + self._wakeup_writer.send(b"\0") + except BlockingIOError: + pass + + def _drain_wakeup(self) -> None: + try: + while self._wakeup_reader.recv(1024): + pass + except BlockingIOError: + pass class SlintEventLoop(asyncio.SelectorEventLoop): def __init__(self) -> None: self._is_running = False - self._timers: typing.Set[native.Timer] = set() + self._timers: typing.Set[core.Timer] = set() self.stop_run_forever_event = asyncio.Event() self._soon_tasks: typing.List[asyncio.TimerHandle] = [] + self._core_loop_started = False + self._core_loop_running = False super().__init__(_SlintSelector()) def run_forever(self) -> None: + if self._core_loop_started: + return asyncio.selector_events.BaseSelectorEventLoop.run_forever(self) + async def loop_stopper(event: asyncio.Event) -> None: await event.wait() - native.quit_event_loop() + core.quit_event_loop() asyncio.events._set_running_loop(self) self._is_running = True + self._core_loop_started = True + self._core_loop_running = True try: self.stop_run_forever_event = asyncio.Event() self.create_task(loop_stopper(self.stop_run_forever_event)) - native.run_event_loop() + core.run_event_loop() finally: + self._core_loop_running = False self._is_running = False + self._stopping = False asyncio.events._set_running_loop(None) def run_until_complete[T](self, future: typing.Awaitable[T]) -> T | None: # type: ignore[override] + if self._core_loop_started and not self._core_loop_running: + return asyncio.selector_events.BaseSelectorEventLoop.run_until_complete( + self, future + ) + def stop_loop(future: typing.Any) -> None: self.stop() @@ -148,16 +217,18 @@ def stop_loop(future: typing.Any) -> None: if future.done(): return future.result() - else: - if self.stop_run_forever_event.is_set(): - raise RuntimeError("run_until_complete's future isn't done", future) - else: - # If the loop was quit for example because the user closed the last window, then - # don't thrown an error but return a None sentinel. The return value of asyncio.run() - # isn't used by slint.run_event_loop() anyway - # TODO: see if we can properly cancel the future by calling cancel() and throwing - # the task cancellation exception. - return None + if self.stop_run_forever_event.is_set(): + raise RuntimeError("run_until_complete's future isn't done", future) + + # The Slint core event loop can terminate even though the awaiting coroutine + # is still running (for example when the user closes the last window). Python's + # BaseEventLoop would raise a RuntimeError in that case, but Slint's API expects + # a graceful shutdown without surfacing an exception to the caller. Returning + # None here mirrors the historical behaviour and avoids breaking applications. + # Attempts at cancelling the Task at this point still leave it pending because + # the underlying loop has already stopped, so we cannot currently satisfy the + # TODO of propagating a proper CancelledError. + return None def _run_forever_setup(self) -> None: pass @@ -166,7 +237,17 @@ def _run_forever_cleanup(self) -> None: pass def stop(self) -> None: - self.stop_run_forever_event.set() + if ( + self._core_loop_started + and self._core_loop_running + and self.stop_run_forever_event is not None + ): + self.stop_run_forever_event.set() + + super().stop() + selector = self._selector # type: ignore[attr-defined] + if isinstance(selector, _SlintSelector): + selector._wakeup() def is_running(self) -> bool: return self._is_running @@ -178,7 +259,10 @@ def is_closed(self) -> bool: return False def call_later(self, delay, callback, *args, context=None) -> asyncio.TimerHandle: # type: ignore - timer = native.Timer() + if self._core_loop_started and not self._core_loop_running: + return super().call_later(delay, callback, *args, context=context) + + timer = core.Timer() handle = asyncio.TimerHandle( when=self.time() + delay, @@ -196,7 +280,7 @@ def timer_done_cb() -> None: handle._run() timer.start( - native.TimerMode.SingleShot, + core.TimerMode.SingleShot, interval=datetime.timedelta(seconds=delay), callback=timer_done_cb, ) @@ -209,6 +293,9 @@ def call_at(self, when, callback, *args, context=None) -> asyncio.TimerHandle: return self.call_later(when - self.time(), callback, *args, context=context) def call_soon(self, callback, *args, context=None) -> asyncio.TimerHandle: # type: ignore + if self._core_loop_started and not self._core_loop_running: + return super().call_soon(callback, *args, context=context) # type: ignore + # Collect call-soon tasks in a separate list to ensure FIFO order, as there's no guarantee # that multiple single-shot timers in Slint are run in order. handle = asyncio.TimerHandle( @@ -226,6 +313,9 @@ def _flush_soon_tasks(self) -> None: handle._run() def call_soon_threadsafe(self, callback, *args, context=None) -> asyncio.Handle: # type: ignore + if self._core_loop_started and not self._core_loop_running: + return super().call_soon_threadsafe(callback, *args, context=context) + handle = asyncio.Handle( callback=callback, args=args, @@ -237,8 +327,12 @@ def run_handle_cb() -> None: if not handle._cancelled: handle._run() - native.invoke_from_event_loop(run_handle_cb) + core.invoke_from_event_loop(run_handle_cb) return handle def _write_to_self(self) -> None: - raise NotImplementedError + selector = self._selector # type: ignore[attr-defined] + if isinstance(selector, _SlintSelector): + selector._wakeup() + else: + asyncio.SelectorEventLoop._write_to_self(self) # type: ignore[attr-defined] diff --git a/api/python/slint/slint/models.py b/api/python/slint/slint/models.py index 5e583a86c6f..18f89bd5ab9 100644 --- a/api/python/slint/slint/models.py +++ b/api/python/slint/slint/models.py @@ -1,14 +1,15 @@ # Copyright © SixtyFPS GmbH # SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 -from . import slint as native -from collections.abc import Iterable -from abc import abstractmethod import typing -from typing import Any, cast, Iterator +from abc import ABC +from collections.abc import Iterable +from typing import Any, Iterator + +from . import core -class Model[T](native.PyModelBase, Iterable[T]): +class Model[T](core.PyModelBase, ABC, Iterable[T]): """Model is the base class for feeding dynamic data into Slint views. Subclass Model to implement your own models, or use `ListModel` to wrap a list. @@ -33,32 +34,6 @@ def __setitem__(self, index: int, value: T) -> None: def __iter__(self) -> Iterator[T]: return ModelIterator(self) - def set_row_data(self, row: int, value: T) -> None: - """Call this method on mutable models to change the data for the given row. - The UI will also call this method when modifying a model's data. - Re-implement this method in a sub-class to handle the change.""" - super().set_row_data(row, value) - - @abstractmethod - def row_data(self, row: int) -> typing.Optional[T]: - """Returns the data for the given row. - Re-implement this method in a sub-class to provide the data.""" - return cast(T, super().row_data(row)) - - def notify_row_changed(self, row: int) -> None: - """Call this method from a sub-class to notify the views that a row has changed.""" - super().notify_row_changed(row) - - def notify_row_removed(self, row: int, count: int) -> None: - """Call this method from a sub-class to notify the views that - `count` rows have been removed starting at `row`.""" - super().notify_row_removed(row, count) - - def notify_row_added(self, row: int, count: int) -> None: - """Call this method from a sub-class to notify the views that - `count` rows have been added starting at `row`.""" - super().notify_row_added(row, count) - class ListModel[T](Model[T]): """ListModel is a `Model` that stores its data in a Python list. @@ -71,20 +46,18 @@ class ListModel[T](Model[T]): in UI they're used with. """ - def __init__(self, iterable: typing.Optional[Iterable[T]] = None): + def __init__(self, iterable: typing.Optional[Iterable[T]] = None) -> None: """Constructs a new ListModel from the give iterable. All the values the iterable produces are stored in a list.""" super().__init__() - if iterable is not None: - self.list = list(iterable) - else: - self.list = [] + items = list(iterable) if iterable is not None else [] + self.list: list[T] = items def row_count(self) -> int: return len(self.list) - def row_data(self, row: int) -> typing.Optional[T]: + def row_data(self, row: int) -> T: return self.list[row] def set_row_data(self, row: int, data: T) -> None: @@ -109,7 +82,7 @@ def append(self, value: T) -> None: class ModelIterator[T](Iterator[T]): - def __init__(self, model: Model[T]): + def __init__(self, model: Model[T]) -> None: self.model = model self.index = 0 @@ -121,6 +94,4 @@ def __next__(self) -> T: raise StopIteration() index = self.index self.index += 1 - data = self.model.row_data(index) - assert data is not None - return data + return self.model.row_data(index) # type: ignore diff --git a/api/python/slint/slint/slint.pyi b/api/python/slint/slint/slint.pyi deleted file mode 100644 index c506ea4b4d1..00000000000 --- a/api/python/slint/slint/slint.pyi +++ /dev/null @@ -1,246 +0,0 @@ -# Copyright © SixtyFPS GmbH -# SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 - -# This file is automatically generated by pyo3_stub_gen -# ruff: noqa: E501, F401 - -import builtins -import datetime -import os -import pathlib -import typing -from typing import Any, List -from collections.abc import Callable, Buffer, Coroutine -from enum import Enum, auto -import gettext - -class RgbColor: - red: int - green: int - blue: int - -class RgbaColor: - red: int - green: int - blue: int - alpha: int - -class Color: - red: int - green: int - blue: int - alpha: int - def __new__( - cls, - maybe_value: typing.Optional[ - str | RgbaColor | RgbColor | typing.Dict[str, int] - ] = None, - ) -> "Color": ... - def brighter(self, factor: float) -> "Color": ... - def darker(self, factor: float) -> "Color": ... - def transparentize(self, factor: float) -> "Color": ... - def mix(self, other: "Image", factor: float) -> "Color": ... - def with_alpha(self, alpha: float) -> "Color": ... - def __str__(self) -> str: ... - def __eq__(self, other: object) -> bool: ... - -class Brush: - color: Color - def __new__(cls, maybe_value: typing.Optional[Color]) -> "Brush": ... - def is_transparent(self) -> bool: ... - def is_opaque(self) -> bool: ... - def brighter(self, factor: float) -> "Brush": ... - def darker(self, factor: float) -> "Brush": ... - def transparentize(self, amount: float) -> "Brush": ... - def with_alpha(self, alpha: float) -> "Brush": ... - def __eq__(self, other: object) -> bool: ... - -class Image: - r""" - Image objects can be set on Slint Image elements for display. Construct Image objects from a path to an - image file on disk, using `Image.load_from_path`. - """ - - size: tuple[int, int] - width: int - height: int - path: typing.Optional[pathlib.Path] - def __new__( - cls, - ) -> "Image": ... - @staticmethod - def load_from_path(path: str | os.PathLike[Any] | pathlib.Path) -> "Image": - r""" - Loads the image from the specified path. Returns None if the image can't be loaded. - """ - ... - - @staticmethod - def load_from_svg_data(data: typing.Sequence[int]) -> "Image": - r""" - Creates a new image from a string that describes the image in SVG format. - """ - ... - - @staticmethod - def load_from_array(array: Buffer) -> Image: - r""" - Creates a new image from an array-like object that implements the [Buffer Protocol](https://docs.python.org/3/c-api/buffer.html). - Use this function to import images created by third-party modules such as matplotlib or Pillow. - - The array must satisfy certain contraints to represent an image: - - - The buffer's format needs to be `B` (unsigned char) - - The shape must be a tuple of (height, width, bytes-per-pixel) - - If a stride is defined, the row stride must be equal to width * bytes-per-pixel, and the column stride must equal the bytes-per-pixel. - - A value of 3 for bytes-per-pixel is interpreted as RGB image, a value of 4 means RGBA. - - Example of importing a matplot figure into an image: - ```python - import slint - import matplotlib - - from matplotlib.backends.backend_agg import FigureCanvasAgg - from matplotlib.figure import Figure - - fig = Figure(figsize=(5, 4), dpi=100) - canvas = FigureCanvasAgg(fig) - ax = fig.add_subplot() - ax.plot([1, 2, 3]) - canvas.draw() - - buffer = canvas.buffer_rgba() - img = slint.Image.load_from_array(buffer) - ``` - - Example of loading an image with Pillow: - ```python - import slint - from PIL import Image - import numpy as np - - pil_img = Image.open("hello.jpeg") - array = np.array(pil_img) - img = slint.Image.load_from_array(array) - ``` - """ - -class TimerMode(Enum): - SingleShot = auto() - Repeated = auto() - -class Timer: - running: bool - interval: datetime.timedelta - def __new__( - cls, - ) -> "Timer": ... - def start( - self, mode: TimerMode, interval: datetime.timedelta, callback: typing.Any - ) -> None: ... - @staticmethod - def single_shot(duration: datetime.timedelta, callback: typing.Any) -> None: ... - def stop(self) -> None: ... - def restart(self) -> None: ... - -def set_xdg_app_id(app_id: str) -> None: ... -def invoke_from_event_loop(callable: typing.Callable[[], None]) -> None: ... -def run_event_loop() -> None: ... -def quit_event_loop() -> None: ... -def init_translations( - translations: typing.Optional[gettext.GNUTranslations], -) -> None: ... - -class PyModelBase: - def init_self(self, *args: Any) -> None: ... - def row_count(self) -> int: ... - def row_data(self, row: int) -> typing.Optional[Any]: ... - def set_row_data(self, row: int, value: Any) -> None: ... - def notify_row_changed(self, row: int) -> None: ... - def notify_row_removed(self, row: int, count: int) -> None: ... - def notify_row_added(self, row: int, count: int) -> None: ... - -class PyStruct(Any): ... - -class ValueType(Enum): - Void = auto() - Number = auto() - String = auto() - Bool = auto() - Model = auto() - Struct = auto() - Brush = auto() - Image = auto() - -class DiagnosticLevel(Enum): - Error = auto() - Warning = auto() - -class PyDiagnostic: - level: DiagnosticLevel - message: str - line_number: int - column_number: int - source_file: typing.Optional[str] - -class ComponentInstance: - def show(self) -> None: ... - def hide(self) -> None: ... - def invoke(self, callback_name: str, *args: Any) -> Any: ... - def invoke_global( - self, global_name: str, callback_name: str, *args: Any - ) -> Any: ... - def set_property(self, property_name: str, value: Any) -> None: ... - def get_property(self, property_name: str) -> Any: ... - def set_callback( - self, callback_name: str, callback: Callable[..., Any] - ) -> None: ... - def set_global_callback( - self, global_name: str, callback_name: str, callback: Callable[..., Any] - ) -> None: ... - def set_global_property( - self, global_name: str, property_name: str, value: Any - ) -> None: ... - def get_global_property(self, global_name: str, property_name: str) -> Any: ... - -class ComponentDefinition: - def create(self) -> ComponentInstance: ... - name: str - globals: list[str] - functions: list[str] - callbacks: list[str] - properties: dict[str, ValueType] - def global_functions(self, global_name: str) -> list[str]: ... - def global_callbacks(self, global_name: str) -> list[str]: ... - def global_properties(self, global_name: str) -> typing.Dict[str, ValueType]: ... - def callback_returns_void(self, callback_name: str) -> bool: ... - def global_callback_returns_void( - self, global_name: str, callback_name: str - ) -> bool: ... - -class CompilationResult: - component_names: list[str] - diagnostics: list[PyDiagnostic] - named_exports: list[typing.Tuple[str, str]] - structs_and_enums: typing.Tuple[typing.Dict[str, PyStruct], typing.Dict[str, Enum]] - def component(self, name: str) -> ComponentDefinition: ... - -class Compiler: - include_paths: list[os.PathLike[Any] | pathlib.Path] - library_paths: dict[str, os.PathLike[Any] | pathlib.Path] - translation_domain: str - style: str - def build_from_path( - self, path: os.PathLike[Any] | pathlib.Path - ) -> CompilationResult: ... - def build_from_source( - self, source: str, path: os.PathLike[Any] | pathlib.Path - ) -> CompilationResult: ... - -class AsyncAdapter: - def __new__( - cls, - fd: int, - ) -> "AsyncAdapter": ... - def wait_for_readable(self, callback: typing.Callable[[int], None]) -> None: ... - def wait_for_writable(self, callback: typing.Callable[[int], None]) -> None: ... diff --git a/api/python/slint/stub-gen/main.rs b/api/python/slint/stub-gen/main.rs index 7423cced56e..61b90e6ad8c 100644 --- a/api/python/slint/stub-gen/main.rs +++ b/api/python/slint/stub-gen/main.rs @@ -4,8 +4,7 @@ use pyo3_stub_gen::Result; fn main() -> Result<()> { - // `stub_info` is a function defined by `define_stub_info_gatherer!` macro. - let stub = slint_python::stub_info()?; + let stub = core::stub_info()?; stub.generate()?; Ok(()) } diff --git a/api/python/slint/stubgen.sh b/api/python/slint/stubgen.sh new file mode 100755 index 00000000000..4d4e814eb31 --- /dev/null +++ b/api/python/slint/stubgen.sh @@ -0,0 +1,11 @@ +# Copyright © SixtyFPS GmbH +# SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +export PATH="$HOME/.pyenv/bin:$PATH"; +eval "$(pyenv init -)"; +pyenv local 3.13.1; +export PYO3_PYTHON="$(pyenv which python3)"; +export LIBRARY_PATH="$HOME/.pyenv/versions/3.13.1/lib"; +export DYLD_LIBRARY_PATH="$HOME/.pyenv/versions/3.13.1/lib"; +export RUSTFLAGS="-C link-arg=-L$HOME/.pyenv/versions/3.13.1/lib -C link-arg=-lpython3.13"; +cargo run -pslint-python --bin stub-gen --features stubgen, diff --git a/api/python/slint/tests/codegen/examples/__init__.py b/api/python/slint/tests/codegen/examples/__init__.py new file mode 100644 index 00000000000..55c735ca1ff --- /dev/null +++ b/api/python/slint/tests/codegen/examples/__init__.py @@ -0,0 +1,2 @@ +# Copyright © SixtyFPS GmbH +# SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 diff --git a/api/python/slint/tests/codegen/examples/counter/README.md b/api/python/slint/tests/codegen/examples/counter/README.md new file mode 100644 index 00000000000..960316724cb --- /dev/null +++ b/api/python/slint/tests/codegen/examples/counter/README.md @@ -0,0 +1,34 @@ + + +# Counter Example Using slint.codegen + +This example mirrors the callback pattern described in the +[Slint Python binding documentation](https://github.com/slint-ui/slint/blob/master/api/python/slint/README.md#readme). +Instead of relying on the runtime auto-loader, it uses the experimental +`slint.codegen` CLI to emit static Python modules (`.py`/`.pyi`) for the +`counter.slint` UI and then subclasses the generated component. + +## Steps + +1. Generate the bindings: + + ```bash + uv run python examples/counter/generate.py + ``` + + This produces `examples/counter/counter.py` and + `examples/counter/counter.pyi` next to the + source `.slint` file, all ready for import. + +2. Run the app: + + ```bash + uv run python -m examples.counter.main + ``` + + Each click anywhere in the window increments the counter via the + `request_increase` callback implemented in Python. + +> **Tip:** The generated `.pyi` file makes the `CounterWindow` API visible to +> type checkers and IDEs, providing a smoother developer experience compared to +> using the dynamic import hook. diff --git a/api/python/slint/tests/codegen/examples/counter/__init__.py b/api/python/slint/tests/codegen/examples/counter/__init__.py new file mode 100644 index 00000000000..55c735ca1ff --- /dev/null +++ b/api/python/slint/tests/codegen/examples/counter/__init__.py @@ -0,0 +1,2 @@ +# Copyright © SixtyFPS GmbH +# SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 diff --git a/api/python/slint/tests/codegen/examples/counter/counter.py b/api/python/slint/tests/codegen/examples/counter/counter.py new file mode 100644 index 00000000000..e98f7e62fb2 --- /dev/null +++ b/api/python/slint/tests/codegen/examples/counter/counter.py @@ -0,0 +1,45 @@ +# Copyright © SixtyFPS GmbH +# SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +# Generated by slint.codegen from counter.slint +from __future__ import annotations + +import importlib.resources as _resources +import os +import types +from contextlib import nullcontext as _nullcontext +from pathlib import Path +from typing import Any + +import slint + +__all__ = ["CounterWindow"] + +_MODULE_DIR = Path(__file__).parent + +_SLINT_RESOURCE = "counter.slint" + + +def _load() -> types.SimpleNamespace: + """Load the compiled Slint module for this package.""" + package = __package__ or (__spec__.parent if __spec__ else None) + if package: + ctx = _resources.as_file(_resources.files(package).joinpath(_SLINT_RESOURCE)) + else: + ctx = _nullcontext(Path(__file__).with_name(_SLINT_RESOURCE)) + with ctx as slint_path: + include_paths: list[os.PathLike[Any] | Path] | None = [_MODULE_DIR] + library_paths: dict[str, os.PathLike[Any] | Path] | None = None + return slint.load_file( + path=slint_path, + quiet=True, + style=None, + include_paths=include_paths, + library_paths=library_paths, + translation_domain=None, + ) + + +_module = _load() + +CounterWindow = _module.CounterWindow diff --git a/api/python/slint/tests/codegen/examples/counter/counter.pyi b/api/python/slint/tests/codegen/examples/counter/counter.pyi new file mode 100644 index 00000000000..eb8b6b75ba3 --- /dev/null +++ b/api/python/slint/tests/codegen/examples/counter/counter.pyi @@ -0,0 +1,18 @@ +# Copyright © SixtyFPS GmbH +# SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +from __future__ import annotations + +from typing import ( + Any, + Callable, +) +import slint + +__all__ = ["CounterWindow"] + +class CounterWindow(slint.Component): + def __init__(self, **kwargs: Any) -> None: ... + alignment: Any + counter: int + request_increase: Callable[[], None] diff --git a/api/python/slint/tests/codegen/examples/counter/counter.slint b/api/python/slint/tests/codegen/examples/counter/counter.slint new file mode 100644 index 00000000000..668fd9ad3a4 --- /dev/null +++ b/api/python/slint/tests/codegen/examples/counter/counter.slint @@ -0,0 +1,31 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +export component CounterWindow inherits Window { + width: 240px; + height: 120px; + + in-out property counter: 0; + // text alignment + in-out property alignment: TextHorizontalAlignment.center; + callback request_increase(); + + Rectangle { + width: parent.width; + height: parent.height; + background: #2b2b2b; + border-radius: 12px; + + Text { + text: "Count: " + root.counter; + color: white; + font-size: 24px; + horizontal-alignment: center; + vertical-alignment: center; + } + + TouchArea { + clicked => root.request_increase(); + } + } +} diff --git a/api/python/slint/tests/codegen/examples/counter/generate.py b/api/python/slint/tests/codegen/examples/counter/generate.py new file mode 100644 index 00000000000..3eccee76dfb --- /dev/null +++ b/api/python/slint/tests/codegen/examples/counter/generate.py @@ -0,0 +1,29 @@ +# Copyright © SixtyFPS GmbH +# SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +from __future__ import annotations + +from pathlib import Path + +from slint.codegen.generator import generate_project +from slint.codegen.models import GenerationConfig + + +def main() -> None: + base_dir = Path(__file__).parent + config = GenerationConfig( + include_paths=[base_dir], + library_paths={}, + style=None, + translation_domain=None, + quiet=False, + ) + + generate_project( + inputs=[base_dir / "counter.slint"], output_dir=None, config=config + ) + print("Generated Python bindings next to counter.slint") + + +if __name__ == "__main__": + main() diff --git a/api/python/slint/tests/codegen/examples/counter/main.py b/api/python/slint/tests/codegen/examples/counter/main.py new file mode 100644 index 00000000000..d733d8c3c6a --- /dev/null +++ b/api/python/slint/tests/codegen/examples/counter/main.py @@ -0,0 +1,27 @@ +# Copyright © SixtyFPS GmbH +# SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +from __future__ import annotations + +import slint + +try: + from .counter import CounterWindow +except ImportError as exc: # pragma: no cover - user-facing guidance + raise SystemExit( + "Generated bindings not found. Run `python generate.py` in the " + "examples/counter directory first." + ) from exc + + +class CounterApp(CounterWindow): + @slint.callback + def request_increase(self) -> None: + self.counter += 1 + + +if __name__ == "__main__": + app = CounterApp() + app.show() + # slint.run_event_loop_blocking() + app.run() diff --git a/api/python/slint/tests/codegen/examples/counter/test.py b/api/python/slint/tests/codegen/examples/counter/test.py new file mode 100644 index 00000000000..55fa293831d --- /dev/null +++ b/api/python/slint/tests/codegen/examples/counter/test.py @@ -0,0 +1,57 @@ +# Copyright © SixtyFPS GmbH +# SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +# Generated by slint.codegen from test.slint +from __future__ import annotations + +import importlib.resources as _resources +import os +import types +from contextlib import nullcontext as _nullcontext +from pathlib import Path +from typing import Any + +import slint + +__all__ = [ + "OptionalDemo", + "OptionalFloat", + "OptionalBool", + "OptionalInt", + "OptionalString", + "OptionalEnum", +] + +_MODULE_DIR = Path(__file__).parent + +_SLINT_RESOURCE = "test.slint" + + +def _load() -> types.SimpleNamespace: + """Load the compiled Slint module for this package.""" + package = __package__ or (__spec__.parent if __spec__ else None) + if package: + ctx = _resources.as_file(_resources.files(package).joinpath(_SLINT_RESOURCE)) + else: + ctx = _nullcontext(Path(__file__).with_name(_SLINT_RESOURCE)) + with ctx as slint_path: + include_paths: list[os.PathLike[Any] | Path] | None = None + library_paths: dict[str, os.PathLike[Any] | Path] | None = None + return slint.load_file( + path=slint_path, + quiet=True, + style=None, + include_paths=include_paths, + library_paths=library_paths, + translation_domain=None, + ) + + +_module = _load() + +OptionalDemo = _module.OptionalDemo +OptionalFloat = _module.OptionalFloat +OptionalBool = _module.OptionalBool +OptionalInt = _module.OptionalInt +OptionalString = _module.OptionalString +OptionalEnum = _module.OptionalEnum diff --git a/api/python/slint/tests/codegen/examples/counter/test.pyi b/api/python/slint/tests/codegen/examples/counter/test.pyi new file mode 100644 index 00000000000..5aa34b97dba --- /dev/null +++ b/api/python/slint/tests/codegen/examples/counter/test.pyi @@ -0,0 +1,49 @@ +# Copyright © SixtyFPS GmbH +# SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +from __future__ import annotations + +import enum +from typing import ( + Any, + Callable, + Optional, +) + +import slint + +__all__ = [ + "OptionalDemo", + "OptionalFloat", + "OptionalBool", + "OptionalInt", + "OptionalString", + "OptionalEnum", +] + +class OptionalFloat: + def __init__(self, *, maybe_value: float = ...) -> None: ... + maybe_value: float + +class OptionalBool: + def __init__(self, *, maybe_value: bool = ...) -> None: ... + maybe_value: bool + +class OptionalInt: + def __init__(self, *, maybe_value: float = ...) -> None: ... + maybe_value: float + +class OptionalString: + def __init__(self, *, maybe_value: str = ...) -> None: ... + maybe_value: str + +class OptionalEnum(enum.Enum): + OptionA = "OptionA" + OptionB = "OptionB" + OptionC = "OptionC" + +class OptionalDemo(slint.Component): + def __init__(self, **kwargs: Any) -> None: ... + maybe_count: Optional[int] + on_action: Callable[[Optional[float]], Optional[int]] + compute: Callable[[Optional[str]], Optional[bool]] diff --git a/api/python/slint/tests/codegen/examples/counter/test.slint b/api/python/slint/tests/codegen/examples/counter/test.slint new file mode 100644 index 00000000000..f2df7baa304 --- /dev/null +++ b/api/python/slint/tests/codegen/examples/counter/test.slint @@ -0,0 +1,35 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +export struct OptionalInt { + maybe_value: int, +} + +export struct OptionalFloat { + maybe_value: float, +} + +export struct OptionalString { + maybe_value: string, +} + +export struct OptionalBool { + maybe_value: bool, +} + +export enum OptionalEnum { + OptionA, + OptionB, + OptionC, +} + + + +export component OptionalDemo { + in-out property maybe_count; + callback on_action(value: OptionalFloat) -> OptionalInt; + + public function compute(input: OptionalString) -> OptionalBool { + return { maybe_value: true }; + } +} \ No newline at end of file diff --git a/api/python/slint/tests/codegen/examples/struct_test.slint b/api/python/slint/tests/codegen/examples/struct_test.slint new file mode 100644 index 00000000000..e66c7803951 --- /dev/null +++ b/api/python/slint/tests/codegen/examples/struct_test.slint @@ -0,0 +1,10 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +struct Foo { + number: int, +} + +export component TestComp inherits Rectangle { + in property data; +} diff --git a/api/python/slint/tests/codegen/test_emitters_package.py b/api/python/slint/tests/codegen/test_emitters_package.py new file mode 100644 index 00000000000..4a5e3150bf5 --- /dev/null +++ b/api/python/slint/tests/codegen/test_emitters_package.py @@ -0,0 +1,254 @@ +# Copyright © SixtyFPS GmbH +# SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +import ast +import importlib +import keyword +import sys +import types +import typing +from collections.abc import Iterator +from pathlib import Path + +import pytest + + +def _read_all_symbols(path: Path) -> list[str]: + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in tree.body: + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name) and target.id == "__all__": + return typing.cast(list[str], ast.literal_eval(node.value)) + raise AssertionError(f"Missing __all__ in {path}") + + +def _collect_generated_imports(path: Path) -> list[str]: + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in tree.body: + if ( + isinstance(node, ast.ImportFrom) + and node.level == 1 + and node.module == "_generated" + ): + return [alias.name for alias in node.names] + return [] + + +@pytest.fixture() +def emitters_modules( + monkeypatch: pytest.MonkeyPatch, +) -> Iterator[tuple[types.ModuleType, types.ModuleType]]: + root = Path(__file__).resolve().parents[2] + + slint_pkg = types.ModuleType("slint") + slint_pkg.__path__ = [str(root / "slint")] + monkeypatch.setitem(sys.modules, "slint", slint_pkg) + + codegen_pkg = types.ModuleType("slint.codegen") + codegen_pkg.__path__ = [str(root / "slint" / "codegen")] + monkeypatch.setitem(sys.modules, "slint.codegen", codegen_pkg) + + api_module = types.ModuleType("slint.api") + + def _normalize_prop(name: str) -> str: + ident = name.replace("-", "_") + if ident and ident[0].isdigit(): + ident = f"_{ident}" + if keyword.iskeyword(ident): + ident = f"{ident}_" + return ident + + api_module._normalize_prop = _normalize_prop # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "slint.api", api_module) + + models = importlib.import_module("slint.codegen.models") + emitters = importlib.import_module("slint.codegen.emitters") + + module_names = ["slint.codegen.emitters", "slint.codegen.models"] + + try: + yield emitters, models + finally: + for name in module_names: + sys.modules.pop(name, None) + + +def test_write_package_emitters( + tmp_path: Path, emitters_modules: tuple[types.ModuleType, types.ModuleType] +) -> None: + emitters, models = emitters_modules + + artifacts = models.ModuleArtifacts( + components=[ + models.ComponentMeta( + name="AppWindow", + py_name="AppWindow", + properties=[], + callbacks=[], + functions=[], + globals=[], + ), + models.ComponentMeta( + name="OtherComponent", + py_name="OtherComponent", + properties=[], + callbacks=[], + functions=[], + globals=[], + ), + ], + structs=[ + models.StructMeta( + name="Config", + py_name="ConfigStruct", + fields=[], + is_builtin=False, + ) + ], + enums=[ + models.EnumMeta( + name="Choice", + py_name="ChoiceEnum", + values=[ + models.EnumValueMeta( + name="First", + py_name="First", + value="first", + ) + ], + is_builtin=False, + is_used=True, + ), + models.EnumMeta( + name="BuiltinEnum", + py_name="BuiltinEnum", + values=[], + is_builtin=True, + is_used=True, + ), + ], + named_exports=[ + ("AppWindow", "WindowAlias"), + ("Config", "ConfigAlias"), + ("BuiltinEnum", "BuiltinAlias"), + ("OtherComponent", "other-component"), + ("Unknown", "UnknownAlias"), + ("Choice", "ChoiceAlias"), + ], + resource_paths=[], + ) + + package_dir = tmp_path / "generated" + package_dir.mkdir() + + emitters.write_package_init( + package_dir / "__init__.py", + source_relative="ui/app.slint", + artifacts=artifacts, + ) + emitters.write_package_init_stub(package_dir / "__init__.pyi", artifacts=artifacts) + emitters.write_package_enums( + package_dir / "enums.py", + source_relative="ui/app.slint", + artifacts=artifacts, + ) + emitters.write_package_enums_stub(package_dir / "enums.pyi", artifacts=artifacts) + emitters.write_package_structs( + package_dir / "structs.py", + source_relative="ui/app.slint", + artifacts=artifacts, + ) + emitters.write_package_structs_stub( + package_dir / "structs.pyi", artifacts=artifacts + ) + + init_py = (package_dir / "__init__.py").read_text(encoding="utf-8") + assert init_py.startswith("# Generated by slint.codegen from ui/app.slint") + assert "from . import enums, structs" in init_py + assert "BuiltinAlias" not in init_py + assert "UnknownAlias" not in init_py + assert "WindowAlias = AppWindow" in init_py + assert "ConfigAlias = ConfigStruct" in init_py + assert "other_component = OtherComponent" in init_py + assert "ChoiceAlias = ChoiceEnum" in init_py + assert _collect_generated_imports(package_dir / "__init__.py") == [ + "AppWindow", + "OtherComponent", + "ConfigStruct", + "ChoiceEnum", + ] + assert _read_all_symbols(package_dir / "__init__.py") == [ + "AppWindow", + "OtherComponent", + "ConfigStruct", + "ChoiceEnum", + "WindowAlias", + "ConfigAlias", + "other_component", + "ChoiceAlias", + "enums", + "structs", + ] + + init_pyi = (package_dir / "__init__.pyi").read_text(encoding="utf-8") + assert init_pyi.startswith("from __future__ import annotations") + assert "# Generated by" not in init_pyi + assert "BuiltinAlias" not in init_pyi + assert "UnknownAlias" not in init_pyi + assert _collect_generated_imports(package_dir / "__init__.pyi") == [ + "AppWindow", + "OtherComponent", + "ConfigStruct", + "ChoiceEnum", + ] + assert _read_all_symbols(package_dir / "__init__.pyi") == [ + "AppWindow", + "OtherComponent", + "ConfigStruct", + "ChoiceEnum", + "WindowAlias", + "ConfigAlias", + "other_component", + "ChoiceAlias", + "enums", + "structs", + ] + + enums_py = (package_dir / "enums.py").read_text(encoding="utf-8") + assert enums_py.startswith("# Generated by slint.codegen from ui/app.slint") + assert "BuiltinAlias" not in enums_py + assert "ChoiceAlias = ChoiceEnum" in enums_py + assert _collect_generated_imports(package_dir / "enums.py") == ["ChoiceEnum"] + assert _read_all_symbols(package_dir / "enums.py") == [ + "ChoiceEnum", + "ChoiceAlias", + ] + + enums_pyi = (package_dir / "enums.pyi").read_text(encoding="utf-8") + assert enums_pyi.startswith("from __future__ import annotations") + assert "# Generated by" not in enums_pyi + assert "BuiltinAlias" not in enums_pyi + assert _collect_generated_imports(package_dir / "enums.pyi") == ["ChoiceEnum"] + assert _read_all_symbols(package_dir / "enums.pyi") == [ + "ChoiceEnum", + "ChoiceAlias", + ] + + structs_py = (package_dir / "structs.py").read_text(encoding="utf-8") + assert structs_py.startswith("# Generated by slint.codegen from ui/app.slint") + assert "ConfigAlias = ConfigStruct" in structs_py + assert _collect_generated_imports(package_dir / "structs.py") == ["ConfigStruct"] + assert _read_all_symbols(package_dir / "structs.py") == [ + "ConfigStruct", + "ConfigAlias", + ] + + structs_pyi = (package_dir / "structs.pyi").read_text(encoding="utf-8") + assert structs_pyi.startswith("from __future__ import annotations") + assert "# Generated by" not in structs_pyi + assert _collect_generated_imports(package_dir / "structs.pyi") == ["ConfigStruct"] + assert _read_all_symbols(package_dir / "structs.pyi") == [ + "ConfigStruct", + "ConfigAlias", + ] diff --git a/api/python/slint/tests/codegen/test_generator.py b/api/python/slint/tests/codegen/test_generator.py new file mode 100644 index 00000000000..796b4af090f --- /dev/null +++ b/api/python/slint/tests/codegen/test_generator.py @@ -0,0 +1,282 @@ +# Copyright © SixtyFPS GmbH +# SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +from __future__ import annotations + +import importlib +import importlib.util +import inspect +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Any + +import pytest +from slint.codegen.cli import _parse_library_paths +from slint.codegen.cli import main as cli_main +from slint.codegen.generator import generate_project +from slint.codegen.models import GenerationConfig + + +def _write_slint_fixture(target_dir: Path) -> Path: + source = inspect.cleandoc( + """ + export struct Config { + value: int, + label: string, + } + + export enum Choice { + first, + second, + } + + export global SharedLogic { + out property total: 0; + callback notify(int); + } + + export component AppWindow inherits Window { + width: 160px; + height: 80px; + in-out property counter: SharedLogic.total; + in-out property alignment: TextHorizontalAlignment.center; + callback activated(int); + + public function reset() -> int { + SharedLogic.notify(counter); + return counter; + } + + Text { + text: counter; + horizontal-alignment: alignment; + } + } + """ + ) + + slint_dir = target_dir / "ui" + slint_dir.mkdir(parents=True, exist_ok=True) + slint_file = slint_dir / "app.slint" + slint_file.write_text(source + "\n", encoding="utf-8") + return slint_file + + +def _write_optional_fixture(target_dir: Path) -> Path: + import inspect + + source = inspect.cleandoc( + """ + export struct OptionalInt := { + maybe_value: int, + } + + export struct OptionalFloat := { + maybe_value: float, + } + + export struct OptionalString := { + maybe_value: string, + } + + export struct OptionalBool := { + maybe_value: bool, + } + + export component OptionalDemo { + in-out property maybe_count; + callback on_action(value: OptionalFloat) -> OptionalInt; + + public function compute(input: OptionalString) -> OptionalBool { + return { maybe_value: true }; + } + } + """ + ) + + slint_dir = target_dir / "optional" + slint_dir.mkdir(parents=True, exist_ok=True) + slint_file = slint_dir / "optional.slint" + slint_file.write_text(source + "\n", encoding="utf-8") + return slint_file + + +def _load_module(module_path: Path) -> Any: + spec = importlib.util.spec_from_file_location("generated_module", module_path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules.pop("generated_module", None) + spec.loader.exec_module(module) + return module + + +def test_generate_project_creates_runtime_and_stub(tmp_path: Path) -> None: + slint_file = _write_slint_fixture(tmp_path) + output_dir = tmp_path / "generated" + config = GenerationConfig( + include_paths=[slint_file.parent], + library_paths={}, + style=None, + translation_domain=None, + quiet=True, + ) + + generate_project(inputs=[slint_file], output_dir=output_dir, config=config) + + py_file = output_dir / "app.py" + pyi_file = output_dir / "app.pyi" + slint_copy = output_dir / "app.slint" + + assert py_file.exists() + assert pyi_file.exists() + assert slint_copy.exists() + + runtime_src = py_file.read_text(encoding="utf-8") + assert "Generated by slint.codegen" in runtime_src + assert "AppWindow = _module.AppWindow" in runtime_src + assert "Config = _module.Config" in runtime_src + assert "Choice = _module.Choice" in runtime_src + + stub_src = pyi_file.read_text(encoding="utf-8") + assert "class AppWindow" in stub_src + assert "class Config" in stub_src + assert "class Choice" in stub_src + assert "class SharedLogic" in stub_src + assert "counter: int" in stub_src + assert "TextHorizontalAlignment" not in stub_src + assert "alignment: Any" in stub_src + assert "activated: Callable[[int], None]" in stub_src + assert "reset: Callable[[], int]" in stub_src + assert "notify: Callable[[int], None]" in stub_src + assert "from typing import" in stub_src + assert "Any" in stub_src + assert "Callable" in stub_src + assert "def __init__(self, *, " in stub_src + assert "value:" in stub_src + assert "label:" in stub_src + + module = _load_module(py_file) + assert hasattr(module, "AppWindow") + instance = module.AppWindow() + assert hasattr(instance, "show") + assert instance.reset() == 0 + alignment = instance.alignment + alignment_type = alignment.__class__ + assert alignment_type.__name__ == "TextHorizontalAlignment" + assert alignment == alignment_type.center + + config_struct = module.Config(value=5, label="demo") + assert config_struct.value == 5 + + +def test_cli_generate(tmp_path: Path) -> None: + slint_file = _write_slint_fixture(tmp_path) + output_dir = tmp_path / "out" + + cmd = [ + sys.executable, + "-m", + "slint.codegen", + "generate", + "--input", + str(slint_file), + "--output", + str(output_dir), + "--quiet", + ] + subprocess.run(cmd, check=True) + + assert (output_dir / "app.py").exists() + assert (output_dir / "app.pyi").exists() + + +def test_generate_optional_type_hints(tmp_path: Path) -> None: + slint_file = _write_optional_fixture(tmp_path) + output_dir = tmp_path / "optional-generated" + config = GenerationConfig( + include_paths=[slint_file.parent], + library_paths={}, + style=None, + translation_domain=None, + quiet=True, + ) + + generate_project(inputs=[slint_file], output_dir=output_dir, config=config) + + stub_src = (output_dir / "optional.pyi").read_text(encoding="utf-8") + assert "maybe_count: Optional[int]" in stub_src + assert "on_action: Callable[[Optional[float]], Optional[int]]" in stub_src + assert "compute: Callable[[Optional[str]], Optional[bool]]" in stub_src + assert "from typing import" in stub_src + assert "Any" in stub_src + assert "Callable" in stub_src + assert "Optional" in stub_src + + +def test_cli_main_without_subcommand(tmp_path: Path) -> None: + slint_file = _write_slint_fixture(tmp_path) + exit_code = cli_main( + [ + "--input", + str(slint_file), + "--quiet", + ] + ) + + assert exit_code == 0 + assert (slint_file.parent / "app.py").exists() + + +def test_counter_example_workflow(tmp_path: Path) -> None: + project_root = Path(__file__).resolve().parent + example_src = project_root / "examples" / "counter" + example_copy = tmp_path / "examples" / "counter" + shutil.copytree(example_src, example_copy) + + subprocess.run([sys.executable, "generate.py"], cwd=example_copy, check=True) + generated_py = example_copy / "counter.py" + assert generated_py.exists() + package_init = tmp_path / "examples" / "__init__.py" + package_init.parent.mkdir(parents=True, exist_ok=True) + package_init.touch() + + sys.path.insert(0, str(tmp_path)) + try: + for name in [ + "examples.counter.main", + "examples.counter.counter", + "examples.counter", + "examples", + ]: + sys.modules.pop(name, None) + assert importlib.util.find_spec("examples.counter.counter") is not None + module = importlib.import_module("examples.counter.main") + finally: + sys.path.pop(0) + + app = module.CounterApp() + assert app.counter == 0 + app.request_increase() + assert app.counter == 1 + + +def test_parse_library_paths_and_error_handling() -> None: + mapping = _parse_library_paths(["std=path/to/std"]) + assert mapping == {"std": Path("path/to/std")} + + with pytest.raises(SystemExit): + _parse_library_paths(["missing_delimiter"]) + + +def test_generate_project_requires_sources(tmp_path: Path) -> None: + config = GenerationConfig( + include_paths=[], + library_paths={}, + style=None, + translation_domain=None, + quiet=True, + ) + with pytest.raises(SystemExit): + generate_project(inputs=[tmp_path], output_dir=tmp_path / "o", config=config) diff --git a/api/python/slint/tests/test-load-file.slint b/api/python/slint/tests/test-load-file-source.slint similarity index 95% rename from api/python/slint/tests/test-load-file.slint rename to api/python/slint/tests/test-load-file-source.slint index 7f9c37be962..3a442937c19 100644 --- a/api/python/slint/tests/test-load-file.slint +++ b/api/python/slint/tests/test-load-file-source.slint @@ -57,6 +57,7 @@ export component App inherits Window { in-out property enum-property: TestEnum.Variant2; in-out property <[TestEnum]> model-with-enums: [TestEnum.Variant2, TestEnum.Variant1]; + in-out property builtin-enum: TextHorizontalAlignment.left; Rectangle { color: red; diff --git a/api/python/slint/tests/test_async.py b/api/python/slint/tests/test_async.py index 90260f826fa..116b9780927 100644 --- a/api/python/slint/tests/test_async.py +++ b/api/python/slint/tests/test_async.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 import slint -from slint import slint as native +from slint import core import asyncio import typing import aiohttp @@ -166,11 +166,11 @@ async def run_server_and_client(exception_check: typing.List[Exception]) -> None def test_loop_close_while_main_future_runs() -> None: def q() -> None: - native.quit_event_loop() + core.quit_event_loop() async def never_quit() -> None: loop = asyncio.get_running_loop() - # Call native.quit_event_loop() directly as if the user closed the last window. We should gracefully + # Call core.quit_event_loop() directly as if the user closed the last window. We should gracefully # handle that the future that this function represents isn't terminated. loop.call_later(0.1, q) while True: diff --git a/api/python/slint/tests/test_brush.py b/api/python/slint/tests/test_brush.py index bf0e668d971..caa4356a0b9 100644 --- a/api/python/slint/tests/test_brush.py +++ b/api/python/slint/tests/test_brush.py @@ -22,8 +22,7 @@ def test_col_from_str() -> None: def test_col_from_rgb_dict() -> None: - coldict = {"red": 0x12, "green": 0x34, "blue": 0x56} - col = Color(coldict) + col = Color({"red": 0x12, "green": 0x34, "blue": 0x56}) assert col.red == 0x12 assert col.green == 0x34 assert col.blue == 0x56 @@ -31,8 +30,7 @@ def test_col_from_rgb_dict() -> None: def test_col_from_rgba_dict() -> None: - coldict = {"red": 0x12, "green": 0x34, "blue": 0x56, "alpha": 128} - col = Color(coldict) + col = Color({"red": 0x12, "green": 0x34, "blue": 0x56, "alpha": 128}) assert col.red == 0x12 assert col.green == 0x34 assert col.blue == 0x56 diff --git a/api/python/slint/tests/test_callback_decorators.py b/api/python/slint/tests/test_callback_decorators.py index 43fd4c1d96a..896d29f6999 100644 --- a/api/python/slint/tests/test_callback_decorators.py +++ b/api/python/slint/tests/test_callback_decorators.py @@ -17,7 +17,7 @@ def base_dir() -> Path: def test_callback_decorators(caplog: pytest.LogCaptureFixture) -> None: - module = load_file(base_dir() / "test-load-file.slint", quiet=False) + module = load_file(base_dir() / "test-load-file-source.slint", quiet=False) class SubClass(module.App): # type: ignore @slint.callback() @@ -40,7 +40,7 @@ def global_callback(self, arg: str) -> str: def test_callback_decorators_async() -> None: - module = load_file(base_dir() / "test-load-file.slint", quiet=False) + module = load_file(base_dir() / "test-load-file-source.slint", quiet=False) class SubClass(module.App): # type: ignore def __init__(self, in_queue: asyncio.Queue[int], out_queue: asyncio.Queue[int]): @@ -68,7 +68,7 @@ async def main( def test_callback_decorators_async_err() -> None: - module = load_file(base_dir() / "test-load-file.slint", quiet=False) + module = load_file(base_dir() / "test-load-file-source.slint", quiet=False) class SubClass(module.App): # type: ignore def __init__(self) -> None: diff --git a/api/python/slint/tests/test_compiler.py b/api/python/slint/tests/test_compiler.py index 93114899a83..d6f32700f84 100644 --- a/api/python/slint/tests/test_compiler.py +++ b/api/python/slint/tests/test_compiler.py @@ -1,13 +1,13 @@ # Copyright © SixtyFPS GmbH # SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 -from slint import slint as native -from slint.slint import ValueType +from slint import core +from slint.core import ValueType from pathlib import Path def test_basic_compiler() -> None: - compiler = native.Compiler() + compiler = core.Compiler() assert compiler.include_paths == [] compiler.include_paths = [Path("testing")] @@ -64,9 +64,11 @@ def test_basic_compiler() -> None: assert compdef.globals == ["TestGlobal"] assert compdef.global_properties("Garbage") is None - assert [ - (name, type) for name, type in compdef.global_properties("TestGlobal").items() - ] == [("theglobalprop", ValueType.String)] + test_global_prop = compdef.global_properties("TestGlobal") + assert test_global_prop is not None + assert [(name, type) for name, type in test_global_prop.items()] == [ + ("theglobalprop", ValueType.String) + ] assert compdef.global_callbacks("Garbage") is None assert compdef.global_callbacks("TestGlobal") == ["globallogic"] @@ -79,7 +81,7 @@ def test_basic_compiler() -> None: def test_compiler_build_from_path() -> None: - compiler = native.Compiler() + compiler = core.Compiler() result = compiler.build_from_path(Path("Nonexistent.slint")) assert len(result.component_names) == 0 @@ -87,5 +89,5 @@ def test_compiler_build_from_path() -> None: diags = result.diagnostics assert len(diags) == 1 - assert diags[0].level == native.DiagnosticLevel.Error + assert diags[0].level == core.DiagnosticLevel.Error assert diags[0].message.startswith("Could not load Nonexistent.slint:") diff --git a/api/python/slint/tests/test_enums.py b/api/python/slint/tests/test_enums.py index 904107f58d3..6d21bbaadb1 100644 --- a/api/python/slint/tests/test_enums.py +++ b/api/python/slint/tests/test_enums.py @@ -1,21 +1,57 @@ # Copyright © SixtyFPS GmbH # SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 -import pytest -from slint import load_file, ListModel +from __future__ import annotations + +import importlib.abc +import importlib.util +import sys +import inspect from pathlib import Path +from types import ModuleType + +import pytest +from slint import ListModel, core +from slint.codegen.generator import generate_project +from slint.codegen.models import GenerationConfig + + +def _slint_source() -> Path: + return Path(__file__).with_name("test-load-file-source.slint") + + +@pytest.fixture +def generated_module(tmp_path: Path) -> ModuleType: + slint_file = _slint_source() + output_dir = tmp_path / "generated" + config = GenerationConfig( + include_paths=[slint_file.parent], + library_paths={}, + style=None, + translation_domain=None, + quiet=True, + ) + generate_project(inputs=[slint_file], output_dir=output_dir, config=config) + + module_path = output_dir / "test_load_file_source.py" + assert module_path.exists() + spec = importlib.util.spec_from_file_location( + "generated_test_load_file", module_path + ) + assert spec and spec.loader -def base_dir() -> Path: - origin = __spec__.origin - assert origin is not None - base_dir = Path(origin).parent - assert base_dir is not None - return base_dir + sys.modules.pop(spec.name, None) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + loader = spec.loader + assert isinstance(loader, importlib.abc.Loader) + loader.exec_module(module) + return module -def test_enums() -> None: - module = load_file(base_dir() / "test-load-file.slint", quiet=False) +def test_enums(generated_module: ModuleType) -> None: + module = generated_module TestEnum = module.TestEnum @@ -40,10 +76,128 @@ def test_enums() -> None: assert model_with_enums[0] == TestEnum.Variant2 assert model_with_enums[1] == TestEnum.Variant1 assert model_with_enums[0].__class__ is TestEnum - model_with_enums = None + model_with_enums = None # allow GC to drop reference instance.model_with_enums = ListModel([TestEnum.Variant1, TestEnum.Variant2]) assert len(instance.model_with_enums) == 2 assert instance.model_with_enums[0] == TestEnum.Variant1 assert instance.model_with_enums[1] == TestEnum.Variant2 assert instance.model_with_enums[0].__class__ is TestEnum + del instance + + +def test_builtin_enum_property_roundtrip() -> None: + compiler = core.Compiler() + result = compiler.build_from_source( + """ + export component Test { + in-out property horizontal: TextHorizontalAlignment.left; + in-out property vertical: TextVerticalAlignment.top; + Text { + horizontal-alignment: root.horizontal; + vertical-alignment: root.vertical; + } + } + """, + Path(""), + ) + + comp = result.component("Test") + assert comp is not None + + _, enums = result.structs_and_enums + assert "TextHorizontalAlignment" not in enums + assert "TextVerticalAlignment" not in enums + + instance = comp.create() + assert instance is not None + + horizontal = instance.get_property("horizontal") + vertical = instance.get_property("vertical") + + horizontal_cls = horizontal.__class__ + vertical_cls = vertical.__class__ + + assert horizontal_cls.__name__ == "TextHorizontalAlignment" + assert vertical_cls.__name__ == "TextVerticalAlignment" + assert horizontal == horizontal_cls.left + assert vertical == vertical_cls.top + + instance.set_property("horizontal", horizontal_cls.right) + instance.set_property("vertical", vertical_cls.bottom) + + assert instance.get_property("horizontal") == horizontal_cls.right + assert instance.get_property("vertical") == vertical_cls.bottom + + +def test_builtin_enum_keyword_variants_have_safe_names() -> None: + compiler = core.Compiler() + result = compiler.build_from_source( + """ + export component Test { + in-out property role: AccessibleRole.none; + in-out property button_role: DialogButtonRole.none; + } + """, + Path(""), + ) + + _, enums = result.structs_and_enums + assert "AccessibleRole" not in enums + assert "DialogButtonRole" not in enums + + comp = result.component("Test") + assert comp is not None + instance = comp.create() + assert instance is not None + + for prop_name in ("role", "button_role"): + enum_value = instance.get_property(prop_name) + enum_cls = enum_value.__class__ + members = enum_cls.__members__ + assert "none" in members + assert members["none"].value == "none" + + +def test_user_enum_exported_and_builtin_hidden() -> None: + source = inspect.cleandoc( + """ + export struct Custom { + value: int, + } + + export enum CustomEnum { + first, + second, + } + + export global Data { + in-out property custom; + } + + export component Test inherits Window { + in-out property data <=> Data.custom; + in-out property mode; + callback pointer_event(event: PointerEvent); + width: 100px; + height: 100px; + TouchArea { } + } + """ + ) + + compiler = core.Compiler() + result = compiler.build_from_source(source, Path("")) + + structs, enums = result.structs_and_enums + assert "CustomEnum" in enums + assert "PointerEventButton" not in enums + + component = result.component("Test") + assert component is not None + instance = component.create() + assert instance is not None + + CustomEnum = enums["CustomEnum"] + instance.set_property("mode", CustomEnum.second) + assert instance.get_property("mode") == CustomEnum.second diff --git a/api/python/slint/tests/test_gc.py b/api/python/slint/tests/test_gc.py index f7ce7451cf1..a333c527b6e 100644 --- a/api/python/slint/tests/test_gc.py +++ b/api/python/slint/tests/test_gc.py @@ -1,7 +1,7 @@ # Copyright © SixtyFPS GmbH # SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 -from slint import slint as native +from slint import core import slint import weakref import gc @@ -10,7 +10,7 @@ def test_callback_gc() -> None: - compiler = native.Compiler() + compiler = core.Compiler() compdef = compiler.build_from_source( """ @@ -23,11 +23,11 @@ def test_callback_gc() -> None: ).component("Test") assert compdef is not None - instance: native.ComponentInstance | None = compdef.create() + instance: core.ComponentInstance | None = compdef.create() assert instance is not None class Handler: - def __init__(self, instance: native.ComponentInstance) -> None: + def __init__(self, instance: core.ComponentInstance) -> None: self.instance = instance def python_callback(self, input: str) -> str: @@ -48,7 +48,7 @@ def python_callback(self, input: str) -> str: def test_struct_gc() -> None: - compiler = native.Compiler() + compiler = core.Compiler() compdef = compiler.build_from_source( """ @@ -63,7 +63,7 @@ def test_struct_gc() -> None: ).component("Test") assert compdef is not None - instance: native.ComponentInstance | None = compdef.create() + instance: core.ComponentInstance | None = compdef.create() assert instance is not None model: typing.Optional[slint.ListModel[int]] = slint.ListModel([1, 2, 3]) @@ -79,7 +79,7 @@ def test_struct_gc() -> None: def test_properties_gc() -> None: - compiler = native.Compiler() + compiler = core.Compiler() compdef = compiler.build_from_source( """ @@ -91,7 +91,7 @@ def test_properties_gc() -> None: ).component("Test") assert compdef is not None - instance: native.ComponentInstance | None = compdef.create() + instance: core.ComponentInstance | None = compdef.create() assert instance is not None model: typing.Optional[slint.ListModel[int]] = slint.ListModel([1, 2, 3]) diff --git a/api/python/slint/tests/test_instance.py b/api/python/slint/tests/test_instance.py index e1087929f24..042a34a2130 100644 --- a/api/python/slint/tests/test_instance.py +++ b/api/python/slint/tests/test_instance.py @@ -2,14 +2,14 @@ # SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 import pytest -from slint import slint as native -from slint.slint import Image, Color, Brush +from slint import core +from slint.core import Image, Color, Brush import os from pathlib import Path def test_property_access() -> None: - compiler = native.Compiler() + compiler = core.Compiler() compdef = compiler.build_from_source( """ @@ -78,7 +78,7 @@ def test_property_access() -> None: instance.set_property("boolprop", 0) structval = instance.get_property("structprop") - assert isinstance(structval, native.PyStruct) + assert isinstance(structval, core.PyStruct) assert structval.title == "builtin" assert structval.finished assert structval.dash_prop @@ -139,7 +139,7 @@ def test_property_access() -> None: def test_callbacks() -> None: - compiler = native.Compiler() + compiler = core.Compiler() compdef = compiler.build_from_source( """ diff --git a/api/python/slint/tests/test_invoke_from_event_loop.py b/api/python/slint/tests/test_invoke_from_event_loop.py index 4f021c8950e..5aa716f9bd6 100644 --- a/api/python/slint/tests/test_invoke_from_event_loop.py +++ b/api/python/slint/tests/test_invoke_from_event_loop.py @@ -1,7 +1,7 @@ # Copyright © SixtyFPS GmbH # SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 -from slint import slint as native +from slint import core import threading from datetime import timedelta @@ -15,18 +15,18 @@ def test_threads() -> None: def invoked_from_event_loop() -> None: global was_here was_here = True - native.quit_event_loop() + core.quit_event_loop() def quit() -> None: - native.invoke_from_event_loop(invoked_from_event_loop) + core.invoke_from_event_loop(invoked_from_event_loop) thr = threading.Thread(target=quit) - native.Timer.single_shot(timedelta(milliseconds=10), lambda: thr.start()) - fallback_timer = native.Timer() + core.Timer.single_shot(timedelta(milliseconds=10), lambda: thr.start()) + fallback_timer = core.Timer() fallback_timer.start( - native.TimerMode.Repeated, timedelta(milliseconds=100), native.quit_event_loop + core.TimerMode.Repeated, timedelta(milliseconds=100), core.quit_event_loop ) - native.run_event_loop() + core.run_event_loop() thr.join() fallback_timer.stop() assert was_here diff --git a/api/python/slint/tests/test_load_file.py b/api/python/slint/tests/test_load_file.py index 26e603a73b0..453203fd3e0 100644 --- a/api/python/slint/tests/test_load_file.py +++ b/api/python/slint/tests/test_load_file.py @@ -1,10 +1,11 @@ # Copyright © SixtyFPS GmbH # SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 -import pytest -from slint import load_file, CompileError from pathlib import Path +import pytest +from slint import CompileError, load_file + def base_dir() -> Path: origin = __spec__.origin @@ -15,21 +16,24 @@ def base_dir() -> Path: def test_load_file(caplog: pytest.LogCaptureFixture) -> None: - module = load_file(base_dir() / "test-load-file.slint", quiet=False) + module = load_file(base_dir() / "test-load-file-source.slint", quiet=False) assert ( "The property 'color' has been deprecated. Please use 'background' instead" in caplog.text ) - assert len(list(module.__dict__.keys())) == 7 - assert "App" in module.__dict__ - assert "Diag" in module.__dict__ - assert "MyDiag" in module.__dict__ - assert "MyData" in module.__dict__ - assert "Secret_Struct" in module.__dict__ - assert "Public_Struct" in module.__dict__ - assert "TestEnum" in module.__dict__ + module_keys = set(module.__dict__.keys()) + expected_keys = { + "App", + "Diag", + "MyDiag", + "MyData", + "Secret_Struct", + "Public_Struct", + "TestEnum", + } + assert expected_keys.issubset(module_keys) instance = module.App() del instance instance = module.MyDiag() @@ -71,7 +75,7 @@ def test_compile_error() -> None: def test_load_file_wrapper() -> None: - module = load_file(base_dir() / "test-load-file.slint", quiet=False) + module = load_file(base_dir() / "test-load-file-source.slint", quiet=False) instance = module.App() @@ -94,7 +98,7 @@ def test_load_file_wrapper() -> None: def test_constructor_kwargs() -> None: - module = load_file(base_dir() / "test-load-file.slint", quiet=False) + module = load_file(base_dir() / "test-load-file-source.slint", quiet=False) def early_say_hello(arg: str) -> str: return "early:" + arg diff --git a/api/python/slint/tests/test_load_file_module.py b/api/python/slint/tests/test_load_file_module.py new file mode 100644 index 00000000000..43920ca92e4 --- /dev/null +++ b/api/python/slint/tests/test_load_file_module.py @@ -0,0 +1,84 @@ +# Copyright © SixtyFPS GmbH +# SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +from __future__ import annotations + +from importlib import import_module, reload +from types import ModuleType + + +def _module() -> ModuleType: + """ + Return a fresh instance of the generated module for each call. + + Using a dynamic import keeps mypy from requiring type information for the + generated module, while runtime callers still get the reloaded module. + """ + module = import_module("test_load_file_source") + return reload(module) + + +def test_codegen_module_exports() -> None: + module: ModuleType = _module() + + expected_exports = { + "App", + "Diag", + "MyDiag", + "MyData", + "Secret_Struct", + "Public_Struct", + "TestEnum", + } + assert expected_exports.issubset(set(module.__all__)) + + assert module.MyDiag is module.Diag + assert module.Public_Struct is module.Secret_Struct + + test_enum = module.TestEnum + assert test_enum.Variant1.name == "Variant1" + + instance = module.App() + del instance + + struct_instance = module.MyData() + struct_instance.name = "Test" + struct_instance.age = 42 + + struct_instance = module.MyData(name="testing") + assert struct_instance.name == "testing" + + +def test_generated_module_wrapper() -> None: + module: ModuleType = _module() + + instance = module.App() + + assert instance.hello == "World" + instance.hello = "Ok" + assert instance.hello == "Ok" + + instance.say_hello = lambda x: "from here: " + x + assert instance.say_hello("wohoo") == "from here: wohoo" + + assert instance.plus_one(42) == 43 + + assert instance.MyGlobal.global_prop == "This is global" + assert instance.MyGlobal.minus_one(100) == 99 + assert instance.SecondGlobal.second == "second" + + del instance + + +def test_constructor_kwargs() -> None: + module: ModuleType = _module() + + def early_say_hello(arg: str) -> str: + return "early:" + arg + + instance = module.App(hello="Set early", say_hello=early_say_hello) + + assert instance.hello == "Set early" + assert instance.invoke_say_hello("test") == "early:test" + + del instance diff --git a/api/python/slint/tests/test_load_file_source.py b/api/python/slint/tests/test_load_file_source.py new file mode 100644 index 00000000000..76c8ee9e44e --- /dev/null +++ b/api/python/slint/tests/test_load_file_source.py @@ -0,0 +1,59 @@ +# Copyright © SixtyFPS GmbH +# SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +# Generated by slint.codegen from test-load-file-source.slint +from __future__ import annotations + +import importlib.resources as _resources +import os +import types +from contextlib import nullcontext as _nullcontext +from pathlib import Path +from typing import Any + +import slint + +__all__ = [ + "Diag", + "App", + "MyData", + "Secret_Struct", + "TestEnum", + "MyDiag", + "Public_Struct", +] + +_MODULE_DIR = Path(__file__).parent + +_SLINT_RESOURCE = "test-load-file-source.slint" + + +def _load() -> types.SimpleNamespace: + """Load the compiled Slint module for this package.""" + package = __package__ or (__spec__.parent if __spec__ else None) + if package: + ctx = _resources.as_file(_resources.files(package).joinpath(_SLINT_RESOURCE)) + else: + ctx = _nullcontext(Path(__file__).with_name(_SLINT_RESOURCE)) + with ctx as slint_path: + include_paths: list[os.PathLike[Any] | Path] | None = None + library_paths: dict[str, os.PathLike[Any] | Path] | None = None + return slint.load_file( + path=slint_path, + quiet=True, + style=None, + include_paths=include_paths, + library_paths=library_paths, + translation_domain=None, + ) + + +_module = _load() + +Diag = _module.Diag +App = _module.App +MyData = _module.MyData +Secret_Struct = _module.Secret_Struct +TestEnum = _module.TestEnum +MyDiag = Diag +Public_Struct = Secret_Struct diff --git a/api/python/slint/tests/test_load_file_source.pyi b/api/python/slint/tests/test_load_file_source.pyi new file mode 100644 index 00000000000..3b8efc73e45 --- /dev/null +++ b/api/python/slint/tests/test_load_file_source.pyi @@ -0,0 +1,71 @@ +# Copyright © SixtyFPS GmbH +# SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +from __future__ import annotations + +import enum +from typing import ( + Any, + Callable, +) +import slint + +__all__ = [ + "Diag", + "App", + "MyData", + "Secret_Struct", + "TestEnum", + "MyDiag", + "Public_Struct", +] + +class MyData: + def __init__(self, *, age: float = ..., name: str = ...) -> None: ... + age: float + name: str + +class Secret_Struct: + def __init__(self, *, balance: float = ...) -> None: ... + balance: float + +class TestEnum(enum.Enum): + Variant1 = "Variant1" + Variant2 = "Variant2" + +class Diag(slint.Component): + def __init__(self, **kwargs: Any) -> None: ... + class MyGlobal: + global_prop: str + global_callback: Callable[[str], str] + minus_one: Callable[[int], None] + + class SecondGlobal: + second: str + +class App(slint.Component): + def __init__(self, **kwargs: Any) -> None: ... + builtin_enum: Any + enum_property: TestEnum + hello: str + model_with_enums: slint.ListModel[Any] + translated: str + call_void: Callable[[], None] + invoke_call_void: Callable[[], None] + invoke_global_callback: Callable[[str], str] + invoke_say_hello: Callable[[str], str] + invoke_say_hello_again: Callable[[str], str] + say_hello: Callable[[str], str] + say_hello_again: Callable[[str], str] + plus_one: Callable[[int], None] + class MyGlobal: + global_prop: str + global_callback: Callable[[str], str] + minus_one: Callable[[int], None] + + class SecondGlobal: + second: str + +MyDiag = Diag + +Public_Struct = Secret_Struct diff --git a/api/python/slint/tests/test_loader.py b/api/python/slint/tests/test_loader.py index bacd14671ef..af9edad591f 100644 --- a/api/python/slint/tests/test_loader.py +++ b/api/python/slint/tests/test_loader.py @@ -7,7 +7,7 @@ def test_magic_import() -> None: - instance = loader.test_load_file.App() + instance = loader.test_load_file_source.App() del instance diff --git a/api/python/slint/tests/test_loop.py b/api/python/slint/tests/test_loop.py index 603aea7a389..ff5bd46c0f6 100644 --- a/api/python/slint/tests/test_loop.py +++ b/api/python/slint/tests/test_loop.py @@ -1,12 +1,14 @@ # Copyright © SixtyFPS GmbH # SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 -import slint -from slint import slint as native from datetime import timedelta -import pytest import sys +import pytest + +import slint +from slint import core + def test_sysexit_exception() -> None: def call_sys_exit() -> None: @@ -14,7 +16,7 @@ def call_sys_exit() -> None: slint.Timer.single_shot(timedelta(milliseconds=100), call_sys_exit) with pytest.raises(SystemExit) as exc_info: - native.run_event_loop() + core.run_event_loop() assert ( "unexpected failure running python singleshot timer callback" in exc_info.value.__notes__ diff --git a/api/python/slint/tests/test_models.py b/api/python/slint/tests/test_models.py index 7b3c2e7a60f..651d0bf5c7c 100644 --- a/api/python/slint/tests/test_models.py +++ b/api/python/slint/tests/test_models.py @@ -1,14 +1,14 @@ # Copyright © SixtyFPS GmbH # SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 -from slint import slint as native +from slint import core from slint import models as models import typing from pathlib import Path def test_model_notify() -> None: - compiler = native.Compiler() + compiler = core.Compiler() compdef = compiler.build_from_source( """ @@ -55,7 +55,7 @@ def test_model_notify() -> None: def test_model_from_list() -> None: - compiler = native.Compiler() + compiler = core.Compiler() compdef = compiler.build_from_source( """ @@ -103,7 +103,7 @@ def test_generator(max: int) -> typing.Iterator[int]: def test_rust_model_sequence() -> None: - compiler = native.Compiler() + compiler = core.Compiler() compdef = compiler.build_from_source( """ @@ -126,7 +126,7 @@ def test_rust_model_sequence() -> None: def test_model_writeback() -> None: - compiler = native.Compiler() + compiler = core.Compiler() compdef = compiler.build_from_source( """ diff --git a/api/python/slint/tests/test_sigint.py b/api/python/slint/tests/test_sigint.py new file mode 100644 index 00000000000..dc0fc1ef386 --- /dev/null +++ b/api/python/slint/tests/test_sigint.py @@ -0,0 +1,25 @@ +# Copyright © SixtyFPS GmbH +# SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +import signal +import threading +import time + +import pytest + +import slint + + +def test_run_event_loop_handles_sigint() -> None: + def trigger_sigint() -> None: + # Allow the event loop to start before raising the signal. + time.sleep(0.1) + signal.raise_signal(signal.SIGINT) + + sender = threading.Thread(target=trigger_sigint) + sender.start() + try: + with pytest.raises(KeyboardInterrupt): + slint.run_event_loop() + finally: + sender.join() diff --git a/api/python/slint/tests/test_structs.py b/api/python/slint/tests/test_structs.py new file mode 100644 index 00000000000..2110299a797 --- /dev/null +++ b/api/python/slint/tests/test_structs.py @@ -0,0 +1,130 @@ +# Copyright © SixtyFPS GmbH +# SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +from __future__ import annotations + +import importlib.util +import importlib.abc +import inspect +import sys +from types import ModuleType +from pathlib import Path + +import pytest + +import slint.core as core +from slint.codegen.generator import generate_project +from slint.codegen.models import GenerationConfig + + +def test_builtin_enum_class_helper_not_available() -> None: + assert not hasattr(core, "built_in_enum_class") + with pytest.raises(AttributeError): + getattr(core, "built_in_enum_class") + + +def test_builtin_struct_factory_not_available() -> None: + assert not hasattr(core, "built_in_struct_factory") + with pytest.raises(AttributeError): + getattr(core, "built_in_struct_factory") + + +def test_user_structs_exported_and_builtin_hidden() -> None: + source = inspect.cleandoc( + """ + export struct Custom { + value: int, + } + + export enum CustomEnum { + first, + second, + } + + export global Data { + in-out property custom; + } + + export component Test inherits Window { + in-out property data <=> Data.custom; + in-out property mode; + callback pointer_event(event: PointerEvent); + width: 100px; + height: 100px; + TouchArea { } + } + """ + ) + + compiler = core.Compiler() + result = compiler.build_from_source(source, Path("")) + + structs, enums = result.structs_and_enums + assert set(structs.keys()) == {"Custom"} + assert "PointerEvent" not in structs + assert set(enums.keys()) == {"CustomEnum"} + + custom_struct_proto = structs["Custom"] + assert hasattr(custom_struct_proto, "value") + + component = result.component("Test") + assert component is not None + instance = component.create() + assert instance is not None + + instance.set_property("data", {"value": 99}) + data = instance.get_property("data") + assert hasattr(data, "value") + assert data.value == 99 + + CustomEnum = enums["CustomEnum"] + instance.set_property("mode", CustomEnum.second) + assert instance.get_property("mode") == CustomEnum.second + + +@pytest.fixture +def generated_struct_module(tmp_path: Path) -> ModuleType: + slint_file = Path(__file__).with_name("test-load-file-source.slint") + output_dir = tmp_path / "generated" + config = GenerationConfig( + include_paths=[slint_file.parent], + library_paths={}, + style=None, + translation_domain=None, + quiet=True, + ) + + generate_project(inputs=[slint_file], output_dir=output_dir, config=config) + + module_path = output_dir / "test_load_file_source.py" + spec = importlib.util.spec_from_file_location("generated_structs", module_path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules.pop(spec.name, None) + sys.modules[spec.name] = module + loader = spec.loader + assert isinstance(loader, importlib.abc.Loader) + loader.exec_module(module) + return module + + +def test_struct_accepts_keywords_only( + generated_struct_module: ModuleType, +) -> None: + MyData = generated_struct_module.MyData + + with pytest.raises(TypeError, match="keyword arguments only"): + MyData("foo", 42) + + instance = MyData(name="foo", age=42) + assert instance.name == "foo" + assert instance.age == 42 + + +def test_struct_rejects_unknown_keywords( + generated_struct_module: ModuleType, +) -> None: + MyData = generated_struct_module.MyData + + with pytest.raises(TypeError, match="unexpected keyword"): # noqa: PT012 + MyData(name="foo", age=1, extra=True) diff --git a/api/python/slint/tests/test_timers.py b/api/python/slint/tests/test_timers.py index 08441e59192..d0b06265b51 100644 --- a/api/python/slint/tests/test_timers.py +++ b/api/python/slint/tests/test_timers.py @@ -1,7 +1,7 @@ # Copyright © SixtyFPS GmbH # SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 -from slint import slint as native +from slint import core from datetime import timedelta counter: int @@ -15,19 +15,19 @@ def quit_after_two_invocations() -> None: global counter counter = min(counter + 1, 2) if counter == 2: - native.quit_event_loop() + core.quit_event_loop() - test_timer = native.Timer() + test_timer = core.Timer() test_timer.start( - native.TimerMode.Repeated, + core.TimerMode.Repeated, timedelta(milliseconds=100), quit_after_two_invocations, ) - native.run_event_loop() + core.run_event_loop() test_timer.stop() assert counter == 2 def test_single_shot() -> None: - native.Timer.single_shot(timedelta(milliseconds=100), native.quit_event_loop) - native.run_event_loop() + core.Timer.single_shot(timedelta(milliseconds=100), core.quit_event_loop) + core.run_event_loop() diff --git a/api/python/slint/tests/test_translations.py b/api/python/slint/tests/test_translations.py index 6f0831e60b2..01015377053 100644 --- a/api/python/slint/tests/test_translations.py +++ b/api/python/slint/tests/test_translations.py @@ -26,7 +26,7 @@ def pgettext(self, context: str, message: str) -> str: def test_load_file() -> None: - module = load_file(base_dir() / "test-load-file.slint") + module = load_file(base_dir() / "test-load-file-source.slint") testcase = module.App() diff --git a/api/python/slint/value.rs b/api/python/slint/value.rs index d152fa2ff77..279cb941336 100644 --- a/api/python/slint/value.rs +++ b/api/python/slint/value.rs @@ -1,6 +1,7 @@ // Copyright © SixtyFPS GmbH // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 +use pyo3::sync::PyOnceLock; use pyo3::types::PyDict; use pyo3::{prelude::*, PyVisit}; use pyo3::{IntoPyObjectExt, PyTraverseError}; @@ -113,8 +114,10 @@ pub struct PyStruct { pub type_collection: TypeCollection, } +#[gen_stub_pymethods] #[pymethods] impl PyStruct { + #[gen_stub(override_return_type(type_repr = "typing.Any", imports = ("typing",)))] fn __getattr__(&self, key: &str) -> PyResult { self.data.get_field(key).map_or_else( || { @@ -148,6 +151,7 @@ impl PyStruct { self.clone() } + #[gen_stub(skip)] fn __traverse__(&self, visit: PyVisit<'_>) -> Result<(), PyTraverseError> { traverse_struct(&self.data, &visit) } @@ -173,6 +177,7 @@ impl PyStructFieldIterator { slf } + #[gen_stub(override_return_type(type_repr = "typing.Any", imports = ("typing",)))] fn __next__(mut slf: PyRefMut<'_, Self>) -> Option<(String, SlintToPyValue)> { slf.inner.next().map(|(name, val)| (name, slf.type_collection.to_py_value(val))) } @@ -182,6 +187,73 @@ thread_local! { static ENUM_CLASS: OnceCell> = OnceCell::new(); } +struct EnumClassInfo { + class: Py, + is_builtin: bool, +} + +static BUILTIN_ENUM_CLASSES: PyOnceLock>> = PyOnceLock::new(); + +macro_rules! generate_enum_support { + ($( + $(#[$enum_attr:meta])* + enum $Name:ident { + $( + $(#[$value_attr:meta])* + $Value:ident, + )* + } + )*) => { + fn build_built_in_enums( + enum_base: &Bound<'_, PyAny>, + ) -> PyResult>> { + let mut enum_classes = HashMap::new(); + + $( + { + let name = stringify!($Name); + let variants = vec![ + $( + { + let value = i_slint_core::items::$Name::$Value.to_string(); + (stringify!($Value).to_ascii_lowercase(), value) + }, + )* + ]; + + let cls = enum_base.call((name, variants), None)?; + enum_classes.insert(name.to_string(), cls.unbind()); + } + )* + + Ok(enum_classes) + } + }; +} + +i_slint_common::for_each_enums!(generate_enum_support); + +fn ensure_builtin_enums_initialized(py: Python<'_>) -> PyResult<()> { + if BUILTIN_ENUM_CLASSES.get(py).is_some() { + return Ok(()); + } + + let enum_base = enum_class(py).into_bound(py); + let classes = build_built_in_enums(&enum_base)?; + + let _ = BUILTIN_ENUM_CLASSES.set(py, classes); + Ok(()) +} + +fn built_in_enum_classes(py: Python<'_>) -> HashMap> { + ensure_builtin_enums_initialized(py).expect("failed to initialize built-in enums"); + + BUILTIN_ENUM_CLASSES + .get(py) + .map(|map| map.iter().map(|(name, class)| (name.clone(), class.clone_ref(py))).collect()) + .unwrap_or_default() +} + pub fn enum_class(py: Python) -> Py { ENUM_CLASS.with(|cls| { cls.get_or_init(|| -> Py { @@ -197,15 +269,18 @@ pub fn enum_class(py: Python) -> Py { /// a `.slint` file loaded with load_file. This is used to map enums /// provided by Slint to the correct python enum classes. pub struct TypeCollection { - enum_classes: Rc>>, + enum_classes: Rc>, } impl TypeCollection { pub fn new(result: &slint_interpreter::CompilationResult, py: Python<'_>) -> Self { - let mut enum_classes = HashMap::new(); - let enum_ctor = crate::value::enum_class(py); + let mut enum_classes: HashMap = built_in_enum_classes(py) + .into_iter() + .map(|(name, class)| (name, EnumClassInfo { class, is_builtin: true })) + .collect(); + for struct_or_enum in result.structs_and_enums(i_slint_core::InternalToken {}) { match struct_or_enum { Type::Enumeration(en) => { @@ -226,7 +301,10 @@ impl TypeCollection { ) .unwrap(); - enum_classes.insert(en.name.to_string(), enum_type); + enum_classes.insert( + en.name.to_string(), + EnumClassInfo { class: enum_type, is_builtin: en.node.is_none() }, + ); } _ => {} } @@ -255,7 +333,7 @@ impl TypeCollection { "Slint provided enum {enum_name} is unknown" )) })?; - enum_cls.getattr(py, enum_value) + enum_cls.class.getattr(py, enum_value) } pub fn model_to_py( @@ -265,8 +343,10 @@ impl TypeCollection { crate::models::ReadOnlyRustModel { model: model.clone(), type_collection: self.clone() } } - pub fn enums(&self) -> impl Iterator)> { - self.enum_classes.iter() + pub fn enums(&self) -> impl Iterator)> + '_ { + self.enum_classes + .iter() + .filter_map(|(name, info)| (!info.is_builtin).then_some((name, &info.class))) } pub fn slint_value_from_py_value( diff --git a/internal/compiler/build.rs b/internal/compiler/build.rs index a8cbb96b2e5..a1c09697348 100644 --- a/internal/compiler/build.rs +++ b/internal/compiler/build.rs @@ -1,6 +1,7 @@ // Copyright © SixtyFPS GmbH // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 +use std::ffi::OsStr; use std::io::{BufWriter, Write}; use std::path::{Path, PathBuf}; @@ -40,11 +41,53 @@ fn widget_library() -> &'static [(&'static str, &'static BuiltinDirectory<'stati writeln!(file, "]\n}}")?; file.flush()?; - println!("cargo:rustc-env=SLINT_WIDGETS_LIBRARY={}", output_file_path.display()); + let stable_library_path = copy_to_stable_location(&cargo_manifest_dir, &output_file_path)?; + + println!("cargo:rerun-if-env-changed=CARGO_TARGET_DIR"); + println!("cargo:rustc-env=SLINT_WIDGETS_LIBRARY={}", stable_library_path.display()); Ok(()) } +fn copy_to_stable_location( + cargo_manifest_dir: &Path, + generated_file: &Path, +) -> std::io::Result { + let out_dir = generated_file.parent().expect("generated file to have a parent directory"); + + let workspace_dir = + std::env::var_os("CARGO_WORKSPACE_DIR").map(PathBuf::from).unwrap_or_else(|| { + cargo_manifest_dir + .ancestors() + .nth(1) + .map(PathBuf::from) + .unwrap_or_else(|| cargo_manifest_dir.to_path_buf()) + }); + + let target_dir = std::env::var_os("CARGO_TARGET_DIR") + .map(PathBuf::from) + .or_else(|| { + out_dir.ancestors().find_map(|ancestor| { + (ancestor.file_name() == Some(OsStr::new("target"))).then(|| ancestor.to_path_buf()) + }) + }) + .unwrap_or_else(|| workspace_dir.join("target")); + + let target_triple = std::env::var("TARGET") + .or_else(|_| std::env::var("HOST")) + .unwrap_or_else(|_| "host".into()); + let profile = std::env::var("PROFILE").unwrap_or_else(|_| "debug".into()); + + let stable_dir = target_dir.join("slint-widget-cache").join(target_triple).join(profile); + std::fs::create_dir_all(&stable_dir)?; + + let stable_path = stable_dir.join("included_library.rs"); + let contents = std::fs::read(generated_file)?; + std::fs::write(&stable_path, contents)?; + + Ok(stable_path) +} + fn process_style(cargo_manifest_dir: &Path, path: &Path) -> std::io::Result { let library_files: Vec = cargo_manifest_dir .join(path) diff --git a/internal/interpreter/api.rs b/internal/interpreter/api.rs index 1b1de962a85..7344c6f0bae 100644 --- a/internal/interpreter/api.rs +++ b/internal/interpreter/api.rs @@ -826,6 +826,7 @@ impl Compiler { return CompilationResult { components: HashMap::new(), diagnostics: diagnostics.into_iter().collect(), + dependencies: Vec::new(), #[cfg(feature = "internal")] structs_and_enums: Vec::new(), #[cfg(feature = "internal")] @@ -864,6 +865,7 @@ impl Compiler { pub struct CompilationResult { pub(crate) components: HashMap, pub(crate) diagnostics: Vec, + pub(crate) dependencies: Vec, #[cfg(feature = "internal")] pub(crate) structs_and_enums: Vec, /// For `export { Foo as Bar }` this vec contains tuples of (`Foo`, `Bar`) @@ -876,6 +878,7 @@ impl core::fmt::Debug for CompilationResult { f.debug_struct("CompilationResult") .field("components", &self.components.keys()) .field("diagnostics", &self.diagnostics) + .field("dependencies", &self.dependencies) .finish() } } @@ -920,6 +923,11 @@ impl CompilationResult { self.components.get(name).cloned() } + /// Returns an iterator over the file dependencies that were used during compilation. + pub fn dependencies(&self) -> impl Iterator { + self.dependencies.iter() + } + /// This is an internal function without API stability guarantees. #[doc(hidden)] #[cfg(feature = "internal")] diff --git a/internal/interpreter/dynamic_item_tree.rs b/internal/interpreter/dynamic_item_tree.rs index 0a5d24e5586..7df37e67cd3 100644 --- a/internal/interpreter/dynamic_item_tree.rs +++ b/internal/interpreter/dynamic_item_tree.rs @@ -39,9 +39,9 @@ use i_slint_core::{Brush, Color, Property, SharedString, SharedVector}; use itertools::Either; use once_cell::unsync::{Lazy, OnceCell}; use smol_str::{SmolStr, ToSmolStr}; -use std::collections::BTreeMap; -use std::collections::HashMap; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::num::NonZeroU32; +use std::path::{Path, PathBuf}; use std::rc::Weak; use std::{pin::Pin, rc::Rc}; @@ -884,6 +884,7 @@ pub async fn load( return CompilationResult { components: HashMap::new(), diagnostics: diag.into_iter().collect(), + dependencies: Vec::new(), #[cfg(feature = "internal")] structs_and_enums: Vec::new(), #[cfg(feature = "internal")] @@ -896,7 +897,12 @@ pub async fn load( #[cfg(feature = "internal-highlight")] let raw_type_loader = raw_type_loader.map(Rc::new); - let doc = loader.get_document(&path).unwrap(); + #[cfg(feature = "internal-highlight")] + let loader_ref: &i_slint_compiler::typeloader::TypeLoader = loader.as_ref(); + #[cfg(not(feature = "internal-highlight"))] + let loader_ref: &i_slint_compiler::typeloader::TypeLoader = &loader; + + let doc = loader_ref.get_document(&path).unwrap(); let compiled_globals = Rc::new(CompiledGlobalCollection::compile(doc)); let mut components = HashMap::new(); @@ -965,6 +971,7 @@ pub async fn load( CompilationResult { diagnostics: diag.into_iter().collect(), components, + dependencies: gather_dependency_paths(loader_ref, &path, doc), #[cfg(feature = "internal")] structs_and_enums, #[cfg(feature = "internal")] @@ -972,6 +979,32 @@ pub async fn load( } } +fn gather_dependency_paths( + loader: &i_slint_compiler::typeloader::TypeLoader, + root_path: &PathBuf, + root_document: &object_tree::Document, +) -> Vec { + let mut collected = BTreeSet::new(); + collected.insert(root_path.clone()); + collect_document_dependencies(loader, root_document, &mut collected); + collected.into_iter().collect() +} + +fn collect_document_dependencies( + loader: &i_slint_compiler::typeloader::TypeLoader, + document: &object_tree::Document, + visited: &mut BTreeSet, +) { + for import in &document.imports { + let path = i_slint_compiler::pathutils::clean_path(Path::new(&import.file)); + if visited.insert(path.clone()) { + if let Some(dep_doc) = loader.get_document(&path) { + collect_document_dependencies(loader, dep_doc, visited); + } + } + } +} + fn generate_rtti() -> HashMap<&'static str, Rc> { let mut rtti = HashMap::new(); use i_slint_core::items::*;