diff --git a/api/python/lib.rs b/api/python/lib.rs index 989fb1aae..954602d7d 100644 --- a/api/python/lib.rs +++ b/api/python/lib.rs @@ -6,6 +6,7 @@ mod interpreter; use interpreter::{ComponentCompiler, PyDiagnostic, PyDiagnosticLevel, PyValueType}; mod brush; mod errors; +mod models; mod timer; mod value; @@ -38,6 +39,7 @@ fn slint(_py: Python<'_>, m: &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/models.rs b/api/python/models.rs new file mode 100644 index 000000000..8060199a0 --- /dev/null +++ b/api/python/models.rs @@ -0,0 +1,204 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-1.1 OR LicenseRef-Slint-commercial + +use std::cell::RefCell; +use std::rc::Rc; + +use i_slint_core::model::{Model, ModelNotify, ModelRc}; + +use pyo3::exceptions::PyIndexError; +use pyo3::gc::PyVisit; +use pyo3::prelude::*; +use pyo3::PyTraverseError; + +use crate::value::PyValue; + +pub struct PyModelShared { + notify: ModelNotify, + self_ref: RefCell>, +} + +#[derive(Clone)] +#[pyclass(unsendable, weakref, subclass)] +pub struct PyModelBase { + inner: Rc, +} + +impl PyModelBase { + pub fn as_model(&self) -> ModelRc { + self.inner.clone().into() + } +} + +#[pymethods] +impl PyModelBase { + #[new] + fn new() -> Self { + Self { + inner: Rc::new(PyModelShared { + notify: Default::default(), + self_ref: RefCell::new(None), + }), + } + } + + fn init_self(&self, self_ref: PyObject) { + *self.inner.self_ref.borrow_mut() = Some(self_ref); + } + + fn notify_row_added(&self, index: usize, count: usize) { + self.inner.notify.row_added(index, count) + } + + fn notify_row_changed(&self, index: usize) { + self.inner.notify.row_changed(index) + } + + fn notify_row_removed(&self, index: usize, count: usize) { + self.inner.notify.row_removed(index, count) + } + + fn __traverse__(&self, visit: PyVisit<'_>) -> Result<(), PyTraverseError> { + if let Some(this) = self.inner.self_ref.borrow().as_ref() { + visit.call(this)?; + } + Ok(()) + } + + fn __clear__(&mut self) { + *self.inner.self_ref.borrow_mut() = None; + } +} + +impl i_slint_core::model::Model for PyModelShared { + type Data = slint_interpreter::Value; + + fn row_count(&self) -> usize { + Python::with_gil(|py| { + let obj = self.self_ref.borrow(); + let Some(obj) = obj.as_ref() else { + eprintln!("Python: Model implementation is lacking self object (in row_count)"); + return 0; + }; + let result = match obj.call_method0(py, "row_count") { + Ok(result) => result, + Err(err) => { + eprintln!( + "Python: Model implementation of row_count() threw an exception: {}", + err + ); + return 0; + } + }; + + match result.extract::(py) { + Ok(count) => count, + Err(err) => { + eprintln!("Python: Model implementation of row_count() returned value that cannot be cast to usize: {}", err); + 0 + } + } + }) + } + + fn row_data(&self, row: usize) -> Option { + Python::with_gil(|py| { + let obj = self.self_ref.borrow(); + let Some(obj) = obj.as_ref() else { + eprintln!("Python: Model implementation is lacking self object (in row_data)"); + return None; + }; + + let result = match obj.call_method1(py, "row_data", (row,)) { + Ok(result) => result, + Err(err) if err.is_instance_of::(py) => return None, + Err(err) => { + eprintln!( + "Python: Model implementation of row_data() threw an exception: {}", + err + ); + return None; + } + }; + + match result.extract::(py) { + Ok(pv) => Some(pv.0), + Err(err) => { + eprintln!("Python: Model implementation of row_data() returned value that cannot be converted to Rust: {}", err); + None + } + } + }) + } + + fn model_tracker(&self) -> &dyn i_slint_core::model::ModelTracker { + &self.notify + } + + fn as_any(&self) -> &dyn core::any::Any { + self + } +} + +impl PyModelShared { + pub fn rust_into_js_model(model: &ModelRc) -> Option { + model + .as_any() + .downcast_ref::() + .and_then(|rust_model| rust_model.self_ref.borrow().clone()) + } +} + +#[pyclass(unsendable)] +pub struct ReadOnlyRustModel(pub ModelRc); + +#[pymethods] +impl ReadOnlyRustModel { + fn row_count(&self) -> usize { + self.0.row_count() + } + + fn row_data(&self, row: usize) -> Option { + self.0.row_data(row).map(|value| value.into()) + } + + fn __len__(&self) -> usize { + self.row_count() + } + + fn __iter__(slf: PyRef<'_, Self>) -> ReadOnlyRustModelIterator { + ReadOnlyRustModelIterator { model: slf.0.clone(), row: 0 } + } + + fn __getitem__(&self, index: usize) -> Option { + self.row_data(index) + } +} + +impl From<&ModelRc> for ReadOnlyRustModel { + fn from(model: &ModelRc) -> Self { + Self(model.clone()) + } +} + +#[pyclass(unsendable)] +struct ReadOnlyRustModelIterator { + model: ModelRc, + row: usize, +} + +#[pymethods] +impl ReadOnlyRustModelIterator { + fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { + slf + } + + fn __next__(&mut self) -> Option { + if self.row >= self.model.row_count() { + return None; + } + let row = self.row; + self.row += 1; + self.model.row_data(row).map(|value| value.into()) + } +} diff --git a/api/python/slint/__init__.py b/api/python/slint/__init__.py index e86ad3f34..bdf08b947 100644 --- a/api/python/slint/__init__.py +++ b/api/python/slint/__init__.py @@ -14,3 +14,4 @@ def load_file(path): Image = native.PyImage Color = native.PyColor Brush = native.PyBrush +Model = native.PyModelBase diff --git a/api/python/slint/models.py b/api/python/slint/models.py new file mode 100644 index 000000000..bfbd42383 --- /dev/null +++ b/api/python/slint/models.py @@ -0,0 +1,71 @@ +# Copyright © SixtyFPS GmbH +# SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-1.1 OR LicenseRef-Slint-commercial + +from . import slint as native + + +class Model(native.PyModelBase): + def __new__(cls, *args): + return super().__new__(cls) + + def __init__(self, lst=None): + self.init_self(self) + + def __len__(self): + return self.row_count() + + def __getitem__(self, index): + return self.row_data(index) + + def __setitem__(self, index, value): + self.set_row_data(index, value) + + def __iter__(self): + return ModelIterator(self) + + +class ListModel(Model): + def __init__(self, lst=None): + super().__init__() + self.list = lst or [] + + def row_count(self): + return len(self.list) + + def row_data(self, row): + return self.list[row] + + def set_row_data(self, row, data): + self.list[row] = data + super().notify_row_changed(row) + + def __delitem__(self, key): + if isinstance(key, slice): + start, stop, step = key.indices(len(self.list)) + del self.list[key] + count = len(range(start, stop, step)) + super().notify_row_removed(start, count) + else: + del self.list[key] + super().notify_row_removed(key, 1) + + def append(self, value): + index = len(self.list) + self.list.append(value) + super().notify_row_added(index, 1) + + +class ModelIterator: + def __init__(self, model): + self.model = model + self.index = 0 + + def __iter__(self): + return self + + def __next__(self): + if self.index >= self.model.row_count(): + raise StopIteration() + index = self.index + self.index += 1 + return self.model.row_data(index) diff --git a/api/python/tests/test_models.py b/api/python/tests/test_models.py new file mode 100644 index 000000000..a3bcd4793 --- /dev/null +++ b/api/python/tests/test_models.py @@ -0,0 +1,103 @@ +# Copyright © SixtyFPS GmbH +# SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-1.1 OR LicenseRef-Slint-commercial + +from slint import slint as native +from slint import models as models + + +def test_model_notify(): + compiler = native.ComponentCompiler() + + compdef = compiler.build_from_source(""" + export component App { + width: 300px; + height: 300px; + + out property layout-height: layout.height; + in-out property<[length]> fixed-height-model; + + VerticalLayout { + alignment: start; + + layout := VerticalLayout { + for fixed-height in fixed-height-model: Rectangle { + background: blue; + height: fixed-height; + } + } + } + + } + """, "") + assert compdef != None + + instance = compdef.create() + assert instance != None + + model = models.ListModel([100, 0]) + + instance.set_property( + "fixed-height-model", model) + + assert instance.get_property("layout-height") == 100 + model.set_row_data(1, 50) + assert instance.get_property("layout-height") == 150 + model.append(75) + assert instance.get_property("layout-height") == 225 + del model[1:] + assert instance.get_property("layout-height") == 100 + + assert isinstance(instance.get_property( + "fixed-height-model"), models.ListModel) + + +def test_model_from_list(): + compiler = native.ComponentCompiler() + + compdef = compiler.build_from_source(""" + export component App { + in-out property<[int]> data: [1, 2, 3, 4]; + } + """, "") + assert compdef != None + + instance = compdef.create() + assert instance != None + + model = instance.get_property("data") + assert model.row_count() == 4 + assert model.row_data(2) == 3 + + instance.set_property("data", models.ListModel([0])) + instance.set_property("data", model) + assert list(instance.get_property("data")) == [1, 2, 3, 4] + + +def test_python_model_sequence(): + model = models.ListModel([1, 2, 3, 4, 5]) + + assert len(model) == 5 + assert list(model) == [1, 2, 3, 4, 5] + model[0] = 100 + assert list(model) == [100, 2, 3, 4, 5] + assert model[2] == 3 + + +def test_rust_model_sequence(): + compiler = native.ComponentCompiler() + + compdef = compiler.build_from_source(""" + export component App { + in-out property<[int]> data: [1, 2, 3, 4, 5]; + } + """, "") + assert compdef != None + + instance = compdef.create() + assert instance != None + + model = instance.get_property("data") + + assert len(model) == 5 + assert list(model) == [1, 2, 3, 4, 5] + assert model[2] == 3 diff --git a/api/python/value.rs b/api/python/value.rs index 116c63324..194a8b5c9 100644 --- a/api/python/value.rs +++ b/api/python/value.rs @@ -37,7 +37,10 @@ impl<'a> ToPyObject for PyValueRef<'a> { slint_interpreter::Value::Image(image) => { crate::image::PyImage::from(image).into_py(py) } - slint_interpreter::Value::Model(_) => todo!(), + slint_interpreter::Value::Model(model) => { + 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))) @@ -72,6 +75,14 @@ impl FromPyObject<'_> for PyValue { ob.extract::>() .map(|pybrush| slint_interpreter::Value::Brush(pybrush.brush.clone())) }) + .or_else(|_| { + ob.extract::>() + .map(|pymodel| slint_interpreter::Value::Model(pymodel.as_model())) + }) + .or_else(|_| { + ob.extract::>() + .map(|rustmodel| slint_interpreter::Value::Model(rustmodel.0.clone())) + }) .or_else(|_| { ob.extract::<&PyDict>().and_then(|dict| { let dict_items: Result, PyErr> = dict