paiagram/
interface.rs

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