diff --git a/committed.toml b/committed.toml index bd1d79f..3addd45 100644 --- a/committed.toml +++ b/committed.toml @@ -11,7 +11,7 @@ merge_commit = false allowed_scopes = [ # Generated using `ls crates | awk '{ print "\"" $1 "\","; }' | sort`. "planar-incr-embed", - "specctra-core", + "specctra", "specctra_derive", "topola-cli", "topola-egui", diff --git a/topola-egui/Cargo.toml b/topola-egui/Cargo.toml index c55e099..bcbadd1 100644 --- a/topola-egui/Cargo.toml +++ b/topola-egui/Cargo.toml @@ -1,34 +1,48 @@ +# SPDX-FileCopyrightText: 2024 Topola contributors +# +# SPDX-License-Identifier: MIT OR Apache-2.0 + [package] name = "topola-egui" description = "Graphical user interface for Topola PCB router in Egui/Eframe framework." version = "0.1.0" edition = "2024" +[features] +default = ["xdg-portal"] +gtk3 = ["rfd/gtk3"] +xdg-portal = ["rfd/xdg-portal"] + [package.metadata.docs.rs] all-features = true targets = ["x86_64-unknown-linux-gnu", "wasm32-unknown-unknown"] [dependencies] -egui = "0.33.0" -eframe = { version = "0.33.0", default-features = false, features = [ - "accesskit", # Make egui compatible with screen readers. NOTE: adds a lot of dependencies. +derive-getters.workspace = true +egui = "0.33" +eframe = { version = "0.33", default-features = false, features = [ + #"accesskit", # Make egui compatible with screen readers. NOTE: adds a lot of dependencies. "default_fonts", # Embed the default egui fonts. "glow", # Use the glow rendering backend. Alternative: "wgpu". "persistence", # Enable restoring app state when restarting the app. "wayland", # To support Linux (and CI). "x11", # To support older Linux distributions (restores one of the default features). ] } -log = "0.4.27" -topola = { version = "0.1", path = "../topola" } +fluent-templates = "0.13" +log = "0.4" +rfd = { version = "0.17", default-features = false } +specctra = { path = "../specctra" } +topola = { path = "../topola" } +unic-langid = { version = "0.9", features = ["macros", "serde"] } -# You only need serde if you want app persistence: -serde = { version = "1.0.219", features = ["derive"] } +serde = { version = "1.0", features = ["derive"] } # Native: [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -env_logger = "0.11.8" +futures-lite = "2.6" +env_logger = "0.11" # Web: [target.'cfg(target_arch = "wasm32")'.dependencies] -wasm-bindgen-futures = "0.4.50" -web-sys = "0.3.70" # to access the DOM to hide the loading text. +wasm-bindgen-futures = "0.4" +web-sys = "0.3" # To access the DOM to hide the loading text. diff --git a/topola-egui/src/action.rs b/topola-egui/src/action.rs new file mode 100644 index 0000000..348816c --- /dev/null +++ b/topola-egui/src/action.rs @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: 2024 Topola contributors +// +// SPDX-License-Identifier: MIT + +pub struct Action { + name: String, + shortcut: Option, +} + +pub struct Trigger { + action: Action, + triggered: bool, +} + +pub struct Switch { + action: Action, +} + +impl Action { + pub fn new(name: String, modifiers: egui::Modifiers, key: egui::Key) -> Self { + Self { + name, + shortcut: Some(egui::KeyboardShortcut::new(modifiers, key)), + } + } + + pub fn new_keyless(name: String) -> Self { + Self { + name, + shortcut: None, + } + } + + fn widget_text(&self) -> String { + if let Some(shortcut) = self.shortcut { + format!( + "{} ({})", + self.name, + shortcut.format(&egui::ModifierNames::NAMES, false) + ) + } else { + self.name.clone() + } + } + + #[inline] + pub fn into_trigger(self) -> Trigger { + Trigger { + action: self, + triggered: false, + } + } + + #[inline(always)] + pub fn into_switch(self) -> Switch { + Switch { action: self } + } +} + +impl Trigger { + pub fn button(&mut self, _ctx: &egui::Context, ui: &mut egui::Ui) { + self.triggered = ui.button(self.action.widget_text()).clicked(); + } + + pub fn hyperlink(&self, _ctx: &egui::Context, ui: &mut egui::Ui, url: &str) { + ui.hyperlink_to(self.action.widget_text(), url); + } + + pub fn consume_key_triggered(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) -> bool { + self.consume_key(ctx, ui); + self.triggered + } + + fn consume_key(&mut self, ctx: &egui::Context, _ui: &mut egui::Ui) { + if let Some(shortcut) = self.action.shortcut { + if ctx.input_mut(|i| i.consume_shortcut(&shortcut)) { + self.triggered = true; + } + } + } +} + +impl Switch { + pub fn toggle_widget(&self, ui: &mut egui::Ui, selected: &mut bool) { + ui.toggle_value(selected, self.action.widget_text()); + } + + pub fn checkbox(&self, ui: &mut egui::Ui, selected: &mut bool) { + ui.checkbox(selected, self.action.widget_text()); + } + + pub fn consume_key_enabled( + &self, + ctx: &egui::Context, + _ui: &mut egui::Ui, + selected: &mut bool, + ) -> bool { + if let Some(shortcut) = self.action.shortcut { + if ctx.input_mut(|i| i.consume_shortcut(&shortcut)) { + *selected = !*selected; + } + } + + *selected + } +} diff --git a/topola-egui/src/actions.rs b/topola-egui/src/actions.rs new file mode 100644 index 0000000..57e3f17 --- /dev/null +++ b/topola-egui/src/actions.rs @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: 2026 Topola contributors +// +// SPDX-License-Identifier: MIT + +use crate::{ + action::{Action, Trigger}, + translator::Translator, +}; + +pub struct Actions { + pub file: FileActions, +} + +impl Actions { + pub fn new(tr: &Translator) -> Self { + Self { + file: FileActions::new(tr), + } + } +} + +pub struct FileActions { + pub open_design: Trigger, + pub export_session: Trigger, + pub quit: Trigger, +} + +impl FileActions { + pub fn new(tr: &Translator) -> Self { + Self { + open_design: Action::new( + tr.text("tr-menu-file-open"), + egui::Modifiers::CTRL, + egui::Key::O, + ) + .into_trigger(), + export_session: Action::new( + tr.text("tr-menu-file-export-session-file"), + egui::Modifiers::CTRL, + egui::Key::O, + ) + .into_trigger(), + quit: Action::new( + tr.text("tr-menu-file-quit"), + egui::Modifiers::CTRL, + egui::Key::Q, + ) + .into_trigger(), + } + } + + pub fn render_menu(&mut self, ctx: &egui::Context, ui: &mut egui::Ui, have_workspace: bool) { + self.open_design.button(ctx, ui); + ui.add_enabled_ui(have_workspace, |ui| { + self.export_session.button(ctx, ui); + + ui.separator(); + + /*self.import_history.button(ctx, ui); + self.export_history.button(ctx, ui);*/ + }); + + ui.separator(); + + // "Quit" button wouldn't work on a Web page. + if !cfg!(target_arch = "wasm32") { + self.quit.button(ctx, ui); + } + } +} diff --git a/topola-egui/src/app.rs b/topola-egui/src/app.rs index a05e9cf..817765c 100644 --- a/topola-egui/src/app.rs +++ b/topola-egui/src/app.rs @@ -1,17 +1,39 @@ -/// We derive Deserialize/Serialize so we can persist app state on shutdown. -#[derive(serde::Deserialize, serde::Serialize)] -#[serde(default)] // if we add new fields, give them default values when deserializing old state +// SPDX-FileCopyrightText: 2026 Topola contributors +// +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use std::sync::mpsc::{Receiver, Sender, channel}; + +use specctra::{error::ParseErrorContext, structure::DsnFile}; +use topola::Board; +use unic_langid::langid; + +use crate::{menu_bar::MenuBar, translator::Translator, workspace::Workspace}; + pub struct App { + translator: Translator, + + content_channel: ( + Sender>, + Receiver>, + ), + + menu_bar: MenuBar, view_rect: egui::Rect, + workspace: Option, } impl Default for App { fn default() -> Self { Self { + translator: Translator::new(langid!("en-US")), + content_channel: channel(), + menu_bar: MenuBar::new(), view_rect: egui::Rect::from_min_max( egui::pos2(-100.0, 100.0), egui::pos2(100.0, 100.0), ), + workspace: None, } } } @@ -22,38 +44,91 @@ impl App { // Restore the persistent part of the app's state from its previous run // if there is one. if let Some(storage) = cc.storage { - eframe::get_value(storage, eframe::APP_KEY).unwrap_or_default() + //eframe::get_value(storage, eframe::APP_KEY).unwrap_or_default() + Default::default() } else { Default::default() } } + + fn update_state(&mut self) { + if let Ok(data) = self.content_channel.1.try_recv() { + self.workspace = Some(Workspace::new( + Board::from_specctra(data.unwrap()), + &self.translator, + )); + } + } + + /// Update the title displayed on the application window's frame to show the + /// currently opened file, if any, and other possible information about the + /// application state. + #[cfg(not(target_arch = "wasm32"))] + fn update_title(&mut self, ctx: &egui::Context) { + /*if let Some(workspace) = &self.maybe_workspace { + if let Some(filename) = Path::new(workspace.design.get_name()) + .file_name() + .and_then(|n| n.to_str()) + { + ctx.send_viewport_cmd(egui::ViewportCommand::Title(filename.to_string())); + // TODO: Also show file's dirty bit. + } + }*/ + } + + /// Update the title displayed on the application window's frame to show the + /// currently opened file, if any, and other possible information about the + /// application state. + #[cfg(target_arch = "wasm32")] + fn update_title(&mut self, ctx: &egui::Context) { + if let Some(workspace) = &self.maybe_workspace { + if let Some(filename) = Path::new(workspace.design.get_name()) + .file_name() + .and_then(|n| n.to_str()) + { + let document = eframe::web_sys::window() + .expect("No window") + .document() + .expect("No document"); + + document.set_title(filename); + // TODO: Also show file's dirty bit. + } + } + } + + /// Handle a possible locale change. + #[cfg(not(target_arch = "wasm32"))] + fn update_locale(&mut self) { + // I don't know any equivalent of changing the lang property in desktop. + } + + /// Handle a possible locale change. + #[cfg(target_arch = "wasm32")] + fn update_locale(&mut self) { + let document_element = eframe::web_sys::window() + .expect("No window") + .document() + .expect("No document") + .document_element() + .expect("No document element"); + + document_element.set_attribute("lang", &self.translator.langid().to_string()); + } } impl eframe::App for App { /// Called to save state before shutdown. fn save(&mut self, storage: &mut dyn eframe::Storage) { - eframe::set_value(storage, eframe::APP_KEY, self); + //eframe::set_value(storage, eframe::APP_KEY, self); } /// Called each time the UI has to be repainted. fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| { - egui::MenuBar::new().ui(ui, |ui| { - ui.menu_button("File", |ui| { - // Quit is not present on Web; the app can't close a webpage - // by itself. - if !cfg!(target_arch = "wasm32") { - if ui.button("Quit").clicked() { - ctx.send_viewport_cmd(egui::ViewportCommand::Close); - } - } - }); + self.menu_bar + .update(ctx, &mut self.translator, self.content_channel.0.clone()); - ui.separator(); - - egui::widgets::global_theme_preference_switch(ui); - }); - }); + self.update_state(); egui::CentralPanel::default().show(ctx, |ui| { egui::Scene::new() @@ -63,5 +138,30 @@ impl eframe::App for App { .circle_filled(egui::pos2(0.0, 0.0), 20.0, egui::Color32::RED); }); }); + + self.update_locale(); + self.update_title(ctx); } } + +#[cfg(not(target_arch = "wasm32"))] +pub fn execute + Send + 'static>(f: F) { + std::thread::spawn(move || futures_lite::future::block_on(f)); +} + +#[cfg(target_arch = "wasm32")] +pub fn execute + 'static>(f: F) { + wasm_bindgen_futures::spawn_local(f); +} + +pub async fn handle_file( + file_handle: &rfd::FileHandle, +) -> std::io::Result { + #[cfg(not(target_arch = "wasm32"))] + let res = std::io::BufReader::new(std::fs::File::open(file_handle.path())?); + + #[cfg(target_arch = "wasm32")] + let res = std::io::Cursor::new(file_handle.read().await); + + Ok(res) +} diff --git a/topola-egui/src/main.rs b/topola-egui/src/main.rs index 29a6152..10d5f35 100644 --- a/topola-egui/src/main.rs +++ b/topola-egui/src/main.rs @@ -1,11 +1,16 @@ // SPDX-FileCopyrightText: 2026 Topola contributors // -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: MIT OR Apache-2.0 #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release +mod action; +mod actions; mod app; -use app::App; +mod menu_bar; +mod translator; +mod workspace; +use crate::app::App; // When compiling natively: #[cfg(not(target_arch = "wasm32"))] diff --git a/topola-egui/src/menu_bar.rs b/topola-egui/src/menu_bar.rs index 8cb322a..6a9fce8 100644 --- a/topola-egui/src/menu_bar.rs +++ b/topola-egui/src/menu_bar.rs @@ -1,3 +1,69 @@ // SPDX-FileCopyrightText: 2026 Topola contributors // // SPDX-License-Identifier: MIT + +use std::sync::mpsc::Sender; + +use serde::{Deserialize, Serialize}; +use specctra::{ + error::{ParseError, ParseErrorContext}, + read::ListTokenizer, + structure::DsnFile, +}; + +use crate::{ + actions::Actions, + app::{execute, handle_file}, + translator::Translator, +}; + +#[derive(Deserialize, Serialize)] +pub struct MenuBar { + step_rate: f32, +} + +impl MenuBar { + pub fn new() -> Self { + Self { step_rate: 1.0 } + } + + pub fn update( + &mut self, + ctx: &egui::Context, + tr: &mut Translator, + content_sender: Sender>, + ) { + let mut actions = Actions::new(tr); + + egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| { + egui::MenuBar::new().ui(ui, |ui| { + ui.menu_button("File", |ui| { + actions.file.render_menu(ctx, ui, false); + }); + + ui.separator(); + egui::widgets::global_theme_preference_switch(ui); + }); + + 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() + .add_filter(tr.text("tr-menu-open-specctra-design-file"), &["dsn"]) + .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(|reader| ListTokenizer::new(reader).read_value::()); + content_sender.send(data); + + ctx.request_repaint(); + } + }); + } + }); + } +} diff --git a/topola-egui/src/translator.rs b/topola-egui/src/translator.rs index 4fcf47c..6bd074c 100644 --- a/topola-egui/src/translator.rs +++ b/topola-egui/src/translator.rs @@ -3,13 +3,13 @@ // SPDX-License-Identifier: MIT use derive_getters::Getters; -use fluent_templates::{static_loader, Loader}; +use fluent_templates::{Loader, static_loader}; use serde::{Deserialize, Serialize}; use unic_langid::LanguageIdentifier; static_loader! { static LOCALES = { - locales: "../../locales", + locales: "../locales", fallback_language: "en-US", }; } diff --git a/topola-egui/src/workspace.rs b/topola-egui/src/workspace.rs new file mode 100644 index 0000000..a4f50a8 --- /dev/null +++ b/topola-egui/src/workspace.rs @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2026 Topola contributors +// +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use topola::Board; + +use crate::translator::Translator; + +pub struct Workspace { + pub board: Board, +} + +impl Workspace { + pub fn new(board: Board, tr: &Translator) -> Self { + Self { board } + } +} diff --git a/topola/Cargo.toml b/topola/Cargo.toml index 1126c08..c9f0c3f 100644 --- a/topola/Cargo.toml +++ b/topola/Cargo.toml @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: 2026 Topola contributors +# +# SPDX-License-Identifier: MIT OR Apache-2.0 + [package] name = "topola" description = "Work-in-progress free and open-source topological (rubberband) router and autorouter for printed circuit boards (PCBs)" @@ -8,6 +12,7 @@ edition = "2024" derive-getters.workspace = true derive_more.workspace = true serde.workspace = true +specctra = { path = "../specctra" } stable-vec = "0.4" undoredo.workspace = true diff --git a/topola/src/board.rs b/topola/src/board.rs index afbb333..b6f7c66 100644 --- a/topola/src/board.rs +++ b/topola/src/board.rs @@ -1,6 +1,6 @@ // SPDX-FileCopyrightText: 2026 Topola contributors // -// SPDX-License-Identifier: MIT OR Apache-2.0 +// SPDX-License-Identifier: MIT use derive_getters::{Dissolve, Getters}; use undoredo::{ApplyDelta, Delta, FlushDelta}; @@ -18,9 +18,9 @@ pub struct Board { } impl Board { - pub fn new() -> Self { + pub fn new(boundary: Vec<[i64; 2]>) -> Self { Self { - layout: Layout::new(), + layout: Layout::new(boundary), } } } diff --git a/topola/src/layout.rs b/topola/src/layout.rs index 64d34e1..16aa55a 100644 --- a/topola/src/layout.rs +++ b/topola/src/layout.rs @@ -1,6 +1,6 @@ // SPDX-FileCopyrightText: 2026 Topola contributors // -// SPDX-License-Identifier: MIT OR Apache-2.0 +// SPDX-License-Identifier: MIT use std::collections::BTreeMap; @@ -103,6 +103,8 @@ pub struct Via { #[derive(Clone, Debug)] pub struct Layout { + boundary: Vec<[i64; 2]>, + place_boundary: Vec<[i64; 2]>, joints: Recorder>, segments: Recorder>, arcs: Recorder>, @@ -110,8 +112,10 @@ pub struct Layout { } impl Layout { - pub fn new() -> Self { + pub fn new(boundary: Vec<[i64; 2]>) -> Self { Self { + boundary: boundary.clone(), + place_boundary: boundary, joints: Recorder::new(StableVec::new()), segments: Recorder::new(StableVec::new()), arcs: Recorder::new(StableVec::new()), diff --git a/topola/src/lib.rs b/topola/src/lib.rs index 7d26849..21e8dc5 100644 --- a/topola/src/lib.rs +++ b/topola/src/lib.rs @@ -1,6 +1,9 @@ // SPDX-FileCopyrightText: 2026 Topola contributors // -// SPDX-License-Identifier: MIT OR Apache-2.0 +// SPDX-License-Identifier: MIT mod board; mod layout; +mod specctra; + +pub use crate::board::Board; diff --git a/topola/src/specctra.rs b/topola/src/specctra.rs new file mode 100644 index 0000000..c03afad --- /dev/null +++ b/topola/src/specctra.rs @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2026 Topola contributors +// +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use specctra::structure::DsnFile; + +use crate::board::Board; + +impl Board { + pub fn from_specctra(dsn: DsnFile) -> Self { + Board::new( + dsn.pcb + .structure + .boundary + .coords() + .into_owned() + .into_iter() + .map(|p| [p.x as i64, p.y as i64]) + .collect(), + ) + } +}