Compare commits
No commits in common. "f5486fb0dd2c4f5f59ea52782af464c2faa4c714" and "96f8b6b5f34c5374b4aebab3cf5d9e582e37ecb3" have entirely different histories.
f5486fb0dd
...
96f8b6b5f3
4
app-proto/full-interface/.gitignore
vendored
4
app-proto/full-interface/.gitignore
vendored
@ -1,4 +0,0 @@
|
||||
target
|
||||
dist
|
||||
profiling
|
||||
Cargo.lock
|
@ -1,120 +0,0 @@
|
||||
body {
|
||||
margin: 0px;
|
||||
color: #fcfcfc;
|
||||
background-color: #222;
|
||||
}
|
||||
|
||||
/* sidebar */
|
||||
|
||||
#sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
float: left;
|
||||
width: 450px;
|
||||
height: 100vh;
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
border-width: 0px 1px 0px 0px;
|
||||
border-style: solid;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
/* add-remove */
|
||||
|
||||
#add-remove {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
#add-remove > button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: large;
|
||||
}
|
||||
|
||||
/* outline */
|
||||
|
||||
#outline {
|
||||
flex-grow: 1;
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
li {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
summary {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
summary.selected {
|
||||
color: #fff;
|
||||
background-color: #444;
|
||||
}
|
||||
|
||||
summary > div, .cst {
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.elt, .cst {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.elt-switch {
|
||||
width: 18px;
|
||||
padding-left: 2px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
details:has(li) .elt-switch::after {
|
||||
content: '▸';
|
||||
}
|
||||
|
||||
details[open]:has(li) .elt-switch::after {
|
||||
content: '▾';
|
||||
}
|
||||
|
||||
.elt-label {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.cst-label {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.elt-rep {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.elt-rep > div, .cst-rep {
|
||||
padding: 2px 0px 0px 0px;
|
||||
font-size: 10pt;
|
||||
text-align: center;
|
||||
width: 56px;
|
||||
}
|
||||
|
||||
.cst {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* display */
|
||||
|
||||
canvas {
|
||||
float: left;
|
||||
margin-left: 20px;
|
||||
margin-top: 20px;
|
||||
background-color: #020202;
|
||||
border: 1px solid #555;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
canvas:focus {
|
||||
border-color: #aaa;
|
||||
}
|
@ -1,55 +0,0 @@
|
||||
use sycamore::prelude::*;
|
||||
use web_sys::{MouseEvent, console, wasm_bindgen::JsValue};
|
||||
|
||||
use crate::AppState;
|
||||
use crate::Constraint;
|
||||
|
||||
#[component]
|
||||
pub fn AddRemove() -> View {
|
||||
let state = use_context::<AppState>();
|
||||
|
||||
view! {
|
||||
div(id="add-remove") {
|
||||
button(
|
||||
on:click=move |event: MouseEvent| {
|
||||
console::log_1(&JsValue::from("constraints:"));
|
||||
state.assembly.constraints.with(|csts| {
|
||||
for (_, cst) in csts.into_iter() {
|
||||
console::log_4(
|
||||
&JsValue::from(cst.args.0),
|
||||
&JsValue::from(cst.args.1),
|
||||
&JsValue::from(":"),
|
||||
&JsValue::from(cst.rep)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
) { "+" }
|
||||
button(
|
||||
disabled={
|
||||
state.selection.with(|sel| sel.len() != 2)
|
||||
},
|
||||
on:click=move |event: MouseEvent| {
|
||||
let args = state.selection.with(
|
||||
|sel| {
|
||||
let arg_vec: Vec<_> = sel.into_iter().collect();
|
||||
(arg_vec[0].clone(), arg_vec[1].clone())
|
||||
}
|
||||
);
|
||||
console::log_5(
|
||||
&JsValue::from("add constraint"),
|
||||
&JsValue::from(args.0),
|
||||
&JsValue::from(args.1),
|
||||
&JsValue::from(":"),
|
||||
&JsValue::from(0.0)
|
||||
);
|
||||
state.assembly.insert_constraint(Constraint {
|
||||
args: args,
|
||||
rep: 0.0
|
||||
});
|
||||
state.selection.update(|sel| sel.clear());
|
||||
}
|
||||
) { "🔗" }
|
||||
}
|
||||
}
|
||||
}
|
@ -1,44 +0,0 @@
|
||||
use nalgebra::DVector;
|
||||
use rustc_hash::FxHashSet;
|
||||
use slab::Slab;
|
||||
use sycamore::prelude::*;
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct Element {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub color: [f32; 3],
|
||||
pub rep: DVector<f64>,
|
||||
pub constraints: FxHashSet<usize>
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Constraint {
|
||||
pub args: (usize, usize),
|
||||
pub rep: f64
|
||||
}
|
||||
|
||||
// a complete, view-independent description of an assembly
|
||||
#[derive(Clone)]
|
||||
pub struct Assembly {
|
||||
pub elements: Signal<Slab<Element>>,
|
||||
pub constraints: Signal<Slab<Constraint>>
|
||||
}
|
||||
|
||||
impl Assembly {
|
||||
pub fn new() -> Assembly {
|
||||
Assembly {
|
||||
elements: create_signal(Slab::new()),
|
||||
constraints: create_signal(Slab::new())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert_constraint(&self, constraint: Constraint) {
|
||||
let args = constraint.args;
|
||||
let key = self.constraints.update(|csts| csts.insert(constraint));
|
||||
self.elements.update(|elts| {
|
||||
elts[args.0].constraints.insert(key);
|
||||
elts[args.1].constraints.insert(key);
|
||||
})
|
||||
}
|
||||
}
|
@ -1,155 +0,0 @@
|
||||
use itertools::Itertools;
|
||||
use sycamore::{prelude::*, web::tags::div};
|
||||
use web_sys::{Element, KeyboardEvent, MouseEvent, wasm_bindgen::JsCast};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
// this component lists the elements of the assembly, showing the constraints
|
||||
// on each element as a collapsible sub-list. its implementation is based on
|
||||
// Kate Morley's HTML + CSS tree views:
|
||||
//
|
||||
// https://iamkate.com/code/tree-views/
|
||||
//
|
||||
#[component]
|
||||
pub fn Outline() -> View {
|
||||
// sort the elements alphabetically by ID
|
||||
let elements_sorted = create_memo(|| {
|
||||
let state = use_context::<AppState>();
|
||||
state.assembly.elements
|
||||
.get_clone()
|
||||
.into_iter()
|
||||
.sorted_by_key(|(_, elt)| elt.id.clone())
|
||||
.collect()
|
||||
});
|
||||
|
||||
view! {
|
||||
ul(
|
||||
id="outline",
|
||||
on:click={
|
||||
let state = use_context::<AppState>();
|
||||
move |_| state.selection.update(|sel| sel.clear())
|
||||
}
|
||||
) {
|
||||
Keyed(
|
||||
list=elements_sorted,
|
||||
view=|(key, elt)| {
|
||||
let state = use_context::<AppState>();
|
||||
let class = create_memo({
|
||||
move || {
|
||||
if state.selection.with(|sel| sel.contains(&key)) {
|
||||
"selected"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
});
|
||||
let label = elt.label.clone();
|
||||
let rep_components = elt.rep.iter().map(|u| {
|
||||
let u_coord = u.to_string().replace("-", "\u{2212}");
|
||||
View::from(div().children(u_coord))
|
||||
}).collect::<Vec<_>>();
|
||||
let constrained = elt.constraints.len() > 0;
|
||||
let details_node = create_node_ref();
|
||||
view! {
|
||||
/* [TO DO] switch to integer-valued parameters whenever
|
||||
that becomes possible again */
|
||||
li {
|
||||
details(ref=details_node) {
|
||||
summary(
|
||||
class=class.get(),
|
||||
on:keydown={
|
||||
move |event: KeyboardEvent| {
|
||||
match event.key().as_str() {
|
||||
"Enter" => {
|
||||
if event.shift_key() {
|
||||
state.selection.update(|sel| {
|
||||
if !sel.remove(&key) {
|
||||
sel.insert(key);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
state.selection.update(|sel| {
|
||||
sel.clear();
|
||||
sel.insert(key);
|
||||
});
|
||||
}
|
||||
event.prevent_default();
|
||||
},
|
||||
"ArrowRight" if constrained => {
|
||||
let _ = details_node
|
||||
.get()
|
||||
.unchecked_into::<Element>()
|
||||
.set_attribute("open", "");
|
||||
},
|
||||
"ArrowLeft" => {
|
||||
let _ = details_node
|
||||
.get()
|
||||
.unchecked_into::<Element>()
|
||||
.remove_attribute("open");
|
||||
},
|
||||
_ => ()
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
div(
|
||||
class="elt-switch",
|
||||
on:click=|event: MouseEvent| event.stop_propagation()
|
||||
)
|
||||
div(
|
||||
class="elt",
|
||||
on:click={
|
||||
move |event: MouseEvent| {
|
||||
if event.shift_key() {
|
||||
state.selection.update(|sel| {
|
||||
if !sel.remove(&key) {
|
||||
sel.insert(key);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
state.selection.update(|sel| {
|
||||
sel.clear();
|
||||
sel.insert(key);
|
||||
});
|
||||
}
|
||||
event.stop_propagation();
|
||||
event.prevent_default();
|
||||
}
|
||||
}
|
||||
) {
|
||||
div(class="elt-label") { (label) }
|
||||
div(class="elt-rep") { (rep_components) }
|
||||
}
|
||||
}
|
||||
ul(class="constraints") {
|
||||
Keyed(
|
||||
list=elt.constraints.into_iter().collect::<Vec<_>>(),
|
||||
view=move |c_key: usize| {
|
||||
let c_state = use_context::<AppState>();
|
||||
let assembly = &c_state.assembly;
|
||||
let cst = assembly.constraints.with(|csts| csts[c_key].clone());
|
||||
let other_arg = if cst.args.0 == key {
|
||||
cst.args.1
|
||||
} else {
|
||||
cst.args.0
|
||||
};
|
||||
let other_arg_label = assembly.elements.with(|elts| elts[other_arg].label.clone());
|
||||
view! {
|
||||
li(class="cst") {
|
||||
div(class="cst-label") { (other_arg_label) }
|
||||
div(class="cst-rep") { (cst.rep) }
|
||||
}
|
||||
}
|
||||
},
|
||||
key=|c_key| c_key.clone()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
key=|(key, _)| key.clone()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
1
app-proto/inversive-display/.gitignore
vendored
1
app-proto/inversive-display/.gitignore
vendored
@ -1,4 +1,3 @@
|
||||
target
|
||||
dist
|
||||
profiling
|
||||
Cargo.lock
|
3
app-proto/sketch-outline/.gitignore
vendored
Normal file
3
app-proto/sketch-outline/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
target
|
||||
dist
|
||||
Cargo.lock
|
@ -12,7 +12,6 @@ itertools = "0.13.0"
|
||||
js-sys = "0.3.70"
|
||||
nalgebra = "0.33.0"
|
||||
rustc-hash = "2.0.0"
|
||||
slab = "0.4.9"
|
||||
sycamore = "0.9.0-beta.3"
|
||||
|
||||
# The `console_error_panic_hook` crate provides better debugging of panics by
|
79
app-proto/sketch-outline/main.css
Normal file
79
app-proto/sketch-outline/main.css
Normal file
@ -0,0 +1,79 @@
|
||||
body {
|
||||
margin-left: 20px;
|
||||
margin-top: 20px;
|
||||
color: #fcfcfc;
|
||||
background-color: #222;
|
||||
}
|
||||
|
||||
/* outline */
|
||||
|
||||
ul {
|
||||
float: left;
|
||||
width: 450px;
|
||||
height: 750px;
|
||||
margin: 0px;
|
||||
padding: 8px;
|
||||
border: 1px solid #555;
|
||||
border-radius: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
padding: 3px;
|
||||
list-style-type: none;
|
||||
background-color: #444;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
li.selected {
|
||||
color: #fff;
|
||||
background-color: #666;
|
||||
}
|
||||
|
||||
li:not(:last-child) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
li > .elt-label {
|
||||
flex-grow: 1;
|
||||
padding: 2px 0px 2px 4px;
|
||||
}
|
||||
|
||||
li > .elt-rep {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
li > .elt-rep > div {
|
||||
padding: 2px;
|
||||
margin-left: 3px;
|
||||
text-align: center;
|
||||
width: 60px;
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
li.selected > .elt-rep > div {
|
||||
background-color: #555;
|
||||
}
|
||||
|
||||
li > .elt-rep > div:first-child {
|
||||
border-radius: 6px 0px 0px 6px;
|
||||
}
|
||||
|
||||
li > .elt-rep > div:last-child {
|
||||
border-radius: 0px 6px 6px 0px;
|
||||
}
|
||||
|
||||
/* display */
|
||||
|
||||
canvas {
|
||||
float: left;
|
||||
margin-left: 16px;
|
||||
background-color: #020202;
|
||||
border: 1px solid #555;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
canvas:focus {
|
||||
border-color: #aaa;
|
||||
}
|
18
app-proto/sketch-outline/src/assembly.rs
Normal file
18
app-proto/sketch-outline/src/assembly.rs
Normal file
@ -0,0 +1,18 @@
|
||||
use nalgebra::DVector;
|
||||
use rustc_hash::FxHashMap;
|
||||
use sycamore::reactive::Signal;
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct Element {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub color: [f32; 3],
|
||||
pub rep: DVector<f64>
|
||||
}
|
||||
|
||||
// a complete, view-independent description of an assembly
|
||||
#[derive(Clone)]
|
||||
pub struct Assembly {
|
||||
// the order of the elements is arbitrary, and it could change at any time
|
||||
pub elements: Signal<FxHashMap<String, Element>>
|
||||
}
|
@ -288,17 +288,17 @@ pub fn Display() -> View {
|
||||
|
||||
// get the assembly
|
||||
let elements = state.assembly.elements.get_clone();
|
||||
let element_iter = (&elements).into_iter();
|
||||
let reps_world: Vec<_> = element_iter.clone().map(|(_, elt)| &assembly_to_world * &elt.rep).collect();
|
||||
let colors: Vec<_> = element_iter.clone().map(|(key, elt)|
|
||||
if state.selection.with(|sel| sel.contains(&key)) {
|
||||
let element_iter = (&elements).values();
|
||||
let reps_world: Vec<_> = element_iter.clone().map(|elt| &assembly_to_world * &elt.rep).collect();
|
||||
let colors: Vec<_> = element_iter.clone().map(|elt|
|
||||
if state.selection.with(|sel| sel.contains(&elt.id)) {
|
||||
elt.color.map(|ch| 0.2 + 0.8*ch)
|
||||
} else {
|
||||
elt.color
|
||||
}
|
||||
).collect();
|
||||
let highlights: Vec<_> = element_iter.map(|(key, _)|
|
||||
if state.selection.with(|sel| sel.contains(&key)) {
|
||||
let highlights: Vec<_> = element_iter.map(|elt|
|
||||
if state.selection.with(|sel| sel.contains(&elt.id)) {
|
||||
1.0_f32
|
||||
} else {
|
||||
HIGHLIGHT
|
||||
@ -386,8 +386,8 @@ pub fn Display() -> View {
|
||||
// again
|
||||
canvas(
|
||||
ref=display,
|
||||
width="600",
|
||||
height="600",
|
||||
width="750",
|
||||
height="750",
|
||||
tabindex="0",
|
||||
on:keydown=move |event: KeyboardEvent| {
|
||||
if event.key() == "Shift" {
|
@ -1,79 +1,66 @@
|
||||
mod add_remove;
|
||||
mod assembly;
|
||||
mod display;
|
||||
mod outline;
|
||||
|
||||
use nalgebra::DVector;
|
||||
use rustc_hash::FxHashSet;
|
||||
use rustc_hash::{FxHashMap, FxHashSet};
|
||||
use sycamore::prelude::*;
|
||||
|
||||
use add_remove::AddRemove;
|
||||
use assembly::{Assembly, Constraint, Element};
|
||||
use assembly::{Assembly, Element};
|
||||
use display::Display;
|
||||
use outline::Outline;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
assembly: Assembly,
|
||||
selection: Signal<FxHashSet<usize>>
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
fn new() -> AppState {
|
||||
AppState {
|
||||
assembly: Assembly::new(),
|
||||
selection: create_signal(FxHashSet::default())
|
||||
}
|
||||
}
|
||||
selection: Signal<FxHashSet<String>>
|
||||
}
|
||||
|
||||
fn main() {
|
||||
sycamore::render(|| {
|
||||
let state = AppState::new();
|
||||
let key_a = state.assembly.elements.update(
|
||||
let state = AppState {
|
||||
assembly: Assembly {
|
||||
elements: create_signal(FxHashMap::default())
|
||||
},
|
||||
selection: create_signal(FxHashSet::default())
|
||||
};
|
||||
state.assembly.elements.update(
|
||||
|elts| elts.insert(
|
||||
"wing_a".to_string(),
|
||||
Element {
|
||||
id: String::from("wing_a"),
|
||||
label: String::from("Wing A"),
|
||||
color: [1.00_f32, 0.25_f32, 0.00_f32],
|
||||
rep: DVector::<f64>::from_column_slice(&[0.5, 0.5, 0.0, 0.5, -0.25]),
|
||||
constraints: FxHashSet::default()
|
||||
rep: DVector::<f64>::from_column_slice(&[0.5, 0.5, 0.0, 0.5, -0.25])
|
||||
}
|
||||
)
|
||||
);
|
||||
let key_b = state.assembly.elements.update(
|
||||
state.assembly.elements.update(
|
||||
|elts| elts.insert(
|
||||
"wing_b".to_string(),
|
||||
Element {
|
||||
id: String::from("wing_b"),
|
||||
label: String::from("Wing B"),
|
||||
color: [0.00_f32, 0.25_f32, 1.00_f32],
|
||||
rep: DVector::<f64>::from_column_slice(&[-0.5, -0.5, 0.0, 0.5, -0.25]),
|
||||
constraints: FxHashSet::default()
|
||||
rep: DVector::<f64>::from_column_slice(&[-0.5, -0.5, 0.0, 0.5, -0.25])
|
||||
},
|
||||
)
|
||||
);
|
||||
state.assembly.elements.update(
|
||||
|elts| elts.insert(
|
||||
"central".to_string(),
|
||||
Element {
|
||||
id: String::from("central"),
|
||||
label: String::from("Central"),
|
||||
color: [0.75_f32, 0.75_f32, 0.75_f32],
|
||||
rep: DVector::<f64>::from_column_slice(&[0.0, 0.0, 0.0, 0.4, -0.625]),
|
||||
constraints: FxHashSet::default()
|
||||
rep: DVector::<f64>::from_column_slice(&[0.0, 0.0, 0.0, 0.4, -0.625])
|
||||
}
|
||||
)
|
||||
);
|
||||
state.assembly.insert_constraint(Constraint {
|
||||
args: (key_a, key_b),
|
||||
rep: 0.5
|
||||
});
|
||||
provide_context(state);
|
||||
|
||||
view! {
|
||||
div(id="sidebar") {
|
||||
AddRemove {}
|
||||
Outline {}
|
||||
}
|
||||
Display {}
|
||||
}
|
||||
});
|
80
app-proto/sketch-outline/src/outline.rs
Normal file
80
app-proto/sketch-outline/src/outline.rs
Normal file
@ -0,0 +1,80 @@
|
||||
use itertools::Itertools;
|
||||
use sycamore::{prelude::*, web::tags::div};
|
||||
use web_sys::KeyboardEvent;
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
#[component]
|
||||
pub fn Outline() -> View {
|
||||
// sort the elements alphabetically by ID
|
||||
let elements_sorted = create_memo(|| {
|
||||
let state = use_context::<AppState>();
|
||||
state.assembly.elements
|
||||
.get_clone()
|
||||
.into_iter()
|
||||
.sorted_by_key(|(id, _)| id.clone())
|
||||
.map(|(_, elt)| elt)
|
||||
.collect()
|
||||
});
|
||||
|
||||
view! {
|
||||
ul {
|
||||
Keyed(
|
||||
list=elements_sorted,
|
||||
view=|elt| {
|
||||
let state = use_context::<AppState>();
|
||||
let class = create_memo({
|
||||
let id = elt.id.clone();
|
||||
move || {
|
||||
if state.selection.with(|sel| sel.contains(&id)) {
|
||||
"selected"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
});
|
||||
let label = elt.label.clone();
|
||||
let rep_components = elt.rep.iter().map(|u| {
|
||||
let u_coord = u.to_string().replace("-", "\u{2212}");
|
||||
View::from(div().children(u_coord))
|
||||
}).collect::<Vec<_>>();
|
||||
view! {
|
||||
/* [TO DO] switch to integer-valued parameters whenever
|
||||
that becomes possible again */
|
||||
li(
|
||||
class=class.get(),
|
||||
tabindex="0",
|
||||
on:click={
|
||||
let id = elt.id.clone();
|
||||
move |_| {
|
||||
state.selection.update(|sel| {
|
||||
if !sel.remove(&id) {
|
||||
sel.insert(id.clone());
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
on:keydown={
|
||||
let id = elt.id.clone();
|
||||
move |event: KeyboardEvent| {
|
||||
if event.key() == "Enter" {
|
||||
state.selection.update(|sel| {
|
||||
if !sel.remove(&id) {
|
||||
sel.insert(id.clone());
|
||||
}
|
||||
});
|
||||
event.prevent_default();
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
div(class="elt-label") { (label) }
|
||||
div(class="elt-rep") { (rep_components) }
|
||||
}
|
||||
}
|
||||
},
|
||||
key=|elt| elt.id.clone()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user