1use std::cell::RefCell;
6use std::collections::HashMap;
7use std::rc::Rc;
8
9use crate::prelude::*;
10use ravenrs_core::Key;
11use ravenrs_layout::{Align, Axis, LayoutStyle};
12use ravenrs_report::barcode::Symbology;
13use ravenrs_report::chart::ChartKind;
14use ravenrs_report::wizard::{self, FieldInfo, FieldKind, WizardOptions};
15use ravenrs_report::{
16 Control, DataSource, DataSourceKind, DateTime, Line, Report, Style, Table, TableRow, TextAlign, samples,
17};
18use ravenrs_report_viewer::{DocumentViewer, ParameterLabels, ParameterPanel, ViewerLabels, ViewerState};
19use ravenrs_style::{RADII, SPACING};
20use ravenrs_widgets::{DragPhase, Paint, Placement, QuadPaint};
21
22pub(crate) mod prelude {
24 pub use ravenrs_core::{Color, CursorIcon, Insets, Point, Quad, Rect, Scene};
25 pub use ravenrs_layout::Align;
26 pub use ravenrs_reactive::{Binding, State};
27 pub use ravenrs_style::{Semantic, TextStyle, Theme, Weight};
28 pub use ravenrs_widgets::*;
29}
30
31mod bands;
32mod editor;
33mod preview;
34mod props;
35mod surface;
36
37use bands::BandRef;
38use editor::{Editor, FieldDrag, Selection};
39use preview::RenderCache;
40
41pub struct Designer {
43 embedded: Option<Embedded>,
44 editor: Editor,
45 cache: Rc<RefCell<RenderCache>>,
46 params: State<HashMap<String, serde_json::Value>>,
47 left_tab: State<usize>,
48 wizard: WizardState,
49}
50
51#[derive(Clone)]
53struct Embedded {
54 save_label: String,
55 close_label: String,
56 on_save: Rc<dyn Fn(Report)>,
57 on_close: Rc<dyn Fn()>,
58}
59
60#[derive(Clone)]
61struct WizardState {
62 source: State<usize>,
63 title: State<String>,
64 chosen: State<Vec<String>>,
65 group: State<usize>,
66 totals: State<bool>,
67}
68
69impl std::fmt::Debug for Designer {
70 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71 f.debug_struct("Designer").finish_non_exhaustive()
72 }
73}
74
75impl Default for Designer {
76 fn default() -> Self {
77 Self::new()
78 }
79}
80
81impl Designer {
82 pub fn new() -> Self {
84 Self {
85 embedded: None,
86 editor: Editor::new(samples::sales_by_region(300)),
87 cache: Rc::new(RefCell::new(RenderCache::default())),
88 params: State::new(HashMap::new()),
89 left_tab: State::new(0),
90 wizard: WizardState {
91 source: State::new(0),
92 title: State::new("Relatório de vendas".into()),
93 chosen: State::new(vec![
94 "Date".into(),
95 "Customer".into(),
96 "Product".into(),
97 "Quantity".into(),
98 "UnitPrice".into(),
99 ]),
100 group: State::new(4),
101 totals: State::new(true),
102 },
103 }
104 }
105}
106
107impl Designer {
108 pub fn embedded(
112 report: Report,
113 save_label: impl Into<String>,
114 close_label: impl Into<String>,
115 on_save: impl Fn(Report) + 'static,
116 on_close: impl Fn() + 'static,
117 ) -> Self {
118 let mut designer = Self::new();
119 designer.editor = Editor::new(report);
120 designer.editor.gallery_open.set(false);
121 designer.embedded = Some(Embedded {
122 save_label: save_label.into(),
123 close_label: close_label.into(),
124 on_save: Rc::new(on_save),
125 on_close: Rc::new(on_close),
126 });
127 designer
128 }
129
130 fn embedded_bar(&self) -> Option<AnyView> {
131 let embedded = self.embedded.clone()?;
132 let (editor, on_save, on_close) = (self.editor.clone(), embedded.on_save, embedded.on_close);
133 Some(Box::new(
134 HStack::new((
135 Button::new(embedded.save_label).size(ButtonSize::Small).on_click(move || {
136 on_save(editor.report.get());
137 }),
138 Button::new(embedded.close_label)
139 .style(ButtonStyle::Ghost)
140 .size(ButtonSize::Small)
141 .on_click(move || on_close()),
142 ))
143 .spacing(SPACING.s2)
144 .align(Align::Center)
145 .padding(SPACING.s2),
146 ))
147 }
148}
149
150const WIZARD_SOURCES: [(&str, usize); 2] =
152 [("Vendas (300 pedidos)", 300), ("Vendas (10.000 pedidos)", 10_000)];
153
154pub(crate) const TOOLBOX_WIDTH: f32 = 48.0;
156pub(crate) const DOCK_WIDTH: f32 = 320.0;
158const ZOOM_LEAST: f32 = 0.5;
160const ZOOM_MOST: f32 = 3.0;
161
162impl Component for Designer {
163 fn body(&self) -> impl View {
164 let e = self.editor.clone();
165 let design = e.tab.get() == 0;
167 let on_its_own = self.embedded.is_none();
168 VStack::new((
169 self.embedded_bar(),
170 design.then(|| self.ribbon()),
171 FillRow((
172 design.then(|| self.toolbox()),
173 design.then(Divider::vertical),
174 CenterPane { editor: e.clone(), cache: self.cache.clone(), params: self.params.clone() },
175 design.then(Divider::vertical),
176 design.then(|| self.dock()),
177 )),
178 Divider::horizontal(),
179 StatusBar { editor: e.clone(), cache: self.cache.clone(), themes: on_its_own },
180 on_its_own.then(|| Gallery { editor: e.clone() }),
181 on_its_own.then(|| WizardDialog { editor: e.clone(), state: self.wizard.clone() }),
182 ExpressionDialog(e.clone()),
183 FieldGhost(e.clone()),
184 Shortcuts { editor: e, cache: self.cache.clone(), params: self.params.clone() },
185 ))
186 .spacing(0.0)
187 .align(Align::Stretch)
188 .grow(1.0)
189 }
190}
191
192fn view_tabs(e: &Editor) -> impl View + use<> {
195 HStack::new((Tabs::new(e.tab.binding())
196 .item("Design")
197 .item("Visualizar")
198 .item("JSON")
199 .size(ButtonSize::Small),))
200 .padding(Insets::symmetric(0.0, SPACING.s3))
201 .background(Semantic::Surface)
202}
203
204struct FillRow<C>(C);
207
208impl<C: ViewSeq> View for FillRow<C> {
209 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
210 let children = cx.children(&self.0);
211 cx.container(
212 LayoutStyle {
213 grow: 1.0,
214 min_height: Some(0.0),
215 overflow_hidden: true,
216 align: Align::Stretch,
217 ..LayoutStyle::stack(Axis::Horizontal)
218 },
219 &children,
220 Paint::None,
221 )
222 }
223}
224
225struct Column<V>(V, f32);
228
229impl<V: View> View for Column<V> {
230 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
231 let theme = *cx.theme();
232 let content = cx.child(&self.0);
233 cx.container(
234 LayoutStyle {
235 width: Some(self.1),
236 min_height: Some(0.0),
237 overflow_hidden: true,
238 align: Align::Stretch,
239 ..LayoutStyle::stack(Axis::Vertical)
240 },
241 &[content],
242 Paint::Quad(QuadPaint::new(theme.color(Semantic::Surface))),
243 )
244 }
245}
246
247fn print_report(
249 editor: &Editor,
250 cache: &Rc<RefCell<RenderCache>>,
251 params: &State<HashMap<String, serde_json::Value>>,
252) {
253 let doc = preview::document(editor, cache, ¶ms.get());
254 preview::print(&doc);
255}
256
257fn export_report(
259 editor: &Editor,
260 cache: &Rc<RefCell<RenderCache>>,
261 params: &State<HashMap<String, serde_json::Value>>,
262 format: preview::Export,
263) {
264 let doc = preview::document(editor, cache, ¶ms.get());
265 preview::export(&doc, format);
266}
267
268impl Designer {
269 fn ribbon(&self) -> impl View + use<> {
270 let e = self.editor.clone();
271 let exporter = |item: RibbonItem, format: preview::Export| {
272 let (ed, cache, params) = (e.clone(), self.cache.clone(), self.params.clone());
273 item.on_click(move || export_report(&ed, &cache, ¶ms, format))
274 };
275 let export_item = |label: &'static str, icon: IconName, format: preview::Export| {
276 let (ed, cache, params) = (e.clone(), self.cache.clone(), self.params.clone());
277 MenuItem::new(label).icon(icon).on_select(move || export_report(&ed, &cache, ¶ms, format))
278 };
279 let printer = |item: RibbonItem| {
280 let (ed, cache, params) = (e.clone(), self.cache.clone(), self.params.clone());
281 item.on_click(move || print_report(&ed, &cache, ¶ms))
282 };
283 let (pm_e, pm_c, pm_p) = (e.clone(), self.cache.clone(), self.params.clone());
284 let (e1, e2, e3, e4, e5, e6) = (e.clone(), e.clone(), e.clone(), e.clone(), e.clone(), e.clone());
285 let (z1, z2) = (e.clone(), e.clone());
286 let selected = e.selected_control().is_some();
287 let action = |label: &'static str, icon: IconName, run: fn(&Editor)| {
288 let e = e.clone();
289 RibbonItem::button(label, icon).on_click(move || run(&e))
290 };
291 let style_toggle =
293 |label: &'static str, icon: IconName, is: fn(&Style) -> bool, set: fn(&mut Style, bool)| {
294 let (read, write) = (e.clone(), e.clone());
295 RibbonItem::toggle(
296 label,
297 icon,
298 Binding::new(
299 move || read.selected_control().is_some_and(|(_, _, c)| is(&c.style)),
300 move |on| write.edit_control(label, |c| set(&mut c.style, on)),
301 ),
302 )
303 .small()
304 .enabled(selected)
305 };
306 let align = |label: &'static str, icon: IconName, to: TextAlign| {
307 let (read, write) = (e.clone(), e.clone());
308 RibbonItem::toggle(
309 label,
310 icon,
311 Binding::new(
312 move || {
313 read.selected_control()
314 .is_some_and(|(_, _, c)| c.style.text_align.unwrap_or_default() == to)
315 },
316 move |_| write.edit_control("align", |c| c.style.text_align = Some(to)),
317 ),
318 )
319 .small()
320 .enabled(selected)
321 };
322 let font_step = |label: &'static str, icon: IconName, by: f32| {
323 let e = e.clone();
324 RibbonItem::button(label, icon).small().enabled(selected).on_click(move || {
325 e.edit_control("font size", |c| {
326 c.style.font_size = Some((c.style.font_size.unwrap_or(10.0) + by).clamp(4.0, 72.0));
327 })
328 })
329 };
330 let on_its_own = self.embedded.is_none();
331 let mut file_menu = vec![
332 MenuItem::new("Imprimir…")
333 .icon(IconName::Printer)
334 .shortcut("⌘P")
335 .on_select(move || print_report(&pm_e, &pm_c, &pm_p)),
336 export_item("Exportar para PDF", IconName::FileText, preview::Export::Pdf),
337 export_item("Exportar para Excel", IconName::Table, preview::Export::Xlsx),
338 export_item("Exportar para CSV", IconName::Download, preview::Export::Csv),
339 MenuItem::new("Ver o JSON do relatório").icon(IconName::Code).on_select(move || e3.tab.set(2)),
340 ];
341 if on_its_own {
342 file_menu.insert(
343 0,
344 MenuItem::new("Assistente de relatório…")
345 .icon(IconName::Sparkles)
346 .on_select(move || e2.wizard_open.set(true)),
347 );
348 file_menu.insert(
349 0,
350 MenuItem::new("Novo a partir de modelo…")
351 .icon(IconName::FilePlus)
352 .on_select(move || e1.gallery_open.set(true)),
353 );
354 }
355 let home = RibbonPage::new("Início");
356 let home = if on_its_own {
357 home.group(
358 RibbonGroup::new("Relatório")
359 .item(
360 RibbonItem::button("Modelos", IconName::LayoutDashboard)
361 .large()
362 .on_click(move || e6.gallery_open.set(true)),
363 )
364 .item({
365 let e = e.clone();
366 RibbonItem::button("Assistente", IconName::Sparkles)
367 .large()
368 .on_click(move || e.wizard_open.set(true))
369 }),
370 )
371 } else {
372 home
373 };
374 let has_copy = e.copied.with(Option::is_some);
375 let home = home
377 .group(
378 RibbonGroup::new("Editar")
379 .item(action("Colar", IconName::ClipboardPaste, Editor::paste).large().enabled(has_copy))
380 .item(
381 action("Recortar", IconName::Scissors, Editor::cut_selected)
382 .small()
383 .enabled(selected),
384 )
385 .item(action("Copiar", IconName::Copy, Editor::copy_selected).small().enabled(selected))
386 .item(
387 action("Excluir", IconName::Trash2, Editor::delete_selected)
388 .small()
389 .enabled(selected),
390 ),
391 )
392 .group(RibbonGroup::new("Dados").item({
393 let e = e.clone();
394 RibbonItem::button("Expressão", IconName::Code)
395 .large()
396 .enabled(e.control_has_expression())
397 .on_click(move || e.open_expression(editor::ExpressionTarget::Control))
398 }))
399 .group(
400 RibbonGroup::new("Fonte")
401 .item(style_toggle(
402 "Negrito",
403 IconName::Bold,
404 |s| s.bold == Some(true),
405 |s, on| s.bold = on.then_some(true),
406 ))
407 .item(font_step("Aumentar fonte", IconName::Plus, 1.0))
408 .item(font_step("Diminuir fonte", IconName::Minus, -1.0)),
409 )
410 .group(
411 RibbonGroup::new("Alinhamento")
412 .item(align("À esquerda", IconName::AlignLeft, TextAlign::Left))
413 .item(align("Centralizar", IconName::AlignCenter, TextAlign::Center))
414 .item(align("À direita", IconName::AlignRight, TextAlign::Right)),
415 )
416 .group(
417 RibbonGroup::new("Layout")
418 .item({
419 let e = e.clone();
420 RibbonItem::button("Trazer para a frente", IconName::ArrowUp)
421 .small()
422 .enabled(selected)
423 .on_click(move || e.reorder_selected(true))
424 })
425 .item({
426 let e = e.clone();
427 RibbonItem::button("Enviar para trás", IconName::ArrowDown)
428 .small()
429 .enabled(selected)
430 .on_click(move || e.reorder_selected(false))
431 }),
432 )
433 .group(
434 RibbonGroup::new("Zoom")
435 .item(
436 RibbonItem::button("Aproximar", IconName::ZoomIn)
437 .small()
438 .on_click(move || z1.zoom.update(|z| *z = (*z + 0.25).min(ZOOM_MOST))),
439 )
440 .item(
441 RibbonItem::button("Afastar", IconName::ZoomOut)
442 .small()
443 .on_click(move || z2.zoom.update(|z| *z = (*z - 0.25).max(ZOOM_LEAST))),
444 ),
445 );
446 let mut controls = RibbonGroup::new("Controles");
447 for (i, (label, icon, make)) in TOOLS.into_iter().enumerate() {
448 let e = e.clone();
449 let item = RibbonItem::button(label, icon).on_click(move || insert_tool(&e, make));
450 controls = controls.item(if i < 6 { item.large() } else { item.small() });
451 }
452 Ribbon::new()
453 .app_menu("Arquivo", file_menu)
454 .quick_access(RibbonItem::button("Desfazer", IconName::Undo2).on_click(move || e4.undo()))
455 .quick_access(RibbonItem::button("Refazer", IconName::Redo2).on_click(move || e5.redo()))
456 .quick_access(printer(RibbonItem::button("Imprimir", IconName::Printer)))
457 .page(home)
458 .page(RibbonPage::new("Inserir").group(controls))
459 .page(
460 RibbonPage::new("Saída").group(
461 RibbonGroup::new("Saída")
462 .item(printer(RibbonItem::button("Imprimir", IconName::Printer).large()))
463 .item(exporter(
464 RibbonItem::button("PDF", IconName::FileText).large(),
465 preview::Export::Pdf,
466 ))
467 .item(exporter(
468 RibbonItem::button("Excel", IconName::Table).large(),
469 preview::Export::Xlsx,
470 ))
471 .item(exporter(
472 RibbonItem::button("CSV", IconName::Download).small(),
473 preview::Export::Csv,
474 )),
475 ),
476 )
477 }
478
479 fn toolbox(&self) -> impl View + use<> {
482 let e = self.editor.clone();
483 let pick = Binding::new(
484 || 0,
485 move |i: usize| {
486 if let Some((_, _, make)) = i.checked_sub(1).and_then(|i| TOOLS.get(i)) {
487 insert_tool(&e, *make);
488 }
489 },
490 );
491 let mut rail =
492 ToolRail::new(pick, [(IconName::MousePointer, "Ponteiro")]).icons_only(true).separator();
493 for (name, icon, _) in TOOLS {
494 rail = rail.tool(ToolRailItem::new(icon, name));
495 }
496 Column(ScrollView::new((rail.width(TOOLBOX_WIDTH),)).grow(1.0), TOOLBOX_WIDTH)
497 }
498
499 fn dock(&self) -> impl View + use<> {
502 let e = self.editor.clone();
503 let fixed = State::new(0);
504 let top = VStack::new((
505 Tabs::new(self.left_tab.binding())
506 .item("Lista de campos")
507 .item("Explorador")
508 .size(ButtonSize::Small),
509 VStack::new((LeftContent { editor: e.clone(), tab: self.left_tab.clone() },))
510 .padding(SPACING.s3)
511 .align(Align::Stretch)
512 .grow(1.0),
513 ))
514 .align(Align::Stretch)
515 .grow(1.0);
516 let bottom = VStack::new((
517 Tabs::new(fixed.binding()).item("Propriedades").size(ButtonSize::Small),
518 props::Properties(e),
519 ))
520 .align(Align::Stretch)
521 .grow(1.0);
522 Column(
523 SplitPanes::vertical()
524 .pane(SplitPane::new(top).size(320.0).range(160.0, 640.0))
525 .pane(SplitPane::new(bottom).range(220.0, f32::MAX)),
526 DOCK_WIDTH,
527 )
528 }
529}
530
531type Tool = (&'static str, IconName, fn() -> Control);
533
534const TOOLS: [Tool; 13] =
536 [
537 ("Rótulo", IconName::Type, || Control::label("Texto").at(0.0, 0.0, 140.0, 18.0)),
538 ("Campo", IconName::Database, || Control::field("[Customer]").at(0.0, 0.0, 140.0, 18.0)),
539 ("Tabela", IconName::Table, || {
540 Control::table(Table::new().row(
541 TableRow::new(18.0).text(120.0, "Coluna 1").text(120.0, "Coluna 2").text(120.0, "Coluna 3"),
542 ))
543 }),
544 ("Imagem", IconName::Image, || Control::picture("").at(0.0, 0.0, 100.0, 60.0)),
545 ("Código de barras", IconName::Tag, || {
546 Control::barcode(Symbology::Code128, "[Id]").at(0.0, 0.0, 160.0, 50.0)
547 }),
548 ("Gráfico", IconName::ChartColumn, || {
549 Control::chart(ChartKind::Bar, "[Region]", "[Quantity] * [UnitPrice]").at(0.0, 0.0, 320.0, 180.0)
550 }),
551 ("Caixa de seleção", IconName::CircleCheck, || {
552 Control::check_box("[Paid]", "Pago").at(0.0, 0.0, 100.0, 18.0)
553 }),
554 ("Linha", IconName::Minus, || Control {
555 kind: ravenrs_report::ControlKind::Line(Line::default()),
556 ..Control::line().at(0.0, 0.0, 200.0, 4.0)
557 }),
558 ("Retângulo", IconName::Square, || {
559 Control::rect(4.0)
560 .at(0.0, 0.0, 120.0, 40.0)
561 .style(Style::new().background("#f4f5f8").border("all", 0.75, "#dadde5"))
562 }),
563 ("Total", IconName::Sigma, || {
564 Control::field("Sum([Total])").format("C2").at(0.0, 0.0, 140.0, 18.0).style(Style::new().bold())
565 }),
566 ("Página N de M", IconName::FileText, || {
567 Control::field("FormatString('Página {0} de {1}', PageNumber(), PageCount())")
568 .at(0.0, 0.0, 160.0, 16.0)
569 }),
570 ("Link", IconName::Link, || Control::link("Abrir site", "https://").at(0.0, 0.0, 140.0, 18.0)),
571 ("Marcador", IconName::Bookmark, || Control::bookmark("[Customer]", 0).at(0.0, 0.0, 120.0, 12.0)),
572 ];
573
574fn insert_tool(e: &Editor, make: fn() -> Control) {
576 let band = e.target_band();
577 let mut c = make();
578 let (x, y) = e.free_spot(band, c.height);
579 c.x = x;
580 c.y = y;
581 e.add_control(band, c);
582 e.tab.set(0);
583}
584
585struct LeftContent {
586 editor: Editor,
587 tab: State<usize>,
588}
589
590impl View for LeftContent {
591 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
592 let e = &self.editor;
593 let content: AnyView =
594 if self.tab.get() == 0 { Box::new(FieldList(e.clone())) } else { Box::new(BandList(e.clone())) };
595 cx.child(&ScrollView::new((content,)).grow(1.0))
596 }
597}
598
599struct FieldList(Editor);
601
602impl View for FieldList {
603 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
604 let e = &self.0;
605 let report = e.report.get();
606 let source = report.data_source.clone().unwrap_or_else(|| "Dados".into());
607 let data = report.embedded_data().cloned().unwrap_or_default();
608 let mut sections: Vec<AnyView> = vec![Box::new(
609 HStack::new((
610 Icon::new(IconName::Database).size(14.0),
611 Text::new(source).font_weight(Weight::SEMIBOLD),
612 ))
613 .spacing(SPACING.s2)
614 .align(Align::Center),
615 )];
616 let fields = wizard::fields(&data, report.data_member.as_deref());
617 let mut rows: Vec<AnyView> = Vec::new();
618 for f in &fields {
619 rows.push(Box::new(FieldItem { editor: e.clone(), field: f.clone(), collection: None }));
620 }
621 for f in report.calculated_fields.iter() {
622 rows.push(Box::new(FieldItem {
623 editor: e.clone(),
624 field: FieldInfo { path: f.name.clone(), kind: FieldKind::Number },
625 collection: None,
626 }));
627 }
628 for f in fields.iter().filter(|f| f.kind == FieldKind::Collection) {
630 let items = wizard::rows_of(&data, report.data_member.as_deref())
631 .first()
632 .and_then(|row| row.get(&f.path))
633 .cloned()
634 .unwrap_or_default();
635 for sub in wizard::fields(&items, None) {
636 rows.push(Box::new(FieldItem {
637 editor: e.clone(),
638 field: sub,
639 collection: Some(f.path.clone()),
640 }));
641 }
642 }
643 sections.push(Box::new(VStack::new(rows).spacing(SPACING.s1).align(Align::Stretch)));
644 sections.push(Box::new(
645 Text::new("Arraste um campo até uma banda, ou clique para inserir na banda selecionada.")
646 .style(TextStyle::Callout)
647 .foreground(Semantic::InkMuted),
648 ));
649 cx.child(&VStack::new(sections).spacing(SPACING.s3).align(Align::Stretch))
650 }
651}
652
653struct FieldItem {
654 editor: Editor,
655 field: FieldInfo,
656 collection: Option<String>,
657}
658
659impl View for FieldItem {
660 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
661 let icon = match self.field.kind {
662 FieldKind::Number => IconName::Sigma,
663 FieldKind::Date => IconName::Calendar,
664 FieldKind::Boolean => IconName::CircleCheck,
665 FieldKind::Collection => IconName::List,
666 _ => IconName::Type,
667 };
668 let label = match &self.collection {
669 Some(c) => format!("{c} › {}", self.field.path),
670 None => self.field.path.clone(),
671 };
672 let item = SidebarItem::new(icon, label).compact();
673 if self.field.kind == FieldKind::Collection {
674 return cx.child(&item);
675 }
676 let (kind, path, collection) = (self.field.kind, self.field.path.clone(), self.collection.clone());
677 let dragged = Rc::new(std::cell::Cell::new(false));
679 let (e, was_dragged) = (self.editor.clone(), dragged.clone());
680 let add_path = path.clone();
681 let item = item.on_click(move || {
682 if was_dragged.replace(false) {
683 return;
684 }
685 let band = match &collection {
686 Some(c) => e
687 .report
688 .with(|r| r.detail_reports.iter().position(|d| &d.data_member == c))
689 .map_or(e.target_band(), BandRef::DetailReportDetail),
690 None => e.target_band(),
691 };
692 let (x, y) = e.free_spot(band, 18.0);
693 e.add_control(band, field_control(&add_path, kind).at(x, y, 140.0, 18.0));
694 e.tab.set(0);
695 });
696 let row = cx.child(&item);
697 let e = self.editor.clone();
698 cx.on_drag(
699 row,
700 Rc::new(move |d| match d.phase {
701 DragPhase::Start => {
702 e.drag.set(Some(FieldDrag { path: path.clone(), start: d.position, at: d.position }))
703 }
704 DragPhase::Move => e.drag.update(|g| {
705 if let Some(g) = g {
706 g.at = d.position;
707 }
708 }),
709 _ => {
710 let Some(g) = e.drag.get() else { return };
711 e.drag.set(None);
712 if (g.at.x - g.start.x).hypot(g.at.y - g.start.y) <= 4.0 {
713 return;
714 }
715 dragged.set(true);
717 if let Some((band, at)) = e.band_at(d.position) {
718 dragged.set(false);
719 let c = field_control(&path, kind).at(
720 editor::snap(at.x.max(0.0)),
721 editor::snap((at.y - 9.0).max(0.0)),
722 140.0,
723 18.0,
724 );
725 e.add_control(band, c);
726 e.tab.set(0);
727 }
728 }
729 }),
730 );
731 row
732 }
733}
734
735fn field_control(path: &str, kind: FieldKind) -> Control {
737 let c = Control::field(format!("[{path}]"));
738 match kind {
739 FieldKind::Date => c.format("dd/MM/yyyy"),
740 FieldKind::Number
741 if path.to_lowercase().contains("price") || path.to_lowercase().contains("total") =>
742 {
743 c.format("C2").style(Style::new().align(ravenrs_report::TextAlign::Right))
744 }
745 FieldKind::Number => c.style(Style::new().align(ravenrs_report::TextAlign::Right)),
746 _ => c,
747 }
748}
749
750struct BandList(Editor);
752
753impl View for BandList {
754 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
755 let e = &self.0;
756 let report = e.report.get();
757 let selection = e.selection.get();
758 let items: Vec<AnyView> = BandRef::all(&report)
759 .into_iter()
760 .map(|b| {
761 let count = b.get(&report).map_or(0, |band| band.controls.len());
762 let selected = matches!(selection, Selection::Band(s) | Selection::Control(s, _) if s == b);
763 let sel = e.selection.clone();
764 Box::new(
765 SidebarItem::new(IconName::Rows3, b.title(&report))
766 .compact()
767 .count(count.to_string())
768 .selected(selected)
769 .on_click(move || sel.set(Selection::Band(b))),
770 ) as AnyView
771 })
772 .collect();
773 cx.child(&VStack::new(items).spacing(SPACING.s1).align(Align::Stretch))
774 }
775}
776
777struct FieldGhost(Editor);
779
780impl View for FieldGhost {
781 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
782 let anchor = cx.empty();
783 if let Some(g) = self.0.drag.get().filter(|g| (g.at.x - g.start.x).hypot(g.at.y - g.start.y) > 4.0) {
784 let over = self.0.band_at(g.at).is_some();
785 let chip = Badge::new(format!("[{}]", g.path))
786 .icon(if over { IconName::Plus } else { IconName::Database })
787 .tone(if over { Tone::Accent } else { Tone::Neutral });
788 cx.overlay(anchor, Placement::At(Point::new(g.at.x + 12.0, g.at.y + 8.0)), &chip, None);
789 }
790 anchor
791 }
792}
793
794struct Shortcuts {
796 editor: Editor,
797 cache: Rc<RefCell<RenderCache>>,
798 params: State<HashMap<String, serde_json::Value>>,
799}
800
801impl View for Shortcuts {
802 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
803 let e = &self.editor;
804 let (u, r, d, b) = (e.clone(), e.clone(), e.clone(), e.clone());
805 let (pe, pc, pp) = (e.clone(), self.cache.clone(), self.params.clone());
806 cx.shortcut(Key::Character("p".into()), true, Rc::new(move || print_report(&pe, &pc, &pp)));
807 cx.shortcut(Key::Character("z".into()), true, Rc::new(move || u.undo()));
808 cx.shortcut(Key::Character("y".into()), true, Rc::new(move || r.redo()));
809 cx.shortcut(Key::Delete, false, Rc::new(move || d.delete_selected()));
810 cx.shortcut(Key::Backspace, false, Rc::new(move || b.delete_selected()));
811 cx.empty()
812 }
813}
814
815struct CenterPane {
817 editor: Editor,
818 cache: Rc<RefCell<RenderCache>>,
819 params: State<HashMap<String, serde_json::Value>>,
820}
821
822impl View for CenterPane {
823 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
824 let e = &self.editor;
825 let theme = *cx.theme();
826 let view: AnyView = match e.tab.get() {
827 1 => Box::new(PreviewPane {
828 editor: e.clone(),
829 cache: self.cache.clone(),
830 params: self.params.clone(),
831 }),
832 2 => Box::new(JsonPane(e.clone())),
833 _ => Box::new(ScrollView::new((surface::Surface(e.clone()),)).horizontal().grow(1.0)),
834 };
835 let content = cx.child(&view);
836 let node = cx.container(
837 LayoutStyle {
838 grow: 1.0,
839 min_width: Some(0.0),
840 min_height: Some(0.0),
841 overflow_hidden: true,
842 align: Align::Stretch,
843 ..LayoutStyle::stack(Axis::Vertical)
844 },
845 &[content],
846 Paint::Quad(QuadPaint::new(theme.color(Semantic::Canvas))),
847 );
848 let sel = e.selection.clone();
850 cx.on_click(node, Rc::new(move || sel.set(Selection::None)));
851 let divider = cx.child(&Divider::horizontal());
852 let tabs = cx.child(&view_tabs(e));
853 cx.container(
854 LayoutStyle {
855 grow: 1.0,
856 min_width: Some(0.0),
857 min_height: Some(0.0),
858 overflow_hidden: true,
859 align: Align::Stretch,
860 ..LayoutStyle::stack(Axis::Vertical)
861 },
862 &[node, divider, tabs],
863 Paint::None,
864 )
865 }
866}
867
868struct PreviewPane {
869 editor: Editor,
870 cache: Rc<RefCell<RenderCache>>,
871 params: State<HashMap<String, serde_json::Value>>,
872}
873
874impl View for PreviewPane {
875 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
876 let e = &self.editor;
877 let params = self.params.get();
878 let doc = preview::document(e, &self.cache, ¶ms);
879 let state = cx.use_state(|| ViewerState::sharing(e.page.clone(), e.zoom.clone()));
880 let report = e.report.get();
881 let (pe, pc, pp) = (e.clone(), self.cache.clone(), self.params.clone());
882 let viewer = DocumentViewer::new(doc, &state.get())
884 .labels(ViewerLabels::pt_br())
885 .print_button(false)
886 .toolbar_trailing(move || {
887 let print = (pe.clone(), pc.clone(), pp.clone());
888 let mut items: Vec<AnyView> = vec![Box::new(
889 Button::new("Imprimir")
890 .icon(IconName::Printer)
891 .size(ButtonSize::Small)
892 .on_click(move || print_report(&print.0, &print.1, &print.2)),
893 )];
894 for (label, icon, format) in [
895 ("PDF", IconName::FileText, preview::Export::Pdf),
896 ("Excel", IconName::Table, preview::Export::Xlsx),
897 ] {
898 let (e, c, p) = (pe.clone(), pc.clone(), pp.clone());
899 items.push(Box::new(
900 Button::new(label)
901 .icon(icon)
902 .size(ButtonSize::Small)
903 .on_click(move || export_report(&e, &c, &p, format)),
904 ));
905 }
906 items
907 });
908 let viewer = cx.child(&viewer);
909 let mut children = Vec::new();
910 if !report.parameters.is_empty() {
911 let (year, month, day) = DateTime::now().ymd();
912 if let Some(today) = Date::new(year, month as u8, day as u8) {
913 let panel = ParameterPanel::new(report.parameters.clone(), self.params.clone(), today)
914 .labels(ParameterLabels::pt_br());
915 children.push(cx.child(&panel));
916 children.push(cx.child(&Divider::horizontal()));
917 }
918 }
919 children.push(viewer);
920 cx.container(
921 LayoutStyle {
922 grow: 1.0,
923 min_height: Some(0.0),
924 overflow_hidden: true,
925 align: Align::Stretch,
926 ..LayoutStyle::stack(Axis::Vertical)
927 },
928 &children,
929 Paint::None,
930 )
931 }
932}
933
934struct JsonPane(Editor);
936
937impl View for JsonPane {
938 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
939 let report = self.0.report.get();
940 let full = report.to_json();
941 let mut shown = report.clone();
942 for s in &mut shown.data_sources {
943 if let DataSourceKind::Json { data: serde_json::Value::Array(rows) } = &mut s.kind
944 && rows.len() > 2
945 {
946 let total = rows.len();
947 rows.truncate(2);
948 rows.push(serde_json::json!(format!("… mais {} linhas", total - 2)));
949 }
950 }
951 let clipboard = cx.clipboard();
952 let copied = cx.use_state(|| false);
953 let c2 = copied.clone();
954 let lines: Vec<AnyView> = shown
955 .to_json()
956 .lines()
957 .map(|l| Box::new(Text::new(l.replace(' ', "\u{a0}")).style(TextStyle::Callout)) as AnyView)
958 .collect();
959 cx.child(
960 &VStack::new((
961 HStack::new((
962 VStack::new((
963 Text::new("Definição do relatório").style(TextStyle::Headline),
964 Text::new("O layout e as fontes de dados ficam em um único arquivo .rvreport.json. O servidor de relatórios usa o mesmo arquivo.")
965 .style(TextStyle::Callout)
966 .foreground(Semantic::InkMuted),
967 ))
968 .spacing(SPACING.s1)
969 .grow(1.0),
970 Button::new(if copied.get() { "Copiado" } else { "Copiar JSON" })
971 .icon(if copied.get() { IconName::Check } else { IconName::Copy })
972 .style(ButtonStyle::Secondary)
973 .on_click(move || {
974 clipboard.write(&full);
975 c2.set(true);
976 }),
977 ))
978 .align(Align::Center)
979 .spacing(SPACING.s3)
980 .padding(Insets::symmetric(SPACING.s3, SPACING.s5)),
981 ScrollView::new((VStack::new(lines).padding(Insets::symmetric(SPACING.s2, SPACING.s5)),)).horizontal().grow(1.0),
982 ))
983 .align(Align::Stretch)
984 .grow(1.0),
985 )
986 }
987}
988
989struct StatusBar {
990 themes: bool,
992 editor: Editor,
993 cache: Rc<RefCell<RenderCache>>,
994}
995
996impl View for StatusBar {
997 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
998 let e = &self.editor;
999 let report = e.report.get();
1000 let cache = self.cache.borrow();
1001 let (page, render) = match (&cache.doc, e.tab.get()) {
1002 (Some(doc), 1) => {
1003 let pages = doc.pages.len().max(1);
1004 let current = e.page.get().min(pages - 1) + 1;
1005 let millis = format!("{:.1}", cache.millis).replace('.', ",");
1006 (Some(format!("Página {current} de {pages}")), format!("Gerado em {millis} ms"))
1007 }
1008 _ => (None, "Abra Visualizar para gerar o documento".to_owned()),
1009 };
1010 let rows =
1011 report.embedded_data().map_or(0, |d| wizard::rows_of(d, report.data_member.as_deref()).len());
1012 let paper = if report.page.width > report.page.height { "paisagem" } else { "retrato" };
1013 drop(cache);
1014 let muted = |text: String| Text::new(text).style(TextStyle::Callout).foreground(Semantic::InkMuted);
1015 let themes = [Theme::raven_dark(), Theme::raven_light(), Theme::raven_glass()];
1016 let state = cx.theme_state();
1017 let read = state.clone();
1018 let theme = Binding::new(
1019 move || read.with(|t| themes.iter().position(|x| x.name == t.name).unwrap_or(0)),
1020 move |i: usize| state.set(themes[i.min(themes.len() - 1)]),
1021 );
1022 cx.child(
1023 &HStack::new((
1024 muted(format!("{} · {} linhas de dados · {} · {}", report.name, rows, report.culture, paper)),
1025 Spacer::new(),
1026 page.map(muted),
1027 muted(render),
1028 ZoomBar(e.clone()),
1029 self.themes.then(|| {
1030 SegmentedControl::with_icons(
1031 theme,
1032 [(IconName::Moon, "Escuro"), (IconName::Sun, "Claro"), (IconName::Layers, "Vidro")],
1033 )
1034 .label("Tema")
1035 }),
1036 ))
1037 .spacing(SPACING.s3)
1038 .align(Align::Center)
1039 .padding(Insets::symmetric(SPACING.s1, SPACING.s4))
1040 .background(Semantic::Surface),
1041 )
1042 }
1043}
1044
1045struct ZoomBar(Editor);
1047
1048impl View for ZoomBar {
1049 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1050 let zoom = &self.0.zoom;
1051 let (out, into) = (zoom.clone(), zoom.clone());
1052 cx.child(
1053 &HStack::new((
1054 IconButton::new(IconName::Minus)
1055 .label("Afastar")
1056 .on_click(move || out.update(|z| *z = (*z - 0.25).max(ZOOM_LEAST))),
1057 Slider::new(zoom.binding())
1058 .range(ZOOM_LEAST, ZOOM_MOST)
1059 .step(0.05)
1060 .width(120.0)
1061 .label("Zoom"),
1062 IconButton::new(IconName::Plus)
1063 .label("Aproximar")
1064 .on_click(move || into.update(|z| *z = (*z + 0.25).min(ZOOM_MOST))),
1065 Text::new(format!("{:.0}%", zoom.get() * 100.0))
1066 .style(TextStyle::Callout)
1067 .foreground(Semantic::InkMuted),
1068 ))
1069 .spacing(SPACING.s1)
1070 .align(Align::Center),
1071 )
1072 }
1073}
1074
1075struct ExpressionDialog(Editor);
1078
1079impl View for ExpressionDialog {
1080 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1081 let e = &self.0;
1082 let report = e.report.get();
1083 let data = report.embedded_data().cloned().unwrap_or_default();
1084 let kind = |k: FieldKind| match k {
1085 FieldKind::Number => FilterFieldKind::Number,
1086 FieldKind::Date => FilterFieldKind::Date,
1087 FieldKind::Boolean => FilterFieldKind::Boolean,
1088 _ => FilterFieldKind::Text,
1089 };
1090 let mut fields: Vec<FilterField> = wizard::fields(&data, report.data_member.as_deref())
1091 .into_iter()
1092 .filter(|f| f.kind != FieldKind::Collection)
1093 .map(|f| FilterField::new(f.path.clone(), f.path.clone(), kind(f.kind)))
1094 .collect();
1095 fields.extend(
1096 report
1097 .calculated_fields
1098 .iter()
1099 .map(|f| FilterField::new(f.name.clone(), f.name.clone(), FilterFieldKind::Number)),
1100 );
1101 let parameters = report.parameters.iter().map(|p| {
1102 let k = match p.kind {
1103 ravenrs_report::ParameterKind::Number => FilterFieldKind::Number,
1104 ravenrs_report::ParameterKind::Date => FilterFieldKind::Date,
1105 ravenrs_report::ParameterKind::Boolean => FilterFieldKind::Boolean,
1106 _ => FilterFieldKind::Text,
1107 };
1108 FilterField::new(p.name.clone(), p.label.clone().unwrap_or_else(|| p.name.clone()), k)
1109 });
1110 let (read, write) = (e.expression.clone(), e.expression.clone());
1111 let open = Binding::new(
1112 move || read.get().is_some(),
1113 move |on: bool| {
1114 if !on {
1115 write.set(None)
1116 }
1117 },
1118 );
1119 let apply = e.clone();
1120 cx.child(
1121 &Dialog::new(open, "Editor de expressões")
1122 .description("Clique num item para ver o que ele faz; clique de novo, ou em Inserir, para colocá-lo no cursor.")
1123 .width(860.0)
1124 .close_label("Cancelar")
1125 .content(Localized::new(
1126 Labels::pt_br(),
1127 ExpressionEditor::new(e.expression_draft.binding()).fields(fields).parameters(parameters).rows(6),
1128 ))
1129 .action(Button::new("Aplicar").icon(IconName::Check).on_click(move || apply.apply_expression())),
1130 )
1131 }
1132}
1133
1134struct Gallery {
1136 editor: Editor,
1137}
1138
1139fn template_card<F: Fn() + 'static>(
1140 title: &str,
1141 text: &str,
1142 icon: IconName,
1143 on_click: F,
1144) -> impl View + use<F> {
1145 Card::new(
1146 VStack::new((
1147 Icon::new(icon).size(22.0).color(Semantic::Accent),
1148 Text::new(title).style(TextStyle::Headline),
1149 Text::new(text).style(TextStyle::Callout).foreground(Semantic::InkMuted),
1150 ))
1151 .spacing(SPACING.s2)
1152 .align(Align::Stretch),
1153 )
1154 .corner_radius(RADII.lg)
1155 .padding(SPACING.s4)
1156 .no_shadow()
1157 .fill(Semantic::Surface)
1158 .on_click(on_click)
1159}
1160
1161impl View for Gallery {
1162 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1163 let e = &self.editor;
1164 let open = |report: fn() -> Report, tab: usize| {
1165 let e = e.clone();
1166 move || {
1167 e.load(report());
1168 e.tab.set(tab);
1169 e.gallery_open.set(false);
1170 }
1171 };
1172 let wizard = {
1173 let e = e.clone();
1174 move || {
1175 e.gallery_open.set(false);
1176 e.wizard_open.set(true);
1177 }
1178 };
1179 let blank = {
1180 let e = e.clone();
1181 move || {
1182 let data = samples::sales_data(300);
1183 let r = Report::new("Novo relatório")
1184 .culture("pt-BR")
1185 .data_source(DataSource { name: "Vendas".into(), kind: DataSourceKind::Json { data } })
1186 .calculated("Total", "[Quantity] * [UnitPrice]")
1187 .report_header(ravenrs_report::Band::new(40.0))
1188 .detail(ravenrs_report::Band::new(20.0))
1189 .page_footer(ravenrs_report::Band::new(20.0));
1190 e.load(r);
1191 e.tab.set(0);
1192 e.gallery_open.set(false);
1193 }
1194 };
1195 let cards = Grid::new(
1196 3,
1197 (
1198 template_card(
1199 "Fatura",
1200 "Emitente, cliente, itens (mestre-detalhe), totais e observações.",
1201 IconName::FileText,
1202 open(samples::invoice, 1),
1203 ),
1204 template_card(
1205 "Vendas por região",
1206 "Grupos aninhados, subtotais, zebra, destaque condicional e parâmetro de valor mínimo.",
1207 IconName::ChartBar,
1208 open(|| samples::sales_by_region(300), 1),
1209 ),
1210 template_card(
1211 "Vendas · 10.000 linhas",
1212 "O mesmo relatório com 10 mil pedidos: veja o tempo de geração.",
1213 IconName::Zap,
1214 open(|| samples::sales_by_region(10_000), 1),
1215 ),
1216 template_card(
1217 "Painel de vendas",
1218 "Gráficos de área, barras e rosca sobre os mesmos pedidos.",
1219 IconName::ChartColumn,
1220 open(samples::sales_dashboard, 1),
1221 ),
1222 template_card(
1223 "Lista de clientes",
1224 "Agrupada pela inicial, com caixa de seleção.",
1225 IconName::Users,
1226 open(samples::customer_list, 1),
1227 ),
1228 template_card(
1229 "Assistente",
1230 "Escolha os campos, o agrupamento e os totais: o relatório fica pronto.",
1231 IconName::Sparkles,
1232 wizard,
1233 ),
1234 template_card(
1235 "Em branco",
1236 "Comece do zero e arraste os campos para as bandas.",
1237 IconName::FilePlus,
1238 blank,
1239 ),
1240 ),
1241 )
1242 .spacing(SPACING.s4);
1243 cx.child(
1244 &Dialog::new(e.gallery_open.binding(), "Novo relatório")
1245 .description("Comece por um modelo pronto ou deixe o assistente montar o relatório a partir dos seus dados.")
1246 .width(880.0)
1247 .close_label("Fechar")
1248 .content(cards),
1249 )
1250 }
1251}
1252
1253struct WizardDialog {
1255 editor: Editor,
1256 state: WizardState,
1257}
1258
1259impl View for WizardDialog {
1260 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1261 let e = &self.editor;
1262 let s = &self.state;
1263 let fields = wizard::fields(&samples::sales_data(5), None);
1265 let chosen = s.chosen.get();
1266 let checks: Vec<AnyView> = fields
1267 .iter()
1268 .map(|f| {
1269 let (read, write, path) = (s.chosen.clone(), s.chosen.clone(), f.path.clone());
1270 let p2 = path.clone();
1271 Box::new(Checkbox::new(
1272 wizard::caption(&f.path),
1273 Binding::new(
1274 move || read.with(|c| c.contains(&path)),
1275 move |on: bool| {
1276 write.update(|c| {
1277 c.retain(|x| x != &p2);
1278 if on {
1279 c.push(p2.clone());
1280 }
1281 })
1282 },
1283 ),
1284 )) as AnyView
1285 })
1286 .collect();
1287 let groups: Vec<String> = std::iter::once("Sem agrupamento".to_owned())
1288 .chain(fields.iter().filter(|f| f.kind == FieldKind::Text).map(|f| wizard::caption(&f.path)))
1289 .collect();
1290 let text_fields: Vec<String> =
1291 fields.iter().filter(|f| f.kind == FieldKind::Text).map(|f| f.path.clone()).collect();
1292 let (e2, st, all_fields) = (e.clone(), s.clone(), fields.clone());
1293 let generate = Button::new("Gerar relatório").icon(IconName::Sparkles).on_click(move || {
1294 let (name, rows) = WIZARD_SOURCES[st.source.get().min(WIZARD_SOURCES.len() - 1)];
1295 let data = samples::sales_data(rows);
1296 let chosen = st.chosen.get();
1297 let columns: Vec<FieldInfo> =
1298 chosen.iter().filter_map(|p| all_fields.iter().find(|f| &f.path == p).cloned()).collect();
1299 let group_by = st.group.get().checked_sub(1).and_then(|i| text_fields.get(i).cloned());
1300 let totals = if st.totals.get() {
1301 columns.iter().filter(|c| c.kind == FieldKind::Number).map(|c| c.path.clone()).collect()
1302 } else {
1303 Vec::new()
1304 };
1305 let options = WizardOptions {
1306 title: st.title.get(),
1307 culture: "pt-BR".into(),
1308 columns,
1309 group_by,
1310 totals,
1311 ..WizardOptions::default()
1312 };
1313 let report = wizard::table_report(
1314 DataSource { name: name.into(), kind: DataSourceKind::Json { data } },
1315 &options,
1316 );
1317 e2.load(report);
1318 e2.tab.set(1);
1319 e2.wizard_open.set(false);
1320 });
1321 let content = VStack::new((
1322 FormField::new("Título", TextField::new(s.title.binding())),
1323 FormField::new(
1324 "Dados",
1325 Select::new(s.source.binding(), WIZARD_SOURCES.iter().map(|(n, _)| *n))
1326 .width(300.0)
1327 .label("Dados"),
1328 ),
1329 Text::new(format!("Campos ({} escolhidos)", chosen.len()))
1330 .style(TextStyle::Callout)
1331 .font_weight(Weight::SEMIBOLD),
1332 Grid::new(3, checks).spacing(SPACING.s2),
1333 FormField::new(
1334 "Agrupar por",
1335 Select::new(s.group.binding(), groups).width(300.0).label("Agrupar por"),
1336 ),
1337 Toggle::new(s.totals.binding()).label("Totais dos campos numéricos"),
1338 ))
1339 .spacing(SPACING.s4)
1340 .align(Align::Stretch);
1341 cx.child(
1342 &Dialog::new(e.wizard_open.binding(), "Assistente de relatório")
1343 .description("Escolha os campos e o agrupamento. O resto (cabeçalhos, zebra, totais, numeração de páginas) o assistente faz.")
1344 .close_label("Fechar")
1345 .content(content)
1346 .action(generate),
1347 )
1348 }
1349}