Skip to main content

ravenrs_report_designer/
lib.rs

1//! RavenRS Report Designer: an end-user report designer running on the
2//! desktop and in the browser. Pick a template or run the wizard, drag fields
3//! onto bands, edit properties, and preview the paginated result instantly.
4
5use 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
22/// What the modules of the designer use, as an application would.
23pub(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
41/// The designer app.
42pub 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/// What the designer says and does inside an application, in place of its gallery and wizard.
52#[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    /// A designer showing the sales report, with the template gallery open.
83    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    /// A designer of this report inside an application, with no gallery or wizard: the layout is
109    /// what it is about, with the data embedded in it for the field list and the preview.
110    /// `on_save` gets the report as it stands, and `on_close` is for leaving.
111    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
150/// The data sets the wizard offers, with their row counts.
151const WIZARD_SOURCES: [(&str, usize); 2] =
152    [("Vendas (300 pedidos)", 300), ("Vendas (10.000 pedidos)", 10_000)];
153
154/// Width of the toolbox: one column of icon buttons.
155pub(crate) const TOOLBOX_WIDTH: f32 = 48.0;
156/// Width of the docked panels: nine color swatches and their padding.
157pub(crate) const DOCK_WIDTH: f32 = 320.0;
158/// The least and the most zoom of the design surface.
159const 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        // The ribbon and the side panels only serve the design surface.
166        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
192/// The tabs under the document that switch between the design surface, the preview
193/// and the JSON, as tabs of the document.
194fn 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
204/// A row that takes the space left and never grows past it, so the panels
205/// inside scroll instead of pushing the status bar off the window.
206struct 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
225/// A column of the window as tall as the row it is in, so what grows inside it takes
226/// the height left: the toolbox and the docked panels.
227struct 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
247/// Renders the report as it stands and sends it to the printer.
248fn 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, &params.get());
254    preview::print(&doc);
255}
256
257/// Renders the report as it stands and exports it.
258fn 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, &params.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, &params, 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, &params, 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, &params))
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        // A toggle over the selected control's style, off and dimmed with nothing selected.
292        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        // The Home page: clipboard, font, alignment, layout, zoom.
376        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    /// The toolbox down the left side: the pointer, then one button per
480    /// control, which drops it into the band in use.
481    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    /// The panels docked on the right: the field list and the report
500    /// explorer as tabs on top, the properties below, with a bar to drag between them.
501    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
531/// A control of the toolbox: its name, its icon and how a new one starts.
532type Tool = (&'static str, IconName, fn() -> Control);
533
534/// The controls of the toolbox and of the Insert page, in order.
535const 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
574/// Drops a new control into the band in use, at a free spot, and selects it.
575fn 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
599/// The data source's fields; drag one onto a band, or click to add it to the selected band.
600struct 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        // Fields of nested collections (invoice items) bind inside their detail band.
629        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        // A click, or Enter while focused: into the selected band, or the band that lists this collection.
678        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                    // The pointer may come up on this same row: that is no click.
716                    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
735/// A new field control with a sensible format for its kind.
736fn 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
750/// The report explorer: bands in order, click to select.
751struct 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
777/// The floating chip that follows a dragged field.
778struct 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
794/// Undo, redo and delete from the keyboard.
795struct 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
815/// Design, preview and JSON views of the report.
816struct 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        // A click on the empty canvas selects the report itself.
849        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, &params);
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        // The designer has its own print button, which renders the report as it stands.
883        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
934/// The report definition as JSON, with long sample data abbreviated.
935struct 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    /// Whether it offers the themes, which are the application's to say inside one.
991    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
1045/// The zoom at the right of the status bar: out, a slider, in and the percentage.
1046struct 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
1075/// The expression editor, opened from an expression's button: the expression with
1076/// the report's fields, calculated fields and parameters, applied or dropped as a whole.
1077struct 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
1134/// The template gallery shown at start, as commercial designers do.
1135struct 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
1253/// The report wizard dialog.
1254struct 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        // A few rows are enough to list the fields; the data is generated on demand.
1264        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}