Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions api/cpp/include/private/slint_models.h
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,21 @@ bool model_all(const std::shared_ptr<M> &model, P predicate)
return true;
}

template<typename M, typename P>
int32_t model_find_index(const std::shared_ptr<M> &model, P predicate)
{
long int count = model_length(model);

for (long int i = 0; i < count; ++i) {
auto data = access_array_index(model, i);
if (predicate(data)) {
return static_cast<int32_t>(i);
}
}

return -1;
}

} // namespace private_api

/// A Model is providing Data for Slint Models or ListView elements of the
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
---
title: Array Predicates
description: Experimental predicate expressions for the array operations any and all.
description: Experimental predicate expressions for the array operations any, all and find-index.
---

The array operations `any` and `all` test the elements of an array against a predicate expression.
The array operations `any`, `all` and `find-index` test the elements of an array against a
predicate expression.

> **Note**: Array predicates are experimental and subject to change.
> Tracking issue: [slint-ui/slint#12777](https://github.com/slint-ui/slint/issues/12777).
Expand All @@ -14,16 +15,24 @@ A predicate expression has the form `(name) => condition`.
It binds `name` to a value and evaluates `condition`, which must be a `bool`.
The name is in scope only inside `condition`.

Predicates are passed to the array operations `any` and `all`,
Predicates are passed to the array operations `any`, `all` and `find-index`,
which supply each element in turn as `name`:

- `array.any((name) => condition)` reads `true` if `condition` holds for at least one element.
- `array.all((name) => condition)` reads `true` if `condition` holds for every element.
- `array.find-index((name) => condition)` reads the index of the first element for which
`condition` holds, or `-1` if no element matches.

The argument name is available only inside the predicate expression,
and its type is inferred from the array element type.

`any` returns `false` for an empty array, while `all` returns `true` for an empty array.
`any` returns `false` for an empty array, `all` returns `true` for an empty array, and
`find-index` returns `-1` for an empty array.

For a plain value-equality lookup, prefer the stable
[`array.index-of(value)`](../../property-types/arrays-and-models/#operations) over
`find-index((name) => name == value)`: it needs no predicate and isn't experimental.
Reach for `find-index` when the match condition is more than equality against a single value.

## Example

Expand All @@ -33,13 +42,14 @@ export component Example {

out property <bool> contains-two: list-of-int.any((value) => value == 2); // true
out property <bool> all-positive: list-of-int.all((value) => value > 0); // true
out property <int> index-of-first-even: list-of-int.find-index((value) => mod(value, 2) == 0); // 1
}
```

## Current Limitations

Predicates are the only form of closure in the Slint language, and they are only accepted
as the argument of `any` and `all`.
as the argument of `any`, `all` and `find-index`.
A predicate cannot be stored in a property, passed to a function or callback, or called directly.
The predicate must be written inline in the call to `any` or `all`;
The predicate must be written inline in the call to `any`, `all` or `find-index`;
passing a closure stored in a local variable is rejected with an error.
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,10 @@ The following operations apply to a value of an array type.
- `array.remove(index)` removes the element at `index`.
- `array.insert(index, value)` inserts `value` before the element at `index`, shifting later elements up.
`value` must have the element type.
- `array.index-of(value)` returns the index of the first element equal to `value` as an `int`,
or `-1` if no element matches. `value` must have the element type.

`length`, `push`, `remove`, and `insert` are members of the array; `index` is written with the `[ ]` operator.
`length`, `push`, `remove`, `insert`, and `index-of` are members of the array; `index` is written with the `[ ]` operator.

## Out-of-bounds behavior

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,16 @@ ComboBox {
<SlintProperty propName="current-value" typeName="string" defaultValue='""' propertyVisibility="in-out">
The currently selected text.

:::caution[Note]
To change the selection from host code (Rust, C++, JavaScript, Python), write to
`current-index`. Writes to `current-value` are not supported — they don't change
the selection. The visible state continues to reflect `model[current-index]`.
:::note
Writing to `current-value` from host code (Rust, C++, JavaScript, Python) looks up the
written value in `model` and moves `current-index` to the first matching row. If the
value isn't found in `model`, the selection is cleared (`current-index` becomes `-1`
and `current-value` becomes `""`), the same as setting `current-index` out of range.
Prefer writing `current-index` directly when the row's position is already known.

This lookup only runs when the write actually changes `current-value`. Writing the
value it already holds — including when the model has duplicate entries and a later
one is currently selected — is a no-op and does not move the selection.
:::
</SlintProperty>

Expand Down
56 changes: 56 additions & 0 deletions internal/compiler/builtin_macros.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ pub fn lower_macro(
BuiltinMacroFunction::ArrayInsert => {
array_insert_macro(n, sub_expr.collect(), diag, symbol_counters)
}
BuiltinMacroFunction::ArrayIndexOf => {
array_index_of_macro(n, sub_expr.collect(), diag, symbol_counters)
}
BuiltinMacroFunction::CustomMouseCursor => {
let mut has_error = None;
let hotspot_type_error = "The last two arguments to custom cursor must be an integer";
Expand Down Expand Up @@ -531,6 +534,59 @@ fn array_insert_macro(
}
}

/// Unlike the other array macros, this lowers to `BuiltinFunction::ArrayFindIndex`
/// (`array.find-index((x) => x == value)`), not a same-named builtin.
fn array_index_of_macro(
node: &dyn Spanned,
mut args: Vec<(Expression, Option<NodeOrToken>)>,
diag: &mut BuildDiagnostics,
symbol_counters: &SymbolCounters,
) -> Expression {
if args.len() != 2 {
diag.push_error(
format!("This method needs 1 argument, but {} were provided", args.len() - 1),
node,
);
return Expression::Invalid;
}

let element_type =
if let Type::Array(t) = args[0].0.ty() { (*t).clone() } else { Type::Invalid };

let (model_expr, _) = args.remove(0);
let (value_expr, value_node) = args.remove(0);
let value =
value_expr.maybe_convert_to(element_type.clone(), &value_node, diag, symbol_counters);

// Evaluate `value` once, before the search: the closure body runs once per row, so
// embedding `value` there directly would re-evaluate it per row instead of once.
let value_local = symbol_counters.generate_name("index_of_value_");
let arg_name = symbol_counters.generate_name("index_of_element_");
let predicate = Expression::Closure {
arg_name: arg_name.clone(),
expression: Box::new(Expression::BinaryExpression {
lhs: Box::new(Expression::ReadLocalVariable {
name: arg_name,
ty: element_type.clone(),
}),
rhs: Box::new(Expression::ReadLocalVariable {
name: value_local.clone(),
ty: element_type,
}),
op: '=',
}),
};

Expression::CodeBlock(vec![
Expression::StoreLocalVariable { name: value_local, value: Box::new(value) },
Expression::FunctionCall {
function: Callable::Builtin(BuiltinFunction::ArrayFindIndex),
arguments: vec![model_expr, predicate],
source_location: Some(node.to_source_location()),
},
])
}

fn to_debug_string(
expr: Expression,
node: &dyn Spanned,
Expand Down
12 changes: 10 additions & 2 deletions internal/compiler/expression_tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ pub enum BuiltinFunction {
ArrayInsert,
ArrayAny,
ArrayAll,
ArrayFindIndex,
Rgb,
Hsv,
Oklch,
Expand Down Expand Up @@ -177,6 +178,8 @@ pub enum BuiltinMacroFunction {
ArrayPush,
ArrayRemove,
ArrayInsert,
/// Transforms `array.index-of(value)` into `array.find-index((x) => x == value)`
ArrayIndexOf,
CustomMouseCursor,
}

Expand Down Expand Up @@ -288,6 +291,7 @@ declare_builtin_function_types!(
ArrayInsert: (Type::Model, Type::Int32, Type::InferredProperty) -> Type::Void,
ArrayAny: (Type::Model, Type::Closure) -> Type::Bool,
ArrayAll: (Type::Model, Type::Closure) -> Type::Bool,
ArrayFindIndex: (Type::Model, Type::Closure) -> Type::Int32,
Rgb: (Type::Int32, Type::Int32, Type::Int32, Type::Float32) -> Type::Color,
Hsv: (Type::Float32, Type::Float32, Type::Float32, Type::Float32) -> Type::Color,
Oklch: (Type::Float32, Type::Float32, Type::Float32, Type::Float32) -> Type::Color,
Expand Down Expand Up @@ -447,7 +451,9 @@ impl BuiltinFunction {
BuiltinFunction::MacosBringAllWindowsToFront => false,
BuiltinFunction::PathPointAt => true,
BuiltinFunction::PathAngleAt => true,
BuiltinFunction::ArrayAny | BuiltinFunction::ArrayAll => true,
BuiltinFunction::ArrayAny
| BuiltinFunction::ArrayAll
| BuiltinFunction::ArrayFindIndex => true,
}
}

Expand Down Expand Up @@ -544,7 +550,9 @@ impl BuiltinFunction {
BuiltinFunction::MacosBringAllWindowsToFront => false,
BuiltinFunction::PathPointAt => true,
BuiltinFunction::PathAngleAt => true,
BuiltinFunction::ArrayAny | BuiltinFunction::ArrayAll => true,
BuiltinFunction::ArrayAny
| BuiltinFunction::ArrayAll
| BuiltinFunction::ArrayFindIndex => true,
}
}
}
Expand Down
7 changes: 7 additions & 0 deletions internal/compiler/generator/cpp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5565,6 +5565,13 @@ fn compile_builtin_function_call(
BuiltinFunction::ArrayAll => {
format!("slint::private_api::model_all({}, {})", a.next().unwrap(), a.next().unwrap())
},
BuiltinFunction::ArrayFindIndex => {
format!(
"slint::private_api::model_find_index({}, {})",
a.next().unwrap(),
a.next().unwrap()
)
},
}
}

Expand Down
12 changes: 12 additions & 0 deletions internal/compiler/generator/rust.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5084,6 +5084,18 @@ fn compile_builtin_function_call(
sp::model_all(&arr, |#arg_name| -> bool { #closure_expression })
})
}
BuiltinFunction::ArrayFindIndex => {
let arr_expression = compile_expression_to_value(&arguments[0], ctx);
let Expression::Closure { arg_name, expression } = &arguments[1] else {
panic!("internal error: ArrayFindIndex expects a closure as second argument")
};
let arg_name = ident(arg_name);
let closure_expression = compile_expression(expression, ctx);
quote!({
let arr = #arr_expression;
sp::model_find_index(&arr, |#arg_name| -> bool { #closure_expression })
})
}
}
}

Expand Down
4 changes: 3 additions & 1 deletion internal/compiler/llr/optim_passes/inline_expressions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,9 @@ fn builtin_function_cost(function: &BuiltinFunction) -> isize {
BuiltinFunction::PathPointAt => isize::MAX,
BuiltinFunction::PathAngleAt => isize::MAX,
// Iterating the model and running the closure is unbounded; never inline.
BuiltinFunction::ArrayAny | BuiltinFunction::ArrayAll => isize::MAX,
BuiltinFunction::ArrayAny | BuiltinFunction::ArrayAll | BuiltinFunction::ArrayFindIndex => {
isize::MAX
}
}
}

Expand Down
4 changes: 3 additions & 1 deletion internal/compiler/lookup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1217,13 +1217,15 @@ impl LookupObject for ArrayExpression<'_> {
.or_else(|| f("push", member_macro(BuiltinMacroFunction::ArrayPush)))
.or_else(|| f("remove", member_macro(BuiltinMacroFunction::ArrayRemove)))
.or_else(|| f("insert", member_macro(BuiltinMacroFunction::ArrayInsert)))
.or_else(|| f("index-of", member_macro(BuiltinMacroFunction::ArrayIndexOf)))
.or_else(|| {
// `any` and `all` take a closure argument; closures are experimental.
// `any`, `all` and `find-index` take a closure argument; closures are experimental.
if !ctx.diag.enable_experimental && !ctx.type_register.expose_internal_types {
return None;
}
f("any", member_function(BuiltinFunction::ArrayAny))
.or_else(|| f("all", member_function(BuiltinFunction::ArrayAll)))
.or_else(|| f("find-index", member_function(BuiltinFunction::ArrayFindIndex)))
})
}
}
Expand Down
14 changes: 9 additions & 5 deletions internal/compiler/passes/resolving.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1574,9 +1574,10 @@ impl Expression {
}
return Self::Invalid;
};
// For `.any(predicate)` / `.all(predicate)` the closure's argument type is
// structurally derived from the base array's element type. Compute it here
// so we can hand it to the closure when resolving that specific argument.
// For `.any(predicate)` / `.all(predicate)` / `.find-index(predicate)` the
// closure's argument type is structurally derived from the base array's
// element type. Compute it here so we can hand it to the closure when
// resolving that specific argument.
let expected_closure_arg_type = match &function {
Some(LookupResult::Callable(LookupResultCallable::MemberFunction {
base,
Expand All @@ -1585,7 +1586,9 @@ impl Expression {
})) if matches!(
**member,
LookupResultCallable::Callable(Callable::Builtin(
BuiltinFunction::ArrayAny | BuiltinFunction::ArrayAll
BuiltinFunction::ArrayAny
| BuiltinFunction::ArrayAll
| BuiltinFunction::ArrayFindIndex
))
) =>
{
Expand Down Expand Up @@ -2112,7 +2115,8 @@ impl Expression {
&& !matches!(expression, Expression::Closure { .. })
{
ctx.diag.push_error(
"Closures must be written inline as the argument of 'any' or 'all'".into(),
"Closures must be written inline as the argument of 'any', 'all' or 'find-index'"
.into(),
&node,
);
return Expression::Invalid;
Expand Down
47 changes: 47 additions & 0 deletions internal/compiler/tests/index_of_stability.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0

//! `array.index-of` must stay usable without `enable_experimental` (unlike find-index/any/all).
//! The syntax-test corpus and every runtime test driver force that flag on uniformly for their
//! whole corpus, so a regression here wouldn't be caught there — this test controls the flag
//! directly instead.

fn compile(
source: &str,
enable_experimental: bool,
) -> i_slint_compiler::diagnostics::BuildDiagnostics {
let mut diag = i_slint_compiler::diagnostics::BuildDiagnostics::default();
let syntax_node = i_slint_compiler::parser::parse(source.into(), None, &mut diag);
let mut compiler_config = i_slint_compiler::CompilerConfiguration::new(
i_slint_compiler::generator::OutputFormat::Interpreter,
);
compiler_config.embed_resources = i_slint_compiler::EmbedResourcesKind::OnlyBuiltinResources;
compiler_config.enable_experimental = enable_experimental;
compiler_config.style = Some("fluent".into());
let (_, build_diags, _) =
spin_on::spin_on(i_slint_compiler::compile_syntax_node(syntax_node, diag, compiler_config));
build_diags
}

#[test]
fn index_of_does_not_require_experimental_features() {
let src = r#"
export component Test {
in property <[string]> model: ["A", "B"];
out property <int> idx: model.index-of("B");
}
"#;
assert!(!compile(src, false).has_errors());
}

#[test]
fn find_index_still_requires_experimental_features() {
let src = r#"
export component Test {
in property <[string]> model: ["A", "B"];
out property <int> idx: model.find-index((x) => x == "B");
}
"#;
assert!(compile(src, false).has_errors());
assert!(!compile(src, true).has_errors());
}
6 changes: 6 additions & 0 deletions internal/compiler/tests/syntax/expressions/array.slint
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export component Test {
// > <error{This method needs 2 arguments, but 0 were provided}
a.insert(1);
// > <error{This method needs 2 arguments, but 1 were provided}
a.index-of();
// > <error{This method needs 1 argument, but 0 were provided}
}

public function test_too_many_args() {
Expand All @@ -22,13 +24,17 @@ export component Test {
// > <error{This method needs 1 argument, but 3 were provided}
a.insert(1, 2, 3, 4);
// > <error{This method needs 2 arguments, but 4 were provided}
a.index-of(1, 2);
// > <error{This method needs 1 argument, but 2 were provided}
}

public function test_arg_incorrect_type() {
a.push("string");
// > <error{Cannot convert string to int}
a.insert(1, "string");
// > <error{Cannot convert string to int}
a.index-of("string");
// > <error{Cannot convert string to int}

}

Expand Down
Loading
Loading