mirror of
https://github.com/slint-ui/slint.git
synced 2025-09-30 13:51:13 +00:00
More progress in states and transition parsing
Fill the object_tree with states, and part of the transition Also make sure to duplicate animations properly in inlining
This commit is contained in:
parent
0dff3f5f78
commit
c0fab1c3e9
5 changed files with 238 additions and 47 deletions
|
@ -3,7 +3,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::diagnostics::FileDiagnostics;
|
use crate::diagnostics::FileDiagnostics;
|
||||||
use crate::expression_tree::Expression;
|
use crate::expression_tree::{Expression, NamedReference};
|
||||||
use crate::parser::{syntax_nodes, Spanned, SyntaxKind, SyntaxNodeEx};
|
use crate::parser::{syntax_nodes, Spanned, SyntaxKind, SyntaxNodeEx};
|
||||||
use crate::typeregister::{Type, TypeRegister};
|
use crate::typeregister::{Type, TypeRegister};
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
|
@ -80,13 +80,13 @@ impl Component {
|
||||||
) -> Rc<Self> {
|
) -> Rc<Self> {
|
||||||
let c = Rc::new(Component {
|
let c = Rc::new(Component {
|
||||||
id: node.child_text(SyntaxKind::Identifier).unwrap_or_default(),
|
id: node.child_text(SyntaxKind::Identifier).unwrap_or_default(),
|
||||||
root_element: Rc::new(RefCell::new(Element::from_node(
|
root_element: Element::from_node(
|
||||||
node.Element(),
|
node.Element(),
|
||||||
"root".into(),
|
"root".into(),
|
||||||
Type::Invalid,
|
Type::Invalid,
|
||||||
diag,
|
diag,
|
||||||
tr,
|
tr,
|
||||||
))),
|
),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
let weak = Rc::downgrade(&c);
|
let weak = Rc::downgrade(&c);
|
||||||
|
@ -128,6 +128,9 @@ pub struct Element {
|
||||||
/// Tis element is part of a `for <xxx> in <model>:
|
/// Tis element is part of a `for <xxx> in <model>:
|
||||||
pub repeated: Option<RepeatedElementInfo>,
|
pub repeated: Option<RepeatedElementInfo>,
|
||||||
|
|
||||||
|
pub states: Vec<State>,
|
||||||
|
pub transitions: Vec<Transition>,
|
||||||
|
|
||||||
/// The AST node, if available
|
/// The AST node, if available
|
||||||
pub node: Option<syntax_nodes::Element>,
|
pub node: Option<syntax_nodes::Element>,
|
||||||
}
|
}
|
||||||
|
@ -153,7 +156,7 @@ impl Element {
|
||||||
parent_type: Type,
|
parent_type: Type,
|
||||||
diag: &mut FileDiagnostics,
|
diag: &mut FileDiagnostics,
|
||||||
tr: &TypeRegister,
|
tr: &TypeRegister,
|
||||||
) -> Self {
|
) -> ElementRc {
|
||||||
let base = QualifiedTypeName::from_node(node.QualifiedName());
|
let base = QualifiedTypeName::from_node(node.QualifiedName());
|
||||||
let mut r = Element {
|
let mut r = Element {
|
||||||
id,
|
id,
|
||||||
|
@ -161,7 +164,7 @@ impl Element {
|
||||||
Ok(ty) => ty,
|
Ok(ty) => ty,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
diag.push_error(err, node.QualifiedName().span());
|
diag.push_error(err, node.QualifiedName().span());
|
||||||
return Element::default();
|
return ElementRc::default();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
node: Some(node.clone()),
|
node: Some(node.clone()),
|
||||||
|
@ -290,32 +293,80 @@ impl Element {
|
||||||
if se.kind() == SyntaxKind::SubElement {
|
if se.kind() == SyntaxKind::SubElement {
|
||||||
let id = se.child_text(SyntaxKind::Identifier).unwrap_or_default();
|
let id = se.child_text(SyntaxKind::Identifier).unwrap_or_default();
|
||||||
if let Some(element_node) = se.child_node(SyntaxKind::Element) {
|
if let Some(element_node) = se.child_node(SyntaxKind::Element) {
|
||||||
r.children.push(Rc::new(RefCell::new(Element::from_node(
|
r.children.push(Element::from_node(
|
||||||
element_node.into(),
|
element_node.into(),
|
||||||
id,
|
id,
|
||||||
r.base_type.clone(),
|
r.base_type.clone(),
|
||||||
diag,
|
diag,
|
||||||
tr,
|
tr,
|
||||||
))));
|
));
|
||||||
} else {
|
} else {
|
||||||
assert!(diag.has_error());
|
assert!(diag.has_error());
|
||||||
}
|
}
|
||||||
} else if se.kind() == SyntaxKind::RepeatedElement {
|
} else if se.kind() == SyntaxKind::RepeatedElement {
|
||||||
r.children.push(Rc::new(RefCell::new(Element::from_repeated_node(
|
r.children.push(Element::from_repeated_node(
|
||||||
se.into(),
|
se.into(),
|
||||||
r.base_type.clone(),
|
r.base_type.clone(),
|
||||||
diag,
|
diag,
|
||||||
tr,
|
tr,
|
||||||
))));
|
));
|
||||||
} else if se.kind() == SyntaxKind::ConditionalElement {
|
} else if se.kind() == SyntaxKind::ConditionalElement {
|
||||||
r.children.push(Rc::new(RefCell::new(Element::from_conditional_node(
|
r.children.push(Element::from_conditional_node(
|
||||||
se.into(),
|
se.into(),
|
||||||
r.base_type.clone(),
|
r.base_type.clone(),
|
||||||
diag,
|
diag,
|
||||||
tr,
|
tr,
|
||||||
))));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let r = ElementRc::new(RefCell::new(r));
|
||||||
|
|
||||||
|
for state in node.States().flat_map(|s| s.State()) {
|
||||||
|
let s = State {
|
||||||
|
id: state
|
||||||
|
.DeclaredIdentifier()
|
||||||
|
.child_text(SyntaxKind::Identifier)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
condition: state.Expression().map(|e| Expression::Uncompiled(e.into())),
|
||||||
|
property_changes: state
|
||||||
|
.StatePropertyChange()
|
||||||
|
.map(|s| {
|
||||||
|
let ne = lookup_property_from_qualified_name(s.QualifiedName(), &r, diag);
|
||||||
|
(ne, Expression::Uncompiled(s.BindingExpression().into()))
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
r.borrow_mut().states.push(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
for trs in node.Transitions().flat_map(|s| s.Transition()) {
|
||||||
|
let trans = Transition {
|
||||||
|
is_out: trs.child_text(SyntaxKind::Identifier).unwrap_or_default() == "out",
|
||||||
|
state_id: trs
|
||||||
|
.DeclaredIdentifier()
|
||||||
|
.child_text(SyntaxKind::Identifier)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
property_animations: trs
|
||||||
|
.PropertyAnimation()
|
||||||
|
.map(|pa| {
|
||||||
|
// TODO: do that properly
|
||||||
|
(
|
||||||
|
NamedReference {
|
||||||
|
element: Rc::downgrade(&r),
|
||||||
|
name: pa
|
||||||
|
.DeclaredIdentifier()
|
||||||
|
.child_text(SyntaxKind::Identifier)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
},
|
||||||
|
Default::default(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
r.borrow_mut().transitions.push(trans);
|
||||||
|
}
|
||||||
|
|
||||||
r
|
r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -324,7 +375,7 @@ impl Element {
|
||||||
parent_type: Type,
|
parent_type: Type,
|
||||||
diag: &mut FileDiagnostics,
|
diag: &mut FileDiagnostics,
|
||||||
tr: &TypeRegister,
|
tr: &TypeRegister,
|
||||||
) -> Self {
|
) -> ElementRc {
|
||||||
let rei = RepeatedElementInfo {
|
let rei = RepeatedElementInfo {
|
||||||
model: Expression::Uncompiled(node.Expression().into()),
|
model: Expression::Uncompiled(node.Expression().into()),
|
||||||
model_data_id: node
|
model_data_id: node
|
||||||
|
@ -337,8 +388,8 @@ impl Element {
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
is_conditional_element: false,
|
is_conditional_element: false,
|
||||||
};
|
};
|
||||||
let mut e = Element::from_node(node.Element(), String::new(), parent_type, diag, tr);
|
let e = Element::from_node(node.Element(), String::new(), parent_type, diag, tr);
|
||||||
e.repeated = Some(rei);
|
e.borrow_mut().repeated = Some(rei);
|
||||||
e
|
e
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -347,15 +398,15 @@ impl Element {
|
||||||
parent_type: Type,
|
parent_type: Type,
|
||||||
diag: &mut FileDiagnostics,
|
diag: &mut FileDiagnostics,
|
||||||
tr: &TypeRegister,
|
tr: &TypeRegister,
|
||||||
) -> Self {
|
) -> ElementRc {
|
||||||
let rei = RepeatedElementInfo {
|
let rei = RepeatedElementInfo {
|
||||||
model: Expression::Uncompiled(node.Expression().into()),
|
model: Expression::Uncompiled(node.Expression().into()),
|
||||||
model_data_id: String::new(),
|
model_data_id: String::new(),
|
||||||
index_id: String::new(),
|
index_id: String::new(),
|
||||||
is_conditional_element: true,
|
is_conditional_element: true,
|
||||||
};
|
};
|
||||||
let mut e = Element::from_node(node.Element(), String::new(), parent_type, diag, tr);
|
let e = Element::from_node(node.Element(), String::new(), parent_type, diag, tr);
|
||||||
e.repeated = Some(rei);
|
e.borrow_mut().repeated = Some(rei);
|
||||||
e
|
e
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -432,6 +483,59 @@ impl std::fmt::Display for QualifiedTypeName {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return a NamedReference, if the reference is invalid, there will be a diagnostic
|
||||||
|
fn lookup_property_from_qualified_name(
|
||||||
|
node: syntax_nodes::QualifiedName,
|
||||||
|
r: &Rc<RefCell<Element>>,
|
||||||
|
diag: &mut FileDiagnostics,
|
||||||
|
) -> NamedReference {
|
||||||
|
let qualname = QualifiedTypeName::from_node(node.clone());
|
||||||
|
match qualname.members.as_slice() {
|
||||||
|
[prop_name] => {
|
||||||
|
if !r.borrow().lookup_property(prop_name.as_ref()).is_property_type() {
|
||||||
|
diag.push_error(format!("'{}' is not a valid property", qualname), node.span());
|
||||||
|
}
|
||||||
|
NamedReference { element: Rc::downgrade(&r), name: String::default() }
|
||||||
|
}
|
||||||
|
[elem_id, prop_name] => {
|
||||||
|
let element = if let Some(element) = find_element_by_id(&r, elem_id.as_ref()) {
|
||||||
|
if !element.borrow().lookup_property(prop_name.as_ref()).is_property_type() {
|
||||||
|
diag.push_error(
|
||||||
|
format!("'{}' not found in '{}'", prop_name, elem_id),
|
||||||
|
node.span(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Rc::downgrade(&element)
|
||||||
|
} else {
|
||||||
|
diag.push_error(format!("'{}' is not a valid element id", elem_id), node.span());
|
||||||
|
Weak::new()
|
||||||
|
};
|
||||||
|
NamedReference { element, name: prop_name.clone() }
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
diag.push_error(format!("'{}' is not a valid property", qualname), node.span());
|
||||||
|
NamedReference { element: Default::default(), name: String::default() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FIXME: this is duplicated the resolving pass. Also, we should use a hash table
|
||||||
|
fn find_element_by_id(e: &ElementRc, name: &str) -> Option<ElementRc> {
|
||||||
|
if e.borrow().id == name {
|
||||||
|
return Some(e.clone());
|
||||||
|
}
|
||||||
|
for x in &e.borrow().children {
|
||||||
|
if x.borrow().repeated.is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(x) = find_element_by_id(x, name) {
|
||||||
|
return Some(x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Call the visitor for each children of the element recursively, starting with the element itself
|
/// Call the visitor for each children of the element recursively, starting with the element itself
|
||||||
///
|
///
|
||||||
/// The state returned by the visitor is passed to the children
|
/// The state returned by the visitor is passed to the children
|
||||||
|
@ -445,3 +549,18 @@ pub fn recurse_elem<State>(
|
||||||
recurse_elem(sub, &state, vis);
|
recurse_elem(sub, &state, vis);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct State {
|
||||||
|
pub id: String,
|
||||||
|
pub condition: Option<Expression>,
|
||||||
|
pub property_changes: Vec<(NamedReference, Expression)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Transition {
|
||||||
|
/// false for 'to', true for 'out'
|
||||||
|
pub is_out: bool,
|
||||||
|
pub state_id: String,
|
||||||
|
pub property_animations: Vec<(NamedReference, ElementRc)>,
|
||||||
|
}
|
||||||
|
|
|
@ -80,20 +80,35 @@ fn inline_element(
|
||||||
.borrow()
|
.borrow()
|
||||||
.bindings
|
.bindings
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(k, val)| (k.clone(), fold_binding(val, &mapping, root_component))),
|
.map(|(k, val)| (k.clone(), fold_binding(val, &mapping))),
|
||||||
);
|
);
|
||||||
|
|
||||||
//core::mem::drop(elem_mut);
|
//core::mem::drop(elem_mut);
|
||||||
|
|
||||||
|
// Now fixup all binding and reference
|
||||||
for (key, e) in &mapping {
|
for (key, e) in &mapping {
|
||||||
if *key == element_key(&inlined_component.root_element) {
|
if *key == element_key(&inlined_component.root_element) {
|
||||||
continue; // the root has been processed
|
continue; // the root has been processed
|
||||||
}
|
}
|
||||||
for (_, expr) in &mut e.borrow_mut().bindings {
|
for (_, expr) in &mut e.borrow_mut().bindings {
|
||||||
fixup_binding(expr, &mapping, root_component);
|
fixup_binding(expr, &mapping);
|
||||||
}
|
}
|
||||||
if let Some(ref mut r) = &mut e.borrow_mut().repeated {
|
if let Some(ref mut r) = &mut e.borrow_mut().repeated {
|
||||||
fixup_binding(&mut r.model, &mapping, root_component);
|
fixup_binding(&mut r.model, &mapping);
|
||||||
|
}
|
||||||
|
for s in &mut e.borrow_mut().states {
|
||||||
|
if let Some(cond) = s.condition.as_mut() {
|
||||||
|
fixup_binding(cond, &mapping)
|
||||||
|
}
|
||||||
|
for (r, e) in &mut s.property_changes {
|
||||||
|
fixup_reference(r, &mapping);
|
||||||
|
fixup_binding(e, &mapping);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for t in &mut e.borrow_mut().transitions {
|
||||||
|
for (r, _) in &mut t.property_animations {
|
||||||
|
fixup_reference(r, &mapping)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -109,8 +124,12 @@ fn duplicate_element_with_mapping(
|
||||||
base_type: elem.base_type.clone(),
|
base_type: elem.base_type.clone(),
|
||||||
id: elem.id.clone(),
|
id: elem.id.clone(),
|
||||||
property_declarations: elem.property_declarations.clone(),
|
property_declarations: elem.property_declarations.clone(),
|
||||||
property_animations: elem.property_animations.clone(),
|
property_animations: elem
|
||||||
// We will do the mapping of the binding later
|
.property_animations
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.clone(), duplicate_element_with_mapping(v, mapping, root_component)))
|
||||||
|
.collect(),
|
||||||
|
// We will do the fixup of the bindings later
|
||||||
bindings: elem.bindings.clone(),
|
bindings: elem.bindings.clone(),
|
||||||
children: elem
|
children: elem
|
||||||
.children
|
.children
|
||||||
|
@ -120,42 +139,54 @@ fn duplicate_element_with_mapping(
|
||||||
repeated: elem.repeated.clone(),
|
repeated: elem.repeated.clone(),
|
||||||
node: elem.node.clone(),
|
node: elem.node.clone(),
|
||||||
enclosing_component: Rc::downgrade(root_component),
|
enclosing_component: Rc::downgrade(root_component),
|
||||||
|
states: elem.states.clone(),
|
||||||
|
transitions: elem
|
||||||
|
.transitions
|
||||||
|
.iter()
|
||||||
|
.map(|t| duplicate_transition(t, mapping, root_component))
|
||||||
|
.collect(),
|
||||||
}));
|
}));
|
||||||
mapping.insert(element_key(element), new.clone());
|
mapping.insert(element_key(element), new.clone());
|
||||||
new
|
new
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fixup_binding(
|
fn fixup_reference(
|
||||||
val: &mut Expression,
|
NamedReference { element, .. }: &mut NamedReference,
|
||||||
mapping: &HashMap<usize, ElementRc>,
|
mapping: &HashMap<usize, ElementRc>,
|
||||||
root_component: &Rc<Component>,
|
|
||||||
) {
|
) {
|
||||||
val.visit_mut(|sub| fixup_binding(sub, mapping, root_component));
|
*element =
|
||||||
|
element.upgrade().and_then(|e| mapping.get(&element_key(&e))).map(Rc::downgrade).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixup_binding(val: &mut Expression, mapping: &HashMap<usize, ElementRc>) {
|
||||||
|
val.visit_mut(|sub| fixup_binding(sub, mapping));
|
||||||
match val {
|
match val {
|
||||||
Expression::PropertyReference(NamedReference { element, .. }) => {
|
Expression::PropertyReference(r) => fixup_reference(r, mapping),
|
||||||
*element = element
|
Expression::SignalReference(r) => fixup_reference(r, mapping),
|
||||||
.upgrade()
|
|
||||||
.and_then(|e| mapping.get(&element_key(&e)))
|
|
||||||
.map(Rc::downgrade)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
Expression::SignalReference(NamedReference { element, .. }) => {
|
|
||||||
*element = element
|
|
||||||
.upgrade()
|
|
||||||
.and_then(|e| mapping.get(&element_key(&e)))
|
|
||||||
.map(Rc::downgrade)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fold_binding(
|
fn fold_binding(val: &Expression, mapping: &HashMap<usize, ElementRc>) -> Expression {
|
||||||
val: &Expression,
|
|
||||||
mapping: &HashMap<usize, ElementRc>,
|
|
||||||
root_component: &Rc<Component>,
|
|
||||||
) -> Expression {
|
|
||||||
let mut new_val = val.clone();
|
let mut new_val = val.clone();
|
||||||
fixup_binding(&mut new_val, mapping, root_component);
|
fixup_binding(&mut new_val, mapping);
|
||||||
new_val
|
new_val
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn duplicate_transition(
|
||||||
|
t: &Transition,
|
||||||
|
mapping: &mut HashMap<usize, Rc<RefCell<Element>>>,
|
||||||
|
root_component: &Rc<Component>,
|
||||||
|
) -> Transition {
|
||||||
|
Transition {
|
||||||
|
is_out: t.is_out,
|
||||||
|
state_id: t.state_id.clone(),
|
||||||
|
property_animations: t
|
||||||
|
.property_animations
|
||||||
|
.iter()
|
||||||
|
.map(|(r, anim)| {
|
||||||
|
(r.clone(), duplicate_element_with_mapping(anim, mapping, root_component))
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -24,6 +24,8 @@ pub fn create_repeater_components(component: &Rc<Component>) {
|
||||||
repeated: None,
|
repeated: None,
|
||||||
node: elem.node.clone(),
|
node: elem.node.clone(),
|
||||||
enclosing_component: Default::default(),
|
enclosing_component: Default::default(),
|
||||||
|
states: Default::default(),
|
||||||
|
transitions: Default::default(),
|
||||||
})),
|
})),
|
||||||
parent_element,
|
parent_element,
|
||||||
..Component::default()
|
..Component::default()
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
|
|
||||||
|
|
||||||
SuperSimple := Rectangle {
|
SuperSimple := Rectangle {
|
||||||
|
|
||||||
animate x {
|
animate x {
|
||||||
|
|
40
sixtyfps_compiler/tests/basic/states_transitions.60
Normal file
40
sixtyfps_compiler/tests/basic/states_transitions.60
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
TestCase := Rectangle {
|
||||||
|
property<bool> checked;
|
||||||
|
property <int32> border;
|
||||||
|
states [
|
||||||
|
checked when checked: {
|
||||||
|
color: blue; // same as root.color
|
||||||
|
text.color: red;
|
||||||
|
border: 42;
|
||||||
|
}
|
||||||
|
pressed when touch.pressed: {
|
||||||
|
color: green;
|
||||||
|
border: 88;
|
||||||
|
text.foo.bar: 0;
|
||||||
|
/// ^error{'text.foo.bar' is not a valid property}
|
||||||
|
colour: yellow;
|
||||||
|
/// ^error{'colour' is not a valid property}
|
||||||
|
fox.color: yellow;
|
||||||
|
/// ^error{'fox' is not a valid element id}
|
||||||
|
text.fox: yellow;
|
||||||
|
/// ^error{'fox' not found in 'text'}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
transitions [
|
||||||
|
to pressed: {
|
||||||
|
//animate * { duration: 88ms }
|
||||||
|
animate color { duration: 88ms; }
|
||||||
|
}
|
||||||
|
out pressed: {
|
||||||
|
//animate color, foo.x { duration: 300ms; }
|
||||||
|
//pause: 20ms;
|
||||||
|
animate border { duration: 120ms; }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
text := Text {}
|
||||||
|
touch := TouchArea {}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue