paiagram/
interface.rs

1mod about;
2mod side_panel;
3mod tabs;
4mod widgets;
5use crate::colors;
6use crate::settings::ApplicationSettings;
7use bevy::color::palettes::tailwind::{EMERALD_700, EMERALD_800, GRAY_900};
8use bevy::prelude::*;
9use egui::{
10    self, Color32, CornerRadius, Frame, Id, Margin, Rect, Sense, Shape, Stroke, Ui, UiBuilder,
11};
12use egui_dock::{DockArea, DockState, TabInteractionStyle};
13use egui_i18n::tr;
14use moonshine_core::save::prelude::*;
15use serde::{Deserialize, Serialize};
16use std::sync::Arc;
17use strum::{EnumCount, IntoEnumIterator};
18use tabs::diagram::SelectedEntityType;
19use tabs::minesweeper::MinesweeperData;
20use tabs::{Tab, all_tabs::*};
21#[cfg(target_arch = "wasm32")]
22use wasm_bindgen::prelude::wasm_bindgen;
23
24/// Plugin that sets up the user interface
25pub struct InterfacePlugin;
26
27impl Plugin for InterfacePlugin {
28    fn build(&self, app: &mut App) {
29        app.add_message::<UiCommand>()
30            .init_resource::<MiscUiState>()
31            .init_resource::<SelectedElement>()
32            .init_resource::<MinesweeperData>()
33            .init_resource::<SidePanelState>()
34            .insert_resource(UiState::new())
35            .insert_resource(StatusBarState::default())
36            .add_systems(Update, (modify_dock_state.run_if(on_message::<UiCommand>),));
37    }
38}
39
40/// The state of the user interface
41#[derive(Resource, Clone, Reflect, Serialize, Deserialize)]
42#[reflect(Resource, MapEntities, Serialize, Deserialize, opaque)]
43pub struct UiState {
44    dock_state: DockState<AppTab>,
45}
46
47impl MapEntities for UiState {
48    fn map_entities<M: EntityMapper>(&mut self, entity_mapper: &mut M) {
49        for (_, tab) in self.dock_state.iter_all_tabs_mut() {
50            tab.map_entities(entity_mapper);
51        }
52    }
53}
54
55// TODO: move this elsewhere
56#[derive(Default, Resource, Deref, DerefMut)]
57pub struct SelectedElement(pub Option<SelectedEntityType>);
58
59// TODO: move this UI state elsewhere
60#[derive(Resource)]
61pub struct MiscUiState {
62    is_dark_mode: bool,
63    initialized: bool,
64    frame_times: egui::util::History<f32>,
65    modal_open: bool,
66    fullscreened: bool,
67    supplementary_panel_state: SupplementaryPanelState,
68}
69
70#[derive(Default)]
71pub struct SupplementaryPanelState {
72    expanded: bool,
73    is_on_bottom: bool,
74}
75
76impl Default for MiscUiState {
77    fn default() -> Self {
78        let max_age: f32 = 1.0;
79        let max_len = (max_age * 300.0).round() as usize;
80        Self {
81            is_dark_mode: true,
82            frame_times: egui::util::History::new(0..max_len, max_age),
83            initialized: false,
84            modal_open: false,
85            fullscreened: false,
86            supplementary_panel_state: SupplementaryPanelState::default(),
87        }
88    }
89}
90
91impl MiscUiState {
92    pub fn on_new_frame(&mut self, now: f64, previous_frame_time: Option<f32>) {
93        let previous_frame_time = previous_frame_time.unwrap_or_default();
94        if let Some(latest) = self.frame_times.latest_mut() {
95            *latest = previous_frame_time; // rewrite history now that we know
96        }
97        self.frame_times.add(now, previous_frame_time); // projected
98    }
99    pub fn mean_frame_time(&self) -> f32 {
100        self.frame_times.average().unwrap_or_default()
101    }
102}
103
104#[derive(Default, Resource)]
105struct StatusBarState {
106    tooltip: String,
107}
108
109/// Modify the dock state based on UI commands
110fn modify_dock_state(mut dock_state: ResMut<UiState>, mut msg_reader: MessageReader<UiCommand>) {
111    for msg in msg_reader.read() {
112        match msg {
113            UiCommand::OpenOrFocusTab(tab) => {
114                dock_state.open_or_focus_tab(tab.clone());
115            }
116        }
117    }
118}
119
120impl UiState {
121    fn new() -> Self {
122        Self {
123            dock_state: DockState::new(vec![AppTab::Start(StartTab::default())]),
124        }
125    }
126    /// Open a tab if it is not already open, or focus it if it is
127    fn open_or_focus_tab(&mut self, tab: AppTab) {
128        if let Some((surface_index, node_index, tab_index)) = self.dock_state.find_tab(&tab) {
129            self.dock_state
130                .set_active_tab((surface_index, node_index, tab_index));
131            self.dock_state
132                .set_focused_node_and_surface((surface_index, node_index));
133        } else {
134            self.dock_state.push_to_focused_leaf(tab);
135        }
136    }
137}
138
139macro_rules! for_all_tabs {
140    ($tab:expr, $t:ident, $body:expr) => {
141        match $tab {
142            AppTab::Start($t) => $body,
143            AppTab::Vehicle($t) => $body,
144            AppTab::StationTimetable($t) => $body,
145            AppTab::Diagram($t) => $body,
146            AppTab::DisplayedLines($t) => $body,
147            AppTab::Settings($t) => $body,
148            AppTab::Classes($t) => $body,
149            AppTab::Services($t) => $body,
150            AppTab::Minesweeper($t) => $body,
151            AppTab::Graph($t) => $body,
152            AppTab::Inspector($t) => $body,
153        }
154    };
155}
156
157/// An application tab
158#[derive(Clone, Serialize, Deserialize)]
159pub enum AppTab {
160    Start(StartTab),
161    Vehicle(VehicleTab),
162    StationTimetable(StationTimetableTab),
163    Diagram(DiagramTab),
164    DisplayedLines(DisplayedLinesTab),
165    Settings(SettingsTab),
166    Classes(ClassesTab),
167    Services(ServicesTab),
168    Minesweeper(MinesweeperTab),
169    Graph(GraphTab),
170    Inspector(InspectorTab),
171}
172
173impl MapEntities for AppTab {
174    fn map_entities<M: EntityMapper>(&mut self, entity_mapper: &mut M) {
175        match self {
176            AppTab::Vehicle(tab) => tab.map_entities(entity_mapper),
177            AppTab::StationTimetable(tab) => tab.map_entities(entity_mapper),
178            AppTab::Diagram(tab) => tab.map_entities(entity_mapper),
179            AppTab::Graph(tab) => tab.map_entities(entity_mapper),
180            AppTab::Start(_)
181            | AppTab::DisplayedLines(_)
182            | AppTab::Settings(_)
183            | AppTab::Classes(_)
184            | AppTab::Services(_)
185            | AppTab::Inspector(_)
186            | AppTab::Minesweeper(_) => {}
187        }
188    }
189}
190
191impl AppTab {
192    pub fn id(&self) -> egui::Id {
193        for_all_tabs!(self, t, t.id())
194    }
195    pub fn color(&self) -> Color32 {
196        let num = self.id().value();
197        // given the num, generate a color32 from it
198        let color = colors::PredefinedColor::iter()
199            .nth(num as usize % colors::PredefinedColor::COUNT)
200            .unwrap();
201        color.get(true)
202    }
203}
204
205impl PartialEq for AppTab {
206    fn eq(&self, other: &Self) -> bool {
207        self.id() == other.id()
208    }
209}
210
211/// User interface commands sent between systems
212#[derive(Message)]
213pub enum UiCommand {
214    OpenOrFocusTab(AppTab),
215}
216
217/// A viewer for application tabs. This struct holds a single mutable reference to the world,
218/// and is constructed each frame.
219struct AppTabViewer<'w> {
220    world: &'w mut World,
221    ctx: &'w egui::Context,
222    focused_id: Option<egui::Id>,
223}
224
225impl<'w> egui_dock::TabViewer for AppTabViewer<'w> {
226    type Tab = AppTab;
227
228    fn ui(&mut self, ui: &mut egui::Ui, tab: &mut Self::Tab) {
229        for_all_tabs!(
230            tab,
231            t,
232            t.frame().show(ui, |ui| t.main_display(self.world, ui))
233        );
234
235        // focus ring
236        let is_focused = self.focused_id == Some(tab.id());
237        let strength = ui
238            .ctx()
239            .animate_bool(ui.id().with("focus_highlight"), is_focused);
240        if strength > 0.0 {
241            ui.painter().rect_stroke(
242                ui.clip_rect(),
243                0,
244                Stroke {
245                    width: 1.8,
246                    color: tab.color().linear_multiply(strength),
247                },
248                egui::StrokeKind::Inside,
249            );
250        }
251    }
252
253    fn title(&mut self, tab: &mut Self::Tab) -> egui::WidgetText {
254        for_all_tabs!(tab, t, t.title())
255    }
256
257    fn id(&mut self, tab: &mut Self::Tab) -> egui::Id {
258        tab.id()
259    }
260
261    fn scroll_bars(&self, tab: &Self::Tab) -> [bool; 2] {
262        for_all_tabs!(tab, t, t.scroll_bars())
263    }
264
265    fn on_tab_button(&mut self, tab: &mut Self::Tab, response: &egui::Response) {
266        for_all_tabs!(tab, t, t.on_tab_button(self.world, response))
267    }
268
269    fn tab_style_override(
270        &self,
271        tab: &Self::Tab,
272        global_style: &egui_dock::TabStyle,
273    ) -> Option<egui_dock::TabStyle> {
274        Some(egui_dock::TabStyle {
275            focused: TabInteractionStyle {
276                bg_fill: tab.color().gamma_multiply_u8(180),
277                text_color: if self.ctx.theme().default_visuals().dark_mode {
278                    Color32::WHITE
279                } else {
280                    Color32::BLACK
281                },
282                ..global_style.focused
283            },
284            active: TabInteractionStyle {
285                bg_fill: tab.color().gamma_multiply_u8(120),
286                ..global_style.active
287            },
288            active_with_kb_focus: TabInteractionStyle {
289                bg_fill: tab.color().gamma_multiply_u8(120),
290                ..global_style.active_with_kb_focus
291            },
292            hovered: TabInteractionStyle {
293                bg_fill: tab.color().gamma_multiply_u8(120),
294                ..global_style.hovered
295            },
296            inactive: TabInteractionStyle {
297                bg_fill: tab.color().gamma_multiply_u8(90),
298                ..global_style.inactive
299            },
300            inactive_with_kb_focus: TabInteractionStyle {
301                bg_fill: tab.color().gamma_multiply_u8(90),
302                ..global_style.inactive_with_kb_focus
303            },
304            ..global_style.clone()
305        })
306    }
307}
308
309#[derive(Resource)]
310struct SidePanelState {
311    dock_state: DockState<SidePanelTab>,
312}
313
314impl Default for SidePanelState {
315    fn default() -> Self {
316        let dock_state = DockState::new(vec![
317            SidePanelTab::Edit,
318            SidePanelTab::Details,
319            SidePanelTab::Export,
320        ]);
321        Self { dock_state }
322    }
323}
324
325enum SidePanelTab {
326    Edit,
327    Details,
328    Export,
329}
330
331struct SidePanelViewer<'w> {
332    world: &'w mut World,
333    focused_tab: Option<&'w mut AppTab>,
334}
335
336impl<'w> egui_dock::TabViewer for SidePanelViewer<'w> {
337    type Tab = SidePanelTab;
338    fn ui(&mut self, ui: &mut egui::Ui, tab: &mut Self::Tab) {
339        let Some(focused_tab) = self.focused_tab.as_deref_mut() else {
340            ui.label("No tabs focused. Open a tab to see its properties.");
341            return;
342        };
343        use egui::NumExt;
344        let dt = ui.ctx().input(|input| input.stable_dt).at_most(0.1);
345        let t = egui::emath::exponential_smooth_factor(0.9, 0.2, dt);
346        let mut repaint = false;
347        let opacity = ui.ctx().data_mut(|map| {
348            let opacity: &mut f32 = map.get_temp_mut_or(Id::new("side panel opacity"), 0.0);
349            if *opacity > 0.99 {
350                *opacity = 1.0;
351            } else {
352                *opacity = emath::lerp(*opacity..=1.0, t);
353                repaint = true;
354            }
355            *opacity
356        });
357        if repaint {
358            ui.ctx().request_repaint();
359        }
360        ui.multiply_opacity(opacity);
361        match tab {
362            SidePanelTab::Edit => {
363                for_all_tabs!(focused_tab, t, {
364                    egui::Frame::new()
365                        .inner_margin(4)
366                        .show(ui, |ui| t.edit_display(self.world, ui));
367                })
368            }
369            SidePanelTab::Details => {
370                for_all_tabs!(focused_tab, t, {
371                    egui::Frame::new()
372                        .inner_margin(4)
373                        .show(ui, |ui| t.display_display(self.world, ui));
374                })
375            }
376            SidePanelTab::Export => {
377                for_all_tabs!(focused_tab, t, {
378                    egui::Frame::new()
379                        .inner_margin(4)
380                        .show(ui, |ui| t.export_display(self.world, ui));
381                })
382            }
383        }
384    }
385    fn on_tab_button(&mut self, _tab: &mut Self::Tab, response: &egui::Response) {
386        if !response.clicked() {
387            return;
388        }
389        // we split the check into two parts. The first part is the new tab check
390        let reload = response.ctx.data_mut(|map| {
391            // store the previous tab id
392            let previous_tab =
393                map.get_temp_mut_or(Id::new("side panel previous tab id"), response.id);
394            if *previous_tab != response.id {
395                *previous_tab = response.id;
396                true
397            } else {
398                false
399            }
400        });
401        if !reload {
402            return;
403        }
404        // reset the animation
405        response.ctx.data_mut(|map| {
406            let opacity: &mut f32 = map.get_temp_mut_or(Id::new("side panel opacity"), 0.0);
407            *opacity = 0.0;
408        });
409    }
410    fn title(&mut self, tab: &mut Self::Tab) -> egui::WidgetText {
411        match tab {
412            SidePanelTab::Edit => tr!("side-panel-edit"),
413            SidePanelTab::Details => tr!("side-panel-details"),
414            SidePanelTab::Export => tr!("side-panel-export"),
415        }
416        .into()
417    }
418    fn id(&mut self, tab: &mut Self::Tab) -> Id {
419        match tab {
420            SidePanelTab::Edit => Id::new("edit"),
421            SidePanelTab::Details => Id::new("details"),
422            SidePanelTab::Export => Id::new("export"),
423        }
424    }
425    fn is_closeable(&self, _tab: &Self::Tab) -> bool {
426        false
427    }
428}
429
430/// WASM fullscreen functions
431/// SAFETY: These functions are unsafe because they interact with the DOM directly.
432#[cfg(target_arch = "wasm32")]
433#[wasm_bindgen(inline_js = r#"
434export function go_fullscreen(id) {
435    const el = document.getElementById(id);
436    if (el?.requestFullscreen) el.requestFullscreen();
437}
438export function exit_fullscreen() {
439    if (document.fullscreenElement) {
440        document.exitFullscreen();
441    }
442}
443"#)]
444unsafe extern "C" {
445    fn go_fullscreen(id: &str);
446    fn exit_fullscreen();
447}
448
449/// Main function to show the user interface
450pub fn show_ui(app: &mut super::PaiagramApp, ctx: &egui::Context) -> Result<()> {
451    ctx.request_repaint_after(std::time::Duration::from_millis(500));
452    let mut mus = app
453        .bevy_app
454        .world_mut()
455        .remove_resource::<MiscUiState>()
456        .unwrap();
457    let mut side_panel_state = app
458        .bevy_app
459        .world_mut()
460        .remove_resource::<SidePanelState>()
461        .unwrap();
462    let frame_time = mus.mean_frame_time();
463    app.bevy_app
464        .world_mut()
465        .resource_scope(|world, mut ui_state: Mut<UiState>| {
466            for (_, tab) in ui_state.dock_state.iter_all_tabs_mut() {
467                for_all_tabs!(tab, t, t.pre_render(world));
468            }
469            egui::TopBottomPanel::top("menu_bar")
470                .frame(Frame::side_top_panel(&ctx.style()))
471                .show(&ctx, |ui| {
472                    ui.horizontal(|ui| {
473                        ui.checkbox(&mut mus.is_dark_mode, "D").changed().then(|| {
474                            if mus.is_dark_mode {
475                                ctx.set_theme(egui::Theme::Dark);
476                            } else {
477                                ctx.set_theme(egui::Theme::Light);
478                            }
479                        });
480                        #[cfg(not(target_arch = "wasm32"))]
481                        if ui.button("F").clicked() {
482                            ui.ctx()
483                                .send_viewport_cmd(egui::ViewportCommand::Fullscreen(
484                                    !mus.fullscreened,
485                                ));
486                            mus.fullscreened = !mus.fullscreened;
487                        }
488                        #[cfg(target_arch = "wasm32")]
489                        if ui.button("F").clicked() {
490                            /// SAFETY: This function is unsafe because it interacts with the DOM directly.
491                            unsafe {
492                                if mus.fullscreened {
493                                    exit_fullscreen();
494                                } else {
495                                    go_fullscreen("paiagram_canvas");
496                                }
497                            }
498                            mus.fullscreened = !mus.fullscreened;
499                        }
500                        if ui.button("S").clicked() {
501                            mus.supplementary_panel_state.is_on_bottom =
502                                !mus.supplementary_panel_state.is_on_bottom;
503                        }
504                        if ui.button("A").clicked() {
505                            mus.supplementary_panel_state.expanded =
506                                !mus.supplementary_panel_state.expanded;
507                        }
508                        world
509                            .run_system_cached_with(about::show_about, (ui, &mut mus.modal_open))
510                            .unwrap();
511                    })
512                });
513
514            let old_bg_stroke_color = ctx.style().visuals.widgets.noninteractive.bg_stroke.color;
515
516            ctx.style_mut(|s| {
517                s.visuals.widgets.noninteractive.bg_stroke.color =
518                    colors::translate_srgba_to_color32(EMERALD_700)
519            });
520            // TODO: make the bottom status bar a separate system
521            egui::TopBottomPanel::bottom("status_bar")
522                .frame(
523                    Frame::side_top_panel(&ctx.style())
524                        .fill(colors::translate_srgba_to_color32(EMERALD_800)),
525                )
526                .show(&ctx, |ui| {
527                    ui.visuals_mut().override_text_color = Some(Color32::from_gray(200));
528                    ui.horizontal(|ui| {
529                        ui.label(&world.resource::<StatusBarState>().tooltip);
530                        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
531                            let current_time = chrono::Local::now();
532                            ui.monospace(current_time.format("%H:%M:%S").to_string());
533                            if !world
534                                .resource::<ApplicationSettings>()
535                                .show_performance_stats
536                            {
537                                return;
538                            }
539                            ui.monospace(format!("eFPS: {:>6.1}", 1.0 / frame_time));
540                            ui.monospace(format!("{:>5.2} ms/f", 1e3 * frame_time));
541                        });
542                    });
543                });
544
545            ctx.style_mut(|s| {
546                s.visuals.widgets.noninteractive.bg_stroke.color = old_bg_stroke_color
547            });
548
549            let supplementary_panel_content = |ui: &mut Ui| {
550                let focused_tab = ui_state
551                    .dock_state
552                    .find_active_focused()
553                    .map(|(_, tab)| tab);
554                let mut side_panel_viewer = SidePanelViewer { world, focused_tab };
555                let mut style = egui_dock::Style::from_egui(ui.style());
556                style.tab.tab_body.inner_margin = Margin::same(1);
557                style.tab.tab_body.corner_radius = CornerRadius::ZERO;
558                style.tab.tab_body.stroke.width = 0.0;
559                style.tab_bar.corner_radius = CornerRadius::ZERO;
560                DockArea::new(&mut side_panel_state.dock_state)
561                    .id(Id::new("Side panel stuff"))
562                    .draggable_tabs(false)
563                    .show_leaf_close_all_buttons(false)
564                    .show_leaf_collapse_buttons(false)
565                    .style(style)
566                    .show_inside(ui, &mut side_panel_viewer);
567            };
568
569            if mus.supplementary_panel_state.is_on_bottom {
570                egui::TopBottomPanel::bottom("TreeView")
571                    .frame(egui::Frame::new())
572                    .resizable(false)
573                    .exact_height(ctx.used_size().y / 2.5)
574                    .show_animated(
575                        ctx,
576                        mus.supplementary_panel_state.expanded,
577                        supplementary_panel_content,
578                    );
579            } else {
580                egui::SidePanel::left("TreeView")
581                    .frame(egui::Frame::new())
582                    .default_width(ctx.used_size().x / 4.0)
583                    .show_animated(
584                        &ctx,
585                        mus.supplementary_panel_state.expanded,
586                        supplementary_panel_content,
587                    );
588            }
589
590            egui::CentralPanel::default()
591                .frame(egui::Frame::central_panel(&ctx.style()).inner_margin(Margin::ZERO))
592                .show(&ctx, |ui| {
593                    let painter = ui.painter();
594                    let max_rect = ui.max_rect();
595                    painter.rect_filled(
596                        max_rect,
597                        CornerRadius::ZERO,
598                        colors::translate_srgba_to_color32(GRAY_900),
599                    );
600                    const LINE_SPACING: f32 = 24.0;
601                    const LINE_STROKE: Stroke = Stroke {
602                        color: Color32::from_additive_luminance(30),
603                        width: 1.0,
604                    };
605                    let x_max = (ui.available_size().x / LINE_SPACING) as usize;
606                    let y_max = (ui.available_size().y / LINE_SPACING) as usize;
607                    for xi in 0..=x_max {
608                        let mut x = xi as f32 * LINE_SPACING + max_rect.min.x;
609                        LINE_STROKE.round_center_to_pixel(ui.pixels_per_point(), &mut x);
610                        painter.vline(x, max_rect.min.y..=max_rect.max.y, LINE_STROKE);
611                    }
612                    for yi in 0..=y_max {
613                        let mut y = yi as f32 * LINE_SPACING + max_rect.min.y;
614                        LINE_STROKE.round_center_to_pixel(ui.pixels_per_point(), &mut y);
615                        painter.hline(max_rect.min.x..=max_rect.max.x, y, LINE_STROKE);
616                    }
617                    let focused_id = ui_state
618                        .dock_state
619                        .find_active_focused()
620                        .map(|(_, tab)| tab.id());
621                    ui.ctx().data_mut(|map| {
622                        let previously_focused =
623                            map.get_temp_mut_or(Id::new("previously focused leaf"), focused_id);
624                        if *previously_focused == focused_id {
625                            return;
626                        } else {
627                            *previously_focused = focused_id;
628                            let opacity: &mut f32 =
629                                map.get_temp_mut_or(Id::new("side panel opacity"), 0.0);
630                            *opacity = 0.0;
631                        }
632                    });
633                    let mut tab_viewer = AppTabViewer {
634                        world,
635                        ctx,
636                        focused_id,
637                    };
638                    let mut style = egui_dock::Style::from_egui(ui.style());
639                    style.tab.tab_body.inner_margin = Margin::same(0);
640                    style.tab.tab_body.corner_radius = CornerRadius::ZERO;
641                    style.tab.tab_body.stroke.width = 0.0;
642                    style.tab.active.outline_color = Color32::TRANSPARENT;
643                    style.tab.inactive.outline_color = Color32::TRANSPARENT;
644                    style.tab.focused.outline_color = Color32::TRANSPARENT;
645                    style.tab.hovered.outline_color = Color32::TRANSPARENT;
646                    style.tab.inactive_with_kb_focus.outline_color = Color32::TRANSPARENT;
647                    style.tab.active_with_kb_focus.outline_color = Color32::TRANSPARENT;
648                    style.tab.focused_with_kb_focus.text_color = Color32::TRANSPARENT;
649                    style.tab.active.corner_radius = CornerRadius::ZERO;
650                    style.tab.inactive.corner_radius = CornerRadius::ZERO;
651                    style.tab.focused.corner_radius = CornerRadius::ZERO;
652                    style.tab.hovered.corner_radius = CornerRadius::ZERO;
653                    style.tab.inactive_with_kb_focus.corner_radius = CornerRadius::ZERO;
654                    style.tab.active_with_kb_focus.corner_radius = CornerRadius::ZERO;
655                    style.tab.focused_with_kb_focus.corner_radius = CornerRadius::ZERO;
656                    style.tab_bar.corner_radius = CornerRadius::ZERO;
657                    style.tab_bar.hline_color = Color32::TRANSPARENT;
658                    style.tab.hline_below_active_tab_name = true;
659                    style.overlay.selection_corner_radius = CornerRadius::same(4);
660                    style.tab_bar.height = 32.0;
661                    // place a button on the bottom left corner for expanding and collapsing the side panel.
662                    let left_bottom = ui.max_rect().left_bottom();
663                    let shift = egui::Vec2 { x: 20.0, y: -40.0 };
664                    DockArea::new(&mut ui_state.dock_state)
665                        .style(style)
666                        .show_inside(ui, &mut tab_viewer);
667                    let res = ui.place(
668                        Rect::from_two_pos(left_bottom, left_bottom + shift),
669                        |ui: &mut Ui| {
670                            let (resp, painter) =
671                                ui.allocate_painter(ui.available_size(), Sense::click());
672                            let rect = resp.rect;
673                            painter.add(Shape::convex_polygon(
674                                vec![
675                                    rect.left_bottom() + egui::Vec2 { x: 0.0, y: 0.0 },
676                                    rect.left_bottom() + egui::Vec2 { x: 0.0, y: -40.0 },
677                                    rect.left_bottom() + egui::Vec2 { x: 20.0, y: -20.0 },
678                                    rect.left_bottom() + egui::Vec2 { x: 20.0, y: 0.0 },
679                                ],
680                                ui.visuals().widgets.hovered.bg_fill,
681                                Stroke::NONE,
682                            ));
683                            resp
684                        },
685                    );
686                    if res.clicked() {
687                        mus.supplementary_panel_state.expanded =
688                            !mus.supplementary_panel_state.expanded;
689                    }
690                });
691            for (_, tab) in ui_state.dock_state.iter_all_tabs_mut() {
692                for_all_tabs!(tab, t, t.post_render(world));
693            }
694        });
695    app.bevy_app.world_mut().insert_resource(mus);
696    app.bevy_app.world_mut().insert_resource(side_panel_state);
697    Ok(())
698}
699
700/// Apply custom fonts to the egui context
701pub fn apply_custom_fonts(ctx: &egui::Context) {
702    let mut fonts = egui::FontDefinitions::default();
703
704    fonts.font_data.insert(
705        "app_default".to_owned(),
706        Arc::new(egui::FontData::from_static(include_bytes!(
707            "../assets/fonts/SarasaUiSC-Regular.ttf"
708        ))),
709    );
710
711    fonts.font_data.insert(
712        "app_mono".to_owned(),
713        Arc::new(egui::FontData::from_static(include_bytes!(
714            "../assets/fonts/SarasaTermSC-Regular.ttf"
715        ))),
716    );
717
718    if let Some(family) = fonts.families.get_mut(&egui::FontFamily::Proportional) {
719        family.insert(0, "app_default".to_owned());
720    }
721    if let Some(family) = fonts.families.get_mut(&egui::FontFamily::Monospace) {
722        family.insert(0, "app_mono".to_owned());
723    }
724
725    ctx.set_fonts(fonts);
726}