// SPDX-FileCopyrightText: 2024 Topola contributors // // SPDX-License-Identifier: MIT use std::{ops::ControlFlow, path::Path, sync::mpsc::Sender}; use topola::{ autorouter::{ execution::Command, invoker::InvokerError, selection::Selection, AutorouterOptions, }, router::RouterOptions, specctra::{design::SpecctraDesign, ParseError, ParseErrorContext as SpecctraLoadingError}, }; use crate::{ actions::Actions, app::{execute, handle_file}, translator::Translator, viewport::Viewport, workspace::Workspace, }; pub struct MenuBar { pub autorouter_options: AutorouterOptions, pub is_placing_via: bool, pub show_ratsnest: bool, pub show_navmesh: bool, pub show_bboxes: bool, pub show_origin_destination: bool, pub show_appearance_panel: bool, pub frame_timestep: f32, } impl MenuBar { pub fn new() -> Self { Self { autorouter_options: AutorouterOptions { presort_by_pairwise_detours: false, router_options: RouterOptions { routed_band_width: 100.0, wrap_around_bands: true, squeeze_through_under_bends: true, }, }, is_placing_via: false, show_ratsnest: true, show_navmesh: false, show_bboxes: false, show_origin_destination: false, show_appearance_panel: true, frame_timestep: 0.1, } } pub fn update( &mut self, ctx: &egui::Context, tr: &mut Translator, content_sender: Sender>, viewport: &mut Viewport, maybe_workspace: Option<&mut Workspace>, ) -> Result<(), InvokerError> { let mut actions = Actions::new(tr); let online_documentation_url = "https://topola.codeberg.page/doc/"; let workspace_activities_enabled = match &maybe_workspace { Some(w) => w .interactor .maybe_activity() .as_ref() .map_or(true, |activity| { matches!(activity.maybe_status(), Some(ControlFlow::Break(..))) }), None => false, }; egui::TopBottomPanel::top("menu_bar") .show(ctx, |ui| { egui::menu::bar(ui, |ui| { egui::widgets::global_theme_preference_switch(ui); ui.separator(); ui.menu_button(tr.text("tr-menu-file"), |ui| { actions.file.render_menu(ctx, ui, maybe_workspace.is_some()) }); ui.menu_button(tr.text("tr-menu-edit"), |ui| { actions.edit.render_menu( ctx, ui, maybe_workspace.is_some(), workspace_activities_enabled, ) }); ui.menu_button(tr.text("tr-menu-view"), |ui| { actions.view.render_menu(ctx, ui, tr, self, viewport); ui.separator(); ui.label(tr.text("tr-menu-view-frame-timestep")); ui.add( egui::widgets::Slider::new(&mut self.frame_timestep, 0.0..=3.0) .suffix(" s"), ); }); // NOTE: we could disable the entire range of menus below // when no workspace is loaded, but that would disrupt "hover-scrolling" // between menus inside of the conditionally enabled section and // those outside... ui.menu_button(tr.text("tr-menu-place"), |ui| { actions.place.render_menu( ctx, ui, maybe_workspace.is_some(), &mut self.is_placing_via, ) }); ui.menu_button(tr.text("tr-menu-route"), |ui| { actions.route.render_menu( ctx, ui, tr, maybe_workspace.is_some(), workspace_activities_enabled, &mut self.autorouter_options, ) }); ui.menu_button(tr.text("tr-menu-inspect"), |ui| { actions .inspect .render_menu(ctx, ui, workspace_activities_enabled) }); Self::update_preferences_menu(ctx, ui, tr); ui.menu_button(tr.text("tr-menu-help"), |ui| { actions.help.render_menu(ctx, ui, online_documentation_url) }); ui.separator(); }); if actions.file.open_design.consume_key_triggered(ctx, ui) { // NOTE: On Linux, this requires Zenity to be installed on your system. let ctx = ctx.clone(); let task = rfd::AsyncFileDialog::new().pick_file(); execute(async move { if let Some(file_handle) = task.await { let data = handle_file(&file_handle) .await .map_err(|e| ParseError::from(e).add_context((0, 0))) .and_then(SpecctraDesign::load); content_sender.send(data); ctx.request_repaint(); } }); } else if actions.file.quit.consume_key_triggered(ctx, ui) { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } else if actions .help .online_documentation .consume_key_triggered(ctx, ui) { ui.ctx().open_url(egui::OpenUrl { url: String::from(online_documentation_url), new_tab: true, }); } else if let Some(workspace) = maybe_workspace { if actions.file.export_session.consume_key_triggered(ctx, ui) { let ctx = ui.ctx().clone(); let board = workspace.interactor.invoker().autorouter().board(); // FIXME: I don't know how to avoid buffering the entire exported file let mut writebuf = vec![]; workspace.design.write_ses(board, &mut writebuf); let mut dialog = rfd::AsyncFileDialog::new(); if let Some(filename) = Path::new(workspace.design.get_name()).file_stem() { if let Some(filename) = filename.to_str() { dialog = dialog.set_file_name(filename); } } let task = dialog .add_filter(tr.text("tr-menu-open-specctra-session-file"), &["ses"]) .save_file(); execute(async move { if let Some(file_handle) = task.await { file_handle.write(&writebuf).await; ctx.request_repaint(); } }); } else if actions.file.import_history.consume_key_triggered(ctx, ui) { let ctx = ctx.clone(); let task = rfd::AsyncFileDialog::new().pick_file(); let history_sender = workspace.history_channel.0.clone(); execute(async move { if let Some(file_handle) = task.await { let data = handle_file(&file_handle).await.and_then(|data| { match serde_json::from_reader(data) { Ok(history) => Ok(Ok(history)), Err(err) if err.is_io() => Err(err.into()), Err(err) => Ok(Err(err)), } }); history_sender.send(data); ctx.request_repaint(); } }); } else if actions.file.export_history.consume_key_triggered(ctx, ui) { let ctx = ctx.clone(); let task = rfd::AsyncFileDialog::new().save_file(); // FIXME: I don't think we should be buffering everything in a `Vec`. let mut writebuf = vec![]; serde_json::to_writer_pretty( &mut writebuf, workspace.interactor.invoker().history(), ); execute(async move { if let Some(file_handle) = task.await { file_handle.write(&writebuf).await; ctx.request_repaint(); } }); } else if actions.edit.undo.consume_key_triggered(ctx, ui) { workspace.interactor.undo(); } else if actions.edit.redo.consume_key_triggered(ctx, ui) { workspace.interactor.redo(); } else if actions.edit.abort.consume_key_triggered(ctx, ui) { workspace.interactor.abort(); } else if actions.edit.unselect_all.consume_key_triggered(ctx, ui) { // NOTE: we need to check `unselect` first because `Ctrl+A` would also match `Ctrl+Shift+A` workspace.overlay.unselect_all(); } else if actions.edit.select_all.consume_key_triggered(ctx, ui) { let board = workspace.interactor.invoker().autorouter().board(); workspace.overlay.select_all(board); } else if actions.place.place_via.consume_key_enabled( ctx, ui, &mut self.is_placing_via, ) { } else if workspace_activities_enabled { let mut schedule = |op: fn(Selection, AutorouterOptions) -> Command| { let selection = workspace.overlay.take_selection(); workspace .interactor .schedule(op(selection, self.autorouter_options)); Ok::<(), InvokerError>(()) }; if actions.edit.remove_bands.consume_key_triggered(ctx, ui) { schedule(|selection, _| { Command::RemoveBands(selection.band_selection) })?; } else if actions.route.autoroute.consume_key_triggered(ctx, ui) { schedule(|selection, opts| { Command::Autoroute(selection.pin_selection, opts) })?; } else if actions .inspect .compare_detours .consume_key_triggered(ctx, ui) { schedule(|selection, opts| { Command::CompareDetours(selection.pin_selection, opts) })?; } else if actions .inspect .measure_length .consume_key_triggered(ctx, ui) { schedule(|selection, _| { Command::MeasureLength(selection.band_selection) })?; } } } Ok::<(), InvokerError>(()) }) .inner } pub fn update_preferences_menu(ctx: &egui::Context, ui: &mut egui::Ui, tr: &mut Translator) { ui.menu_button(tr.text("tr-menu-preferences"), |ui| { ui.menu_button(tr.text("tr-menu-preferences-set-language"), |ui| { use icu_experimental::displaynames::{ DisplayNamesOptions, Fallback, LocaleDisplayNamesFormatter, }; use icu_locale_core::{locale, LanguageIdentifier, Locale}; let mut display_names_options: DisplayNamesOptions = Default::default(); display_names_options.fallback = Fallback::None; for langid in Translator::locales() { if let Ok(locale) = Locale::try_from_str(&langid.to_string()) { if let Ok(formatter) = LocaleDisplayNamesFormatter::try_new( locale.clone().into(), display_names_options, ) { // NOTE: I don't know how to reliably detect if there's no display name // in the current locale to fall back to the English display name. // NOTE: At the time of writing, `Fallback::None` handling hasn't been // implemented in the ICU library, despite this enum variant existing. ui.radio_value(tr.langid_mut(), langid.clone(), formatter.of(&locale)); continue; } } ui.radio_value(tr.langid_mut(), langid.clone(), langid.to_string()); } }); }); } }