Skip to main content

egui_charts/ui/widget_bar/panels/object_tree/
panel.rs

1//! Main Object Tree panel widget
2//!
3//! Ties together all subcomponents into the complete Object Tree panel.
4
5use crate::icons::icons;
6use crate::localization as l10n;
7use crate::ui_kit::{IconButton, PanelHeader, filter::filter_input};
8use egui::{Frame, RichText, Ui, vec2};
9
10use super::super::controls::dropdown;
11use super::actions::ObjectTreeAction;
12use super::config::ObjectTreeConfig;
13use super::context_menu::handle_keyboard_shortcuts;
14use super::data_window::show_data_window;
15use super::source_tree::render_source_tree;
16use super::state::ObjectTreeState;
17use super::types::{DataWindowInfo, SourceItem, SourceType};
18use crate::ui_kit::ext::UiExt;
19
20/// Object Tree panel widget
21///
22/// Complete object management panel with:
23/// - Data Window (OHLCV at cursor)
24/// - Hierarchical source tree (indicators, drawings)
25/// - Full object management (visibility, lock, delete, etc.)
26/// - Multi-selection and keyboard shortcuts
27/// - Context menu with all options
28///
29/// # Example
30///
31/// ```no_run
32/// use egui_charts::ui::widget_bar::panels::object_tree::{ObjectTreePanel, SourceItem};
33///
34/// # fn side_panel(ui: &mut egui::Ui) {
35/// let mut panel = ObjectTreePanel::new();
36/// let mut sources = vec![
37///     SourceItem::indicator(1, "SMA(20)", egui::Color32::YELLOW),
38/// ];
39///
40/// let action = panel.show(ui, None, &mut sources);
41/// # }
42/// ```
43pub struct ObjectTreePanel {
44    /// Configuration
45    config: ObjectTreeConfig,
46    /// UI state
47    state: ObjectTreeState,
48}
49
50impl Default for ObjectTreePanel {
51    fn default() -> Self {
52        Self::new()
53    }
54}
55
56impl ObjectTreePanel {
57    /// Create a new Object Tree panel
58    pub fn new() -> Self {
59        Self {
60            config: ObjectTreeConfig::default(),
61            state: ObjectTreeState::new(),
62        }
63    }
64
65    /// Create with custom configuration
66    pub fn with_config(config: ObjectTreeConfig) -> Self {
67        Self {
68            config,
69            state: ObjectTreeState::new(),
70        }
71    }
72
73    /// Get mutable reference to state (for external manipulation)
74    pub fn state_mut(&mut self) -> &mut ObjectTreeState {
75        &mut self.state
76    }
77
78    /// Get reference to state
79    pub fn state(&self) -> &ObjectTreeState {
80        &self.state
81    }
82
83    /// Get reference to config
84    pub fn config(&self) -> &ObjectTreeConfig {
85        &self.config
86    }
87
88    /// Set configuration
89    pub fn set_config(&mut self, config: ObjectTreeConfig) {
90        self.config = config;
91    }
92
93    /// Main show method
94    ///
95    /// Renders the complete Object Tree panel and returns any action.
96    pub fn show(
97        &mut self,
98        ui: &mut Ui,
99        data_window: Option<&DataWindowInfo>,
100        sources: &mut [SourceItem],
101    ) -> ObjectTreeAction {
102        let mut action = ObjectTreeAction::None;
103        for item in sources.iter().filter(|s| s.is_drawing()) {
104            for depth in 1..=item.folder.len() {
105                self.state.folders.insert(item.folder[..depth].to_vec());
106            }
107        }
108
109        if self.config.group_by_type {
110            sources.sort_by_key(|s| {
111                let group = match s.source_type {
112                    SourceType::DataSource => 0,
113                    SourceType::Indicator => 1,
114                    SourceType::Drawing => 2,
115                    SourceType::Template => 3,
116                };
117                (group, std::cmp::Reverse(s.z_index))
118            });
119        } else {
120            sources.sort_by_key(|s| std::cmp::Reverse(s.z_index));
121        }
122
123        // Handle keyboard shortcuts
124        if self.config.enable_keyboard_shortcuts {
125            let kb_action = handle_keyboard_shortcuts(ui, &mut self.state, sources);
126            if !kb_action.is_none() {
127                action = kb_action;
128            }
129        }
130
131        // Main panel frame
132        Frame::new().fill(ui.visuals().panel_fill).show(ui, |ui| {
133            // Header
134            self.show_header(ui, &mut action);
135
136            ui.separator();
137
138            // Data Window section (collapsible)
139            if self.config.show_data_window
140                && let Some(data) = data_window
141            {
142                let expanded = show_data_window(ui, data, self.state.data_window_expanded);
143                self.state.data_window_expanded = expanded;
144                ui.separator();
145            }
146
147            // Filter controls
148            self.show_filter_controls(ui);
149
150            ui.space_sm();
151
152            ui.with_layout(egui::Layout::bottom_up(egui::Align::LEFT), |ui| {
153                self.show_footer(ui, sources);
154                ui.separator();
155                let mut tree_config = self.config.clone();
156                tree_config.max_height = tree_config.max_height.min(ui.available_height());
157                ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| {
158                    let tree_action =
159                        render_source_tree(ui, sources, &tree_config, &mut self.state);
160                    if !tree_action.is_none() {
161                        action = tree_action;
162                    }
163                });
164            });
165        });
166
167        if let Some(parent) = self.state.creating_folder.clone() {
168            use crate::ui_kit::modal::dialogs::{PromptDialog, PromptResult};
169            let mut open = true;
170            match PromptDialog::new(ui.scope_id().with("new_folder"), "New folder")
171                .placeholder("Folder name")
172                .show(ui.ctx(), &mut open)
173            {
174                PromptResult::Submitted(name) => {
175                    self.state.creating_folder = None;
176                    if !name.trim().is_empty() {
177                        action = ObjectTreeAction::CreateFolder(parent, name);
178                    }
179                }
180                PromptResult::Cancelled => self.state.creating_folder = None,
181                PromptResult::None => {}
182            }
183        }
184
185        if let Some(id) = self.state.renaming_id {
186            use crate::ui_kit::modal::dialogs::{PromptDialog, PromptResult};
187            let mut open = true;
188            match PromptDialog::new(ui.scope_id().with(("rename_object", id)), "Rename object")
189                .initial_value(&self.state.rename_text)
190                .show(ui.ctx(), &mut open)
191            {
192                PromptResult::Submitted(name) if !name.trim().is_empty() => {
193                    self.state.cancel_rename();
194                    action = ObjectTreeAction::Rename(id, name);
195                }
196                PromptResult::Cancelled | PromptResult::Submitted(_) => self.state.cancel_rename(),
197                PromptResult::None => {}
198            }
199        }
200
201        // Handle selection state updates
202        self.handle_selection_action(&action, sources);
203
204        action
205    }
206
207    /// Render header with title and controls
208    fn show_header(&mut self, ui: &mut Ui, action: &mut ObjectTreeAction) {
209        PanelHeader::new("Objects")
210            .strong()
211            .no_separator()
212            .show(ui, |ui| {
213                if IconButton::new(&icons::PLUS)
214                    .tooltip("New folder with selected drawings")
215                    .show(ui)
216                    .clicked()
217                {
218                    self.state.creating_folder = Some(Vec::new());
219                }
220                // Settings menu
221                let settings = IconButton::new(&icons::SETTINGS)
222                    .tooltip("Object tree settings")
223                    .show(ui);
224                egui::Popup::menu(&settings).show(|ui| {
225                    ui.checkbox(
226                        &mut self.config.show_data_window,
227                        l10n::text(ui.ctx(), "Show Data Window"),
228                    );
229                    ui.checkbox(
230                        &mut self.config.group_by_type,
231                        l10n::text(ui.ctx(), "Group by Type"),
232                    );
233                    ui.checkbox(
234                        &mut self.config.show_color_indicators,
235                        l10n::text(ui.ctx(), "Show Colors"),
236                    );
237                    ui.checkbox(
238                        &mut self.config.show_item_counts,
239                        l10n::text(ui.ctx(), "Show Counts"),
240                    );
241                });
242
243                // Expand all
244                if IconButton::new(&icons::CHEVRON_DOWN)
245                    .tooltip("Expand all")
246                    .show(ui)
247                    .clicked()
248                {
249                    self.state.expand_all_groups();
250                }
251
252                // Collapse all
253                if IconButton::new(&icons::CHEVRON_UP)
254                    .tooltip("Collapse all")
255                    .show(ui)
256                    .clicked()
257                {
258                    self.state.collapse_all_groups();
259                }
260
261                // Bulk actions menu
262                let more = IconButton::new(&icons::UI_MORE_HORIZONTAL)
263                    .tooltip("Object actions")
264                    .show(ui);
265                egui::Popup::menu(&more).show(|ui| {
266                    if ui.button(l10n::text(ui.ctx(), "Show all")).clicked() {
267                        *action = ObjectTreeAction::ShowAll;
268                        ui.close();
269                    }
270                    if ui.button(l10n::text(ui.ctx(), "Hide all")).clicked() {
271                        *action = ObjectTreeAction::HideAll;
272                        ui.close();
273                    }
274                    ui.separator();
275                    if ui.button(l10n::text(ui.ctx(), "Lock all")).clicked() {
276                        *action = ObjectTreeAction::LockAll;
277                        ui.close();
278                    }
279                    if ui.button(l10n::text(ui.ctx(), "Unlock all")).clicked() {
280                        *action = ObjectTreeAction::UnlockAll;
281                        ui.close();
282                    }
283                    ui.separator();
284                    if self.state.selection_count() > 0
285                        && ui.button(l10n::text(ui.ctx(), "Delete selected")).clicked()
286                    {
287                        *action = ObjectTreeAction::DeleteSelected;
288                        ui.close();
289                    }
290                });
291            });
292    }
293
294    /// Render filter controls
295    fn show_filter_controls(&mut self, ui: &mut Ui) {
296        ui.horizontal(|ui| {
297            let type_width = 108.0;
298            let field_width =
299                (ui.available_width() - type_width - ui.spacing().item_spacing.x).max(0.0);
300            ui.allocate_ui(vec2(field_width, 28.0), |ui| {
301                ui.spacing_mut().interact_size.y = 28.0;
302                filter_input(ui, &mut self.state.filter_text);
303            });
304            ui.spacing_mut().interact_size.y = 28.0;
305            ui.allocate_ui(vec2(type_width, 28.0), |ui| {
306                dropdown(
307                    ui,
308                    "object_tree_type_filter",
309                    self.state.filter_type.map_or("All", |t| t.display_name()),
310                    |ui| {
311                        for source_type in [
312                            None,
313                            Some(SourceType::DataSource),
314                            Some(SourceType::Indicator),
315                            Some(SourceType::Drawing),
316                            Some(SourceType::Template),
317                        ] {
318                            let label = source_type.map_or("All", |t| t.display_name());
319                            ui.selectable_value(
320                                &mut self.state.filter_type,
321                                source_type,
322                                l10n::text(ui.ctx(), label),
323                            );
324                        }
325                    },
326                )
327                .accessible_name(l10n::text(ui.ctx(), "Object type"));
328            });
329        });
330    }
331
332    /// Render footer with stats
333    fn show_footer(&self, ui: &mut Ui, sources: &[SourceItem]) {
334        let total = sources.len();
335        let visible = sources.iter().filter(|s| s.visible).count();
336        let selected = self.state.selection_count();
337        let indicators = sources
338            .iter()
339            .filter(|s| s.source_type == SourceType::Indicator)
340            .count();
341        let drawings = sources
342            .iter()
343            .filter(|s| s.source_type == SourceType::Drawing)
344            .count();
345
346        ui.add(
347            egui::Label::new(
348                RichText::new(l10n::format_text(
349                    ui.ctx(),
350                    "{0} objects • {1} indicators • {2} drawings • {3} visible{4}",
351                    &[
352                        ("0", format!("{}", total)),
353                        ("1", format!("{}", indicators)),
354                        ("2", format!("{}", drawings)),
355                        ("3", format!("{}", visible)),
356                        (
357                            "4",
358                            (if selected > 0 {
359                                format!(" • {selected} selected")
360                            } else {
361                                String::new()
362                            })
363                            .to_string(),
364                        ),
365                    ],
366                ))
367                .color(ui.visuals().weak_text_color())
368                .size(11.0),
369            )
370            .wrap(),
371        );
372    }
373
374    /// Handle selection state updates based on action
375    fn handle_selection_action(&mut self, action: &ObjectTreeAction, sources: &[SourceItem]) {
376        let filter = self.state.filter_text.to_lowercase();
377        let source_ids: Vec<usize> = sources
378            .iter()
379            .filter(|s| super::source_tree::matches_filter(s, &filter, &self.state))
380            .map(|s| s.id)
381            .collect();
382
383        match action {
384            ObjectTreeAction::Select(id) => {
385                self.state.select(*id);
386            }
387            ObjectTreeAction::AddToSelection(id) => {
388                self.state.toggle_selection(*id);
389            }
390            ObjectTreeAction::RangeSelect(id) => {
391                let ordered = if self.state.visible_ids.is_empty() {
392                    source_ids
393                } else {
394                    self.state.visible_ids.clone()
395                };
396                self.state.range_select(*id, &ordered);
397            }
398            ObjectTreeAction::ClearSelection => {
399                self.state.clear_selection();
400            }
401            ObjectTreeAction::SelectAll => {
402                self.state.select_all(&source_ids);
403            }
404            _ => {}
405        }
406    }
407
408    /// Get selected item IDs
409    pub fn selected_ids(&self) -> Vec<usize> {
410        self.state.selected_ids.iter().copied().collect()
411    }
412
413    /// Clear all state
414    pub fn reset(&mut self) {
415        self.state = ObjectTreeState::new();
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422
423    #[test]
424    fn selected_drawings_can_be_nested_hidden_locked_and_moved_without_replacing_them() {
425        use crate::drawings::{ChartPoint, Drawing, DrawingToolType};
426        use egui_kittest::{Harness, kittest::Queryable};
427        for theme in [crate::theme::Theme::light(), crate::theme::Theme::dark()] {
428            let mut chart = crate::widget::ChartBuilder::new().build();
429            for name in ["Support", "Resistance", "Outside"] {
430                let mut drawing = Drawing::new(0, DrawingToolType::TrendLine);
431                drawing.label = Some(name.into());
432                drawing.stored_id = Some(name.into());
433                drawing.z_order = match name {
434                    "Resistance" => 3,
435                    "Outside" => 2,
436                    _ => 0,
437                };
438                drawing.chart_points =
439                    vec![ChartPoint::new(1.0, 100.0), ChartPoint::new(2.0, 110.0)];
440                drawing.completed = true;
441                chart.drawings_mut().add_synced_drawing(drawing).unwrap();
442            }
443            let ids: Vec<_> = chart.drawings().drawings.iter().map(|d| d.id).collect();
444            let mut panel = ObjectTreePanel::with_config(ObjectTreeConfig {
445                show_data_window: false,
446                ..Default::default()
447            });
448            panel.state_mut().select_all(&ids[..2]);
449            let mut h = Harness::builder()
450                .allow_missing_glyphs()
451                .with_size(vec2(220.0, 600.0))
452                .with_context_setup(move |ctx| crate::theme::apply_to_egui(ctx, &theme))
453                .build_ui_state(
454                    |ui, (panel, chart): &mut (ObjectTreePanel, crate::Chart)| {
455                        let mut sources: Vec<_> = chart
456                            .drawings()
457                            .drawings
458                            .iter()
459                            .map(|d| {
460                                let mut item = SourceItem::drawing(d.id, d.tool_type, d.color32());
461                                item.label = d.label.clone();
462                                item.folder = d.folder.clone();
463                                item.visible = d.visible;
464                                item.locked = d.locked;
465                                item.z_index = d.z_order;
466                                item
467                            })
468                            .collect();
469                        let action = panel.show(ui, None, &mut sources);
470                        assert!(panel.apply_to_chart(chart, &action, &sources));
471                    },
472                    (panel, chart),
473                );
474            h.run();
475            h.get_by_label("New folder with selected drawings").click();
476            h.run();
477            h.event(egui::Event::Text("Analysis / one".into()));
478            h.key_press(egui::Key::Enter);
479            h.run();
480            let caret_right = h.get_by_label("Toggle folder").rect().right();
481            let name_left = h
482                .output()
483                .shapes
484                .iter()
485                .find_map(|shape| match &shape.shape {
486                    egui::Shape::Text(text) if text.galley.text() == "Analysis / one" => {
487                        Some(text.pos.x)
488                    }
489                    _ => None,
490                })
491                .unwrap();
492            assert!(
493                name_left - caret_right <= 12.0,
494                "folder name must follow its expand control"
495            );
496            h.get_by_label("Analysis / one").click_secondary();
497            h.run();
498            h.get_by_label("New subfolder with selected drawings")
499                .click();
500            h.run();
501            h.event(egui::Event::Text("Levels".into()));
502            h.key_press(egui::Key::Enter);
503            h.run();
504            let saved = |h: &Harness<'_, (ObjectTreePanel, crate::Chart)>| {
505                h.state()
506                    .1
507                    .drawings()
508                    .drawings
509                    .iter()
510                    .map(|d| serde_json::to_value(d).unwrap())
511                    .collect::<Vec<_>>()
512            };
513            h.get_by_label("Resistance").click();
514            h.run();
515            h.get_by_label("Support").click_modifiers(egui::Modifiers {
516                shift: true,
517                ..Default::default()
518            });
519            h.run();
520            assert_eq!(h.state().0.state().selection_count(), 2);
521            for filter_type in [None, Some(SourceType::Drawing)] {
522                h.state_mut().0.state_mut().filter_type = filter_type;
523                h.run();
524                h.query_all_by_label("Toggle folder")
525                    .next()
526                    .unwrap()
527                    .click();
528                h.run();
529                assert!(h.query_by_label("Support").is_none());
530                h.state_mut().0.state_mut().filter_text = "Support".into();
531                h.run();
532                h.get_by_label("Support");
533                assert!(h.query_by_label("Outside").is_none());
534                h.state_mut().0.state_mut().filter_text.clear();
535                h.run();
536                h.query_all_by_label("Toggle folder")
537                    .next()
538                    .unwrap()
539                    .click();
540                h.run();
541            }
542            h.state_mut().0.state_mut().filter_type = None;
543            let nested = saved(&h);
544            for (index, name) in ["Support", "Resistance", "Outside"].iter().enumerate() {
545                assert_eq!(nested[index]["label"], *name);
546                assert_eq!(
547                    h.state().1.drawings().drawings[index].stored_id.as_deref(),
548                    Some(*name)
549                );
550                assert_eq!(
551                    nested[index]["folder"],
552                    if index < 2 {
553                        serde_json::json!(["Analysis / one", "Levels"])
554                    } else {
555                        serde_json::json!([])
556                    }
557                );
558            }
559            h.query_all_by_label("Hide folder").next().unwrap().click();
560            h.run();
561            assert_eq!(
562                saved(&h)
563                    .iter()
564                    .map(|d| d["visible"].as_bool().unwrap())
565                    .collect::<Vec<_>>(),
566                [false, false, true]
567            );
568            h.query_all_by_label("Lock folder").next().unwrap().click();
569            h.run();
570            assert_eq!(
571                saved(&h)
572                    .iter()
573                    .map(|d| d["locked"].as_bool().unwrap())
574                    .collect::<Vec<_>>(),
575                [true, true, false]
576            );
577            assert!(h.state_mut().1.drawings_mut().undo());
578            assert_eq!(
579                saved(&h)
580                    .iter()
581                    .map(|d| d["locked"].as_bool().unwrap())
582                    .collect::<Vec<_>>(),
583                [false, false, false]
584            );
585            assert!(h.state_mut().1.drawings_mut().redo());
586            h.run();
587            h.get_by_label("Support").click_secondary();
588            h.run();
589            h.get_by_label("Move to folder").click();
590            h.run();
591            h.get_by_label("Root").click();
592            h.run();
593            assert!(
594                saved(&h)
595                    .iter()
596                    .all(|d| d["folder"] == serde_json::json!([]))
597            );
598            assert!(h.state_mut().1.drawings_mut().undo());
599            assert_eq!(saved(&h)[0]["folder"], nested[0]["folder"]);
600            assert_eq!(saved(&h)[1]["folder"], nested[1]["folder"]);
601            assert_eq!(
602                h.state().1.drawings().drawings[0].chart_points,
603                [ChartPoint::new(1.0, 100.0), ChartPoint::new(2.0, 110.0)]
604            );
605            let path = vec!["Analysis / one".into(), "Levels".into()];
606            let sources: Vec<_> = ids[..2]
607                .iter()
608                .map(|&id| SourceItem::drawing(id, DrawingToolType::TrendLine, egui::Color32::RED))
609                .collect();
610            let (panel, chart) = h.state_mut();
611            for action in [
612                ObjectTreeAction::MoveToFolder(ids[..2].to_vec(), path.clone()),
613                ObjectTreeAction::SetFolderVisible(path.clone(), false),
614                ObjectTreeAction::SetFolderLocked(path, true),
615            ] {
616                assert!(panel.apply_to_chart(chart, &action, &sources));
617                assert!(
618                    chart.drawings().can_redo(),
619                    "unchanged {action:?} cleared redo"
620                );
621            }
622        }
623    }
624
625    #[test]
626    fn select_all_and_range_selection_respect_the_filter() {
627        let mut panel = ObjectTreePanel::new();
628        let sources = [
629            SourceItem::indicator(1, "Fast", egui::Color32::BLUE),
630            SourceItem::data_source(2, "Other"),
631            SourceItem::indicator(3, "Slow", egui::Color32::BLUE),
632        ];
633        panel.state.filter_type = Some(SourceType::Indicator);
634        for action in [
635            ObjectTreeAction::SelectAll,
636            ObjectTreeAction::RangeSelect(3),
637        ] {
638            panel.state.select(1);
639            panel.handle_selection_action(&action, &sources);
640            assert!(panel.state.is_selected(1));
641            assert!(panel.state.is_selected(3));
642            assert!(!panel.state.is_selected(2));
643        }
644    }
645
646    #[test]
647    fn filter_controls_align_and_footer_stays_inside_a_narrow_panel() {
648        for (theme, width) in [
649            (crate::theme::Theme::light(), 220.0),
650            (crate::theme::Theme::light(), 280.0),
651            (crate::theme::Theme::dark(), 220.0),
652            (crate::theme::Theme::dark(), 280.0),
653        ] {
654            let ctx = egui::Context::default();
655            crate::theme::apply_to_egui(&ctx, &theme);
656            let mut panel = ObjectTreePanel::new();
657            let mut sources: Vec<_> = (0..30)
658                .map(|id| SourceItem::indicator(id, "SMA", egui::Color32::BLUE))
659                .collect();
660            panel.state.select_all(&(0..30).collect::<Vec<_>>());
661            for _ in 0..2 {
662                let mut output = ctx.run_ui(
663                    egui::RawInput {
664                        screen_rect: Some(egui::Rect::from_min_size(
665                            egui::Pos2::ZERO,
666                            vec2(width, 360.0),
667                        )),
668                        ..Default::default()
669                    },
670                    |ui| {
671                        panel.show(ui, None, &mut sources);
672                    },
673                );
674                output.textures_delta.clear();
675                let controls: Vec<_> = output
676                    .shapes
677                    .iter()
678                    .filter_map(|shape| match &shape.shape {
679                        egui::Shape::Rect(rect)
680                            if rect.rect.width() > 70.0
681                                && (20.0..32.0).contains(&rect.rect.height()) =>
682                        {
683                            Some(rect.rect)
684                        }
685                        _ => None,
686                    })
687                    .collect();
688                assert!(!controls.is_empty(), "{controls:?}");
689                assert_eq!(controls[0].height(), 28.0, "filter at {width}");
690                let dropdown = output
691                    .shapes
692                    .iter()
693                    .find_map(|shape| match &shape.shape {
694                        egui::Shape::Text(text) if text.galley.text() == "All" => {
695                            Some(egui::Rect::from_min_size(text.pos, text.galley.size()))
696                        }
697                        _ => None,
698                    })
699                    .unwrap();
700                assert!(
701                    (dropdown.center().y - controls[0].center().y).abs() < 2.0,
702                    "dropdown and filter must align at {width}"
703                );
704                assert!(dropdown.right() <= width, "dropdown at {width}");
705                let footer = output
706                    .shapes
707                    .iter()
708                    .find_map(|shape| match &shape.shape {
709                        egui::Shape::Text(text) if text.galley.text().starts_with("30 objects") => {
710                            Some(text)
711                        }
712                        _ => None,
713                    })
714                    .unwrap();
715                assert!(
716                    footer.pos.y + footer.galley.rect.bottom() <= 360.0,
717                    "footer at {width}: bottom {}",
718                    footer.pos.y + footer.galley.rect.bottom()
719                );
720                output.drop_without_applying_deltas();
721            }
722        }
723    }
724}