diff --git a/api/python/README.md b/api/python/README.md index a089ea336d0..c980a4f6d24 100644 --- a/api/python/README.md +++ b/api/python/README.md @@ -229,7 +229,7 @@ The types used for properties in the Slint Language each translate to specific t | `physical_length` | `float` | | | `duration` | `float` | The number of milliseconds | | `angle` | `float` | The angle in degrees | -| structure | `dict` | Structures are mapped to Python dictionaries where each structure field is an item. | +| structure | `dict`/`Struct` | When reading, structures are mapped to data classes, when writing dicts are also accepted. | | array | `slint.Model` | | ### Arrays and Models diff --git a/api/python/lib.rs b/api/python/lib.rs index cc9747fdfe4..65c9ee1e9fd 100644 --- a/api/python/lib.rs +++ b/api/python/lib.rs @@ -41,6 +41,7 @@ fn slint(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { 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/__init__.py b/api/python/slint/__init__.py index 7027113a805..ba52f8d5b86 100644 --- a/api/python/slint/__init__.py +++ b/api/python/slint/__init__.py @@ -178,11 +178,15 @@ def call(*args): for global_name in compdef.globals: global_class = _build_global_class(compdef, global_name) - def global_getter(self): - wrapper = global_class() - setattr(wrapper, "__instance__", self.__instance__) - return wrapper - properties_and_callbacks[global_name] = property(global_getter) + def mk_global(global_class): + def global_getter(self): + 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) @@ -277,3 +281,4 @@ def callback(global_name=None, name=None): Model = models.Model Timer = native.Timer TimerMode = native.TimerMode +Struct = native.PyStruct diff --git a/api/python/tests/test_instance.py b/api/python/tests/test_instance.py index 5ce0ec14ca0..6341c16e8f4 100644 --- a/api/python/tests/test_instance.py +++ b/api/python/tests/test_instance.py @@ -22,6 +22,7 @@ def test_property_access(): export struct MyStruct { title: string, finished: bool, + dash-prop: bool, } export component Test { @@ -36,6 +37,7 @@ def test_property_access(): in property structprop: { title: "builtin", finished: true, + dash-prop: true, }; in property imageprop: @image-url("../../../examples/printerdemo/ui/images/cat.jpg"); @@ -75,11 +77,16 @@ def test_property_access(): instance.set_property("boolprop", 0) structval = instance.get_property("structprop") - assert isinstance(structval, dict) - assert structval == {'title': 'builtin', 'finished': True} - instance.set_property("structprop", {'title': 'new', 'finished': False}) - assert instance.get_property("structprop") == { - 'title': 'new', 'finished': False} + assert isinstance(structval, native.PyStruct) + assert structval.title == "builtin" + assert structval.finished == True + assert structval.dash_prop == True + instance.set_property( + "structprop", {'title': 'new', 'finished': False, 'dash_prop': False}) + structval = instance.get_property("structprop") + assert structval.title == "new" + assert structval.finished == False + assert structval.dash_prop == False imageval = instance.get_property("imageprop") assert imageval.width == 320 diff --git a/api/python/tests/test_load_file.py b/api/python/tests/test_load_file.py index 1a59c60f884..83697646588 100644 --- a/api/python/tests/test_load_file.py +++ b/api/python/tests/test_load_file.py @@ -45,6 +45,8 @@ def test_load_file_wrapper(): assert instance.MyGlobal.minus_one(100) == 99 + assert instance.SecondGlobal.second == "second" + del instance diff --git a/api/python/tests/test_load_file.slint b/api/python/tests/test_load_file.slint index 961bc4f7baa..549c06f96b1 100644 --- a/api/python/tests/test_load_file.slint +++ b/api/python/tests/test_load_file.slint @@ -9,6 +9,10 @@ export global MyGlobal { } } +export global SecondGlobal { + out property second: "second"; +} + export component App inherits Window { in-out property hello: "World"; callback say-hello(string) -> string; diff --git a/api/python/value.rs b/api/python/value.rs index 08fb8cc59d2..035a85b54e3 100644 --- a/api/python/value.rs +++ b/api/python/value.rs @@ -2,7 +2,9 @@ // 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::types::{IntoPyDict, PyDict}; +use pyo3::types::PyDict; + +use std::collections::HashMap; pub struct PyValue(pub slint_interpreter::Value); struct PyValueRef<'a>(&'a slint_interpreter::Value); @@ -41,11 +43,9 @@ impl<'a> ToPyObject for PyValueRef<'a> { crate::models::PyModelShared::rust_into_js_model(model) .unwrap_or_else(|| crate::models::ReadOnlyRustModel::from(model).into_py(py)) } - slint_interpreter::Value::Struct(structval) => structval - .iter() - .map(|(name, val)| (name.to_string().into_py(py), PyValueRef(val).into_py(py))) - .into_py_dict_bound(py) - .into_py(py), + slint_interpreter::Value::Struct(structval) => { + PyStruct { data: structval.clone() }.into_py(py) + } slint_interpreter::Value::Brush(brush) => { crate::brush::PyBrush::from(brush.clone()).into_py(py) } @@ -90,13 +90,18 @@ impl FromPyObject<'_> for PyValue { ob.extract::>() .map(|rustmodel| slint_interpreter::Value::Model(rustmodel.0.clone())) }) + .or_else(|_| { + ob.extract::>().and_then(|pystruct| { + Ok(slint_interpreter::Value::Struct(pystruct.data.clone())) + }) + }) .or_else(|_| { ob.extract::<&PyDict>().and_then(|dict| { let dict_items: Result, PyErr> = dict .iter() .map(|(name, pyval)| { let name = name.extract::<&str>()?.to_string(); - let slintval: PyValue = pyval.extract()?; + let slintval = PyValue::extract(pyval)?; Ok((name, slintval.0)) }) .collect::, PyErr>>(); @@ -114,3 +119,64 @@ impl From for PyValue { Self(value) } } + +#[pyclass(subclass, unsendable)] +#[derive(Clone, Default)] +pub struct PyStruct { + data: slint_interpreter::Struct, +} + +#[pymethods] +impl PyStruct { + #[new] + fn new() -> Self { + Default::default() + } + + fn __getattr__(&self, key: &str) -> PyResult { + self.data.get_field(key).map_or_else( + || { + Err(pyo3::exceptions::PyAttributeError::new_err(format!( + "Python: No such field {key} on PyStruct" + ))) + }, + |value| Ok(value.clone().into()), + ) + } + fn __setattr__(&mut self, py: Python<'_>, key: String, value: PyObject) -> PyResult<()> { + let pv: PyValue = value.extract(py)?; + self.data.set_field(key, pv.0); + Ok(()) + } + + fn __iter__(slf: PyRef<'_, Self>) -> PyStructFieldIterator { + PyStructFieldIterator { + inner: slf + .data + .iter() + .map(|(name, val)| (name.to_string(), val.clone())) + .collect::>() + .into_iter(), + } + } + + fn __copy__(&self) -> Self { + self.clone() + } +} + +#[pyclass(unsendable)] +struct PyStructFieldIterator { + inner: std::collections::hash_map::IntoIter, +} + +#[pymethods] +impl PyStructFieldIterator { + fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { + slf + } + + fn __next__(mut slf: PyRefMut<'_, Self>) -> Option<(String, PyValue)> { + slf.inner.next().map(|(name, val)| (name, PyValue(val))) + } +} diff --git a/examples/memory/main.py b/examples/memory/main.py index cfd47838406..60bc3129bba 100644 --- a/examples/memory/main.py +++ b/examples/memory/main.py @@ -6,6 +6,7 @@ import os import random import itertools +import copy import slint from slint import Color, ListModel, Timer, TimerMode @@ -14,31 +15,32 @@ class MainWindow(slint.loader.memory.MainWindow): def __init__(self): super().__init__() initial_tiles = self.memory_tiles - tiles = ListModel(itertools.chain(initial_tiles, initial_tiles)) + tiles = ListModel(itertools.chain( + map(copy.copy, initial_tiles), map(copy.copy, initial_tiles))) random.shuffle(tiles) self.memory_tiles = tiles @slint.callback def check_if_pair_solved(self): - flipped_tiles = [(index, tile) for index, tile in enumerate( - self.memory_tiles) if tile["image-visible"] and not tile["solved"]] + flipped_tiles = [(index, copy.copy(tile)) for index, tile in enumerate( + self.memory_tiles) if tile.image_visible and not tile.solved] if len(flipped_tiles) == 2: tile1_index, tile1 = flipped_tiles[0] tile2_index, tile2 = flipped_tiles[1] - is_pair_solved = tile1["image"].path == tile2["image"].path + is_pair_solved = tile1.image.path == tile2.image.path if is_pair_solved: - tile1["solved"] = True + tile1.solved = True self.memory_tiles[tile1_index] = tile1 - tile2["solved"] = True + tile2.solved = True self.memory_tiles[tile2_index] = tile2 else: self.disable_tiles = True def reenable_tiles(): self.disable_tiles = False - tile1["image-visible"] = False + tile1.image_visible = False self.memory_tiles[tile1_index] = tile1 - tile2["image-visible"] = False + tile2.image_visible = False self.memory_tiles[tile2_index] = tile2 Timer.single_shot(timedelta(seconds=1), reenable_tiles) diff --git a/examples/printerdemo/python/main.py b/examples/printerdemo/python/main.py index 32759282604..7a5fd7143cf 100644 --- a/examples/printerdemo/python/main.py +++ b/examples/printerdemo/python/main.py @@ -5,6 +5,7 @@ import slint from datetime import timedelta, datetime import os +import copy import sys sys.path.append(os.path.join(os.path.dirname(__file__), "..")) @@ -48,13 +49,13 @@ def cancel_job(self, index): def update_jobs(self): if len(self.printer_queue) <= 0: return - top_item = self.printer_queue[0] - top_item["progress"] += 1 - if top_item["progress"] >= 100: + top_item = copy.copy(self.printer_queue[0]) + top_item.progress += 1 + if top_item.progress >= 100: del self.printer_queue[0] if len(self.printer_queue) == 0: return - top_item = self.printer_queue[0] + top_item = copy.copy(self.printer_queue[0]) self.printer_queue[0] = top_item