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
32pub 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#[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; }
96 self.frame_times.add(now, previous_frame_time); }
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
111fn 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 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#[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 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#[derive(Message)]
195pub enum UiCommand {
196 OpenOrFocusTab(AppTab),
197}
198
199struct 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 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#[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
346pub 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 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 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 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
565fn 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}