Skip to main content

brep_app/workbench/
ecad.rs

1//! The eCAD editors, and their actions as workbench buttons.
2//!
3//! Four workbenches host eCAD (`brep_ecad_egui`): **Diagram** and **PCB** run its
4//! sheet-and-board [`Editor`] — on a wiring document and on a schematic
5//! document — while **Symbol** and **Pads** run its [`SymbolEditor`] and
6//! [`FootprintEditor`]. eCAD keeps every toolbar command as data
7//! ([`brep_ecad_egui::Action`]), with predicates over the editor saying whether it is
8//! offered, enabled and pressed, and what its button says. The workbench
9//! buttons here are those actions: each button asks eCAD's own predicate about
10//! the editor it names ([`offered`], [`pressed`], [`disabled`], [`caption`]),
11//! and a press runs eCAD's own action ([`dispatch`]). BREP decides only which
12//! actions reach a row and what they look like.
13//!
14//! # Ids
15//!
16//! A button id leads with its workbench ([`super::button_prefix`]). Diagram
17//! and PCB take their buttons from ONE eCAD table, whose ids lead with a group
18//! (`view.`, `sheet.`, `board.`), so each puts its own workbench in front —
19//! `diagram.sheet.tool.select`, `pcb.sheet.tool.select` — and [`split`] takes
20//! it off again. The symbol and pads editors' ids already lead with `symbol.`
21//! and `pads.`, which ARE their workbenches' ids, so their buttons are eCAD's
22//! ids verbatim. Two workbenches sharing a table is what made the prefix law
23//! necessary: see `a_duplicate_id_names_the_first_workbench_s_button_everywhere`.
24//!
25//! # Which actions reach a row
26//!
27//! eCAD groups its actions ([`brep_ecad_egui::ActionGroup`]). The rows carry View,
28//! Tool, Layer and Context. History (undo, redo) and Zoom (in, out, fit) are
29//! not here: BREP's main toolbar owns Undo, Redo and Zoom to fit, as the
30//! Drawing workbench's sheet already relies on. Selection is keyboard-only in
31//! eCAD's own toolbars too. Of Context, the six actions that only put the tool
32//! down ([`PUT_DOWN`]) are not here: the row's Select and Escape do that, and
33//! BREP's sketch row already teaches "press the lit tool to put it down". The
34//! six copper-layer actions are one MENU button ([`LAYER_MENU_ID`]), because a
35//! layer's name depends on the board's layer count and no fixed picture can say
36//! it.
37//!
38//! # When they are offered
39//!
40//! An eCAD button is offered only while the document HAS editors, its own
41//! workbench is the active one, no sketch is being edited, and eCAD offers the
42//! action ([`active_target`]). The active-workbench condition keeps them out
43//! of All's row: All draws the 3D view, so no editor is on screen under it, and
44//! Diagram's and PCB's copies of one action would stand side by side there —
45//! the duplicate-id collision in another form. The sketch condition is the same
46//! thought for the shared sketch tools: a sketch takes the viewport over, and
47//! its Select beside an editor's Select would be two identical hands.
48
49use super::ButtonState;
50use brep_ecad_egui::{Action, Editor, FootprintEditor, SymbolEditor};
51
52/// One document's eCAD editors: what every eCAD button reads and every press
53/// runs on. The app keeps one per document, beside its engine, so each tab has
54/// its own diagram and board and a tab switch keeps them.
55pub struct Editors {
56    /// The Diagram workbench's editor, on a wiring document.
57    pub diagram: Editor,
58    /// The PCB workbench's editor, on a schematic document with its board.
59    pub pcb: Editor,
60    /// The Symbol workbench's editor: the part's schematic symbol.
61    pub symbol: SymbolEditor,
62    /// The Pads workbench's editor: the part's footprint pads.
63    pub pads: FootprintEditor,
64    /// A selection a HOST asked an editor to make, held until that editor is
65    /// next drawn: `(which editor, the pin or pad NUMBER)`.
66    ///
67    /// Deferred, not set at the moment of asking, because the ask crosses a
68    /// workbench switch. The editor is re-handed its block on the frame it is
69    /// drawn (`viewport::ecad`'s pull), and a load CLEARS the selection — so a
70    /// selection made now would be wiped by the very switch that was supposed
71    /// to show it. [`Self::apply_focus`] runs after the pull instead, which is
72    /// where `set_pins` already runs for the same reason.
73    focus: Option<(Target, String)>,
74}
75
76impl Editors {
77    /// Empty editors: a blank wiring diagram, a blank schematic, a new symbol
78    /// and an empty footprint. The Diagram and PCB libraries offer the parts the
79    /// document's assembly places (`panels::ecad_parts`); no symbols ship with
80    /// the editors.
81    pub fn new() -> Self {
82        let mut diagram = Editor::default();
83        diagram.set_document(brep_ecad_core::Document::new(brep_ecad_core::DocumentKind::Wiring));
84        let mut pcb = Editor::default();
85        pcb.set_document(brep_ecad_core::Document::new(brep_ecad_core::DocumentKind::Schematic));
86        Self {
87            diagram,
88            pcb,
89            symbol: SymbolEditor::blank(),
90            pads: FootprintEditor::new(Default::default(), None, Vec::new()),
91            focus: None,
92        }
93    }
94
95    /// Ask `target`'s editor to select the pin or pad numbered `number` the
96    /// next time it draws. The Qualify panel's jump: a connection point, its
97    /// pin and its pad all carry ONE name, so the number is the whole ask.
98    pub fn focus(&mut self, target: Target, number: String) {
99        self.focus = Some((target, number));
100    }
101
102    /// Make the selection [`Self::focus`] asked for, if it was asked of
103    /// `target`. Called by the host AFTER the pull, once per frame the editor
104    /// is drawn; returns whether anything was selected. A number the editor
105    /// does not have clears the ask all the same — a pin the symbol has not
106    /// got yet is not a standing request.
107    pub fn apply_focus(&mut self, target: Target) -> bool {
108        match self.focus.take_if(|(asked, _)| *asked == target) {
109            Some((Target::Symbol, number)) => self.symbol.select_pin(&number),
110            Some((Target::Pads, number)) => self.pads.select_pad(&number),
111            // Diagram and PCB select components, not pins; nothing asks yet.
112            _ => false,
113        }
114    }
115
116    /// Route selection keys to the visible editor even when the pointer is in a dock.
117    pub fn selection_key(&mut self, target: Target, delete: bool) {
118        match target {
119            Target::Diagram | Target::Pcb => {
120                let editor = if target == Target::Diagram { &mut self.diagram } else { &mut self.pcb };
121                let action = match (editor.view, delete) {
122                    (brep_ecad_egui::View::Board, true) => "board.delete",
123                    (brep_ecad_egui::View::Board, false) => "board.cancel",
124                    (_, true) => "sheet.delete",
125                    (_, false) => "sheet.cancel",
126                };
127                editor.run_action(action);
128            }
129            Target::Symbol => { self.symbol.run_action(if delete { "symbol.delete" } else { "symbol.tool.select" }); }
130            Target::Pads => { self.pads.run_action(if delete { "pads.delete" } else { "pads.tool.select" }); }
131        }
132    }
133
134}
135
136impl Default for Editors {
137    fn default() -> Self {
138        Self::new()
139    }
140}
141
142/// Which of the four editors a button acts on.
143#[derive(Clone, Copy, Debug, PartialEq, Eq)]
144pub enum Target {
145    Diagram,
146    Pcb,
147    Symbol,
148    Pads,
149}
150
151impl Target {
152    pub const ALL: [Target; 4] = [Target::Diagram, Target::Pcb, Target::Symbol, Target::Pads];
153
154    /// The workbench that draws this editor. It is also the key of the
155    /// document block the editor's document is kept in (`diagram`, `pcb`,
156    /// `symbol`, `pads`): the two namings agree by construction.
157    pub fn workbench_id(self) -> &'static str {
158        match self {
159            Target::Diagram => "diagram",
160            Target::Pcb => "pcb",
161            Target::Symbol => "symbol",
162            Target::Pads => "pads",
163        }
164    }
165
166    /// The editor a workbench id drives, or `None` for a workbench that is not
167    /// one of the four. A plain mapping — unlike [`active_target`] it asks
168    /// nothing about editors or sketch mode, so the host can ask it at the
169    /// moment it must END a sketch because an eCAD workbench became active.
170    pub fn of_workbench(id: &str) -> Option<Target> {
171        Target::ALL.into_iter().find(|target| target.workbench_id() == id)
172    }
173}
174
175/// The dock pane that draws the active editor's parts library
176/// (`Editor::library_panel`): Diagram's and PCB's.
177pub const LIBRARY_PANEL_ID: &str = "ecadLibrary";
178
179/// The dock pane that draws the active editor's inspector — `Editor::inspector`
180/// for Diagram and PCB, `properties` for the symbol and pads editors. One pane
181/// for all four, as the assembly panes serve both Assembly and Wire harness.
182pub const INSPECTOR_PANEL_ID: &str = "ecadInspector";
183
184/// The editor a button id acts on and the eCAD action id it runs there, or
185/// `None` for an id that is no eCAD button's. The inverse of how the tables
186/// spell their ids: `diagram.` and `pcb.` come off, `symbol.*` and `pads.*`
187/// are eCAD's own.
188pub fn split(id: &str) -> Option<(Target, &str)> {
189    if let Some(action) = id.strip_prefix("diagram.") {
190        Some((Target::Diagram, action))
191    } else if let Some(action) = id.strip_prefix("pcb.") {
192        Some((Target::Pcb, action))
193    } else if id.starts_with("symbol.") {
194        Some((Target::Symbol, id))
195    } else if id.starts_with("pads.") {
196        Some((Target::Pads, id))
197    } else {
198        None
199    }
200}
201
202/// The buttons an eCAD workbench carries that are BREP's OWN, not eCAD actions —
203/// dispatched by the shell's own arms, never by [`dispatch`]. None today. Listed
204/// rather than inferred, so an eCAD button whose id is misspelt fails
205/// `every_ecad_workbench_button_is_an_ecad_action_or_brep_s_own` instead of
206/// passing quietly as one of BREP's.
207pub const BREP_OWN: &[&str] = &[];
208
209/// The editor and eCAD action a button id runs, only when the id IS an action in
210/// that editor's own table: [`split`] reads the prefix, this also asks eCAD.
211pub fn ecad_action(id: &str) -> Option<(Target, &str)> {
212    let (target, action) = split(id)?;
213    let known = match target {
214        Target::Diagram | Target::Pcb => brep_ecad_egui::actions().any(|a| a.id == action),
215        Target::Symbol => brep_ecad_egui::symbol_actions().iter().any(|a| a.id == action),
216        Target::Pads => brep_ecad_egui::pad_actions().iter().any(|a| a.id == action),
217    };
218    known.then_some((target, action))
219}
220
221/// The copper-layer MENU button's id. Not an eCAD action: its entries are
222/// (`board.layer.1` .. `board.layer.6`).
223pub const LAYER_MENU_ID: &str = "pcb.board.layer";
224
225/// The six Context actions that only abandon the gesture in progress and
226/// return to Select — five run `Editor::cancel`, the route one drops the draft
227/// and selects `BoardTool::Select`. The row's Select button and Escape
228/// (`sheet.cancel`, `board.cancel`) already do that, so they are not buttons.
229pub const PUT_DOWN: &[&str] = &[
230    "sheet.place.done",
231    "sheet.wire.done",
232    "sheet.label.done",
233    "sheet.move.cancel",
234    "sheet.junction.done",
235    "board.route.done",
236];
237
238/// Why eCAD disables an action it offers, for the few that it ever does. eCAD
239/// answers `enabled` with a bare bool, so the words are BREP's, one per action,
240/// read from the predicate it declares.
241const DISABLED_BECAUSE: &[(&str, &str)] = &[
242    ("view.schematic", "the autorouter is running"),
243    ("view.board", "the autorouter is running"),
244    ("sheet.wire.finish", "no wire is being drawn"),
245    ("board.route.via", "a via needs a track being drawn on a board with more than one copper layer"),
246    ("board.route.finish", "no track is being drawn"),
247    ("board.zone.finish", "a zone needs three corners before it can be closed"),
248    ("board.zones.fill", "the board has no zones"),
249];
250
251/// The words for an action eCAD disables with no entry in
252/// [`DISABLED_BECAUSE`] — a table gap, which `every_disabled_action_says_why`
253/// fails on rather than letting this reach a user.
254const DISABLED_UNEXPLAINED: &str = "the editor does not allow it right now";
255
256/// The editor the active workbench draws, if it is one of the four and
257/// something could draw it: the document has editors and no sketch is being
258/// edited (a sketch takes the viewport over). See the module doc for why each
259/// condition is there.
260///
261/// The stored workbench id is read directly, not through `resolve`: `resolve`
262/// answers only for a workbench in the dropdown, and these four are declared
263/// before they are registered ([`super::UNREGISTERED`]). The two readings
264/// agree on every registered id.
265pub fn active_target(state: &ButtonState) -> Option<Target> {
266    state.ecad?;
267    if state.engine.sketch_mode() {
268        return None;
269    }
270    Target::of_workbench(&state.engine.settings.workbench)
271}
272
273/// What eCAD says about one of its actions on one of its editors.
274struct Reading {
275    offered: bool,
276    enabled: bool,
277    pressed: Option<bool>,
278    caption: String,
279}
280
281fn read_in<T: 'static>(
282    mut table: impl Iterator<Item = &'static Action<T>>,
283    editor: &T,
284    action: &str,
285) -> Option<Reading> {
286    let action = table.find(|a| a.id == action)?;
287    Some(Reading {
288        offered: action.offered(editor),
289        enabled: action.enabled(editor),
290        pressed: action.pressed(editor),
291        caption: action.caption(editor),
292    })
293}
294
295/// eCAD's reading of `action` on `target`'s editor in `editors`.
296fn read(editors: &Editors, target: Target, action: &str) -> Option<Reading> {
297    match target {
298        Target::Diagram => read_in(brep_ecad_egui::actions(), &editors.diagram, action),
299        Target::Pcb => read_in(brep_ecad_egui::actions(), &editors.pcb, action),
300        Target::Symbol => read_in(brep_ecad_egui::symbol_actions().iter(), &editors.symbol, action),
301        Target::Pads => read_in(brep_ecad_egui::pad_actions().iter(), &editors.pads, action),
302    }
303}
304
305/// eCAD's reading of the action button `id` names, but only while that
306/// button's editor is the one on screen ([`active_target`]).
307fn reading(state: &ButtonState, id: &str) -> Option<Reading> {
308    let (target, action) = split(id)?;
309    if active_target(state) != Some(target) {
310        return None;
311    }
312    read(state.ecad?, target, action)
313}
314
315/// Whether the eCAD button `id` is offered: its editor is on screen and eCAD
316/// offers the action.
317pub fn offered(state: &ButtonState, id: &str) -> bool {
318    reading(state, id).is_some_and(|r| r.offered)
319}
320
321/// Whether the eCAD button `id` draws pressed: eCAD's own toggle state.
322pub fn pressed(state: &ButtonState, id: &str) -> bool {
323    reading(state, id).is_some_and(|r| r.pressed == Some(true))
324}
325
326/// Why the eCAD button `id` cannot act now, or `None` when eCAD enables it.
327pub fn disabled(state: &ButtonState, id: &str) -> Option<&'static str> {
328    let reading = reading(state, id)?;
329    if reading.enabled {
330        return None;
331    }
332    let action = split(id)?.1;
333    Some(
334        DISABLED_BECAUSE
335            .iter()
336            .find(|(known, _)| *known == action)
337            .map_or(DISABLED_UNEXPLAINED, |(_, why)| *why),
338    )
339}
340
341/// The eCAD button `id`'s live caption: eCAD's own button text, which for some
342/// actions describes state (`Bend: vertical first`, a layer's `B.Cu`).
343pub fn caption(state: &ButtonState, id: &str) -> String {
344    reading(state, id).map_or_else(
345        || super::button_by_declared_id(id).map_or_else(String::new, |b| b.tooltip.to_string()),
346        |r| r.caption,
347    )
348}
349
350/// The layer menu's live caption: the menu's own words and the layer it has
351/// on, so the closed menu still says where a track will go.
352pub fn layer_menu_caption(state: &ButtonState) -> String {
353    let on = super::pcb::LAYERS
354        .iter()
355        .find(|entry| entry.offered(state) && entry.is_pressed(state))
356        .map(|entry| entry.detail(state));
357    match on {
358        Some(layer) => format!("Copper layer: {layer}"),
359        None => "Copper layer".to_string(),
360    }
361}
362
363/// Run the eCAD action the button `id` names on its editor, as a click on
364/// eCAD's own button does. `None` when `id` is no declared button that IS an
365/// eCAD action — a menu is not one (its entries are), and neither is a button
366/// of BREP's own on an eCAD workbench ([`BREP_OWN`]), which the shell's own
367/// arms run. Without that, such a button would reach eCAD's `run_action`,
368/// read `false`, and be reported handled: a press that vanishes. Otherwise
369/// eCAD's answer, `false` when the action is not offered or not enabled now.
370pub fn dispatch(editors: &mut Editors, id: &str) -> Option<bool> {
371    dispatch_in(&super::declared_workbenches(), editors, id)
372}
373
374/// [`dispatch`] over any registry.
375fn dispatch_in(registry: &[&'static super::Workbench], editors: &mut Editors, id: &str) -> Option<bool> {
376    let button = super::button_in(registry, id)?;
377    if !button.menu.is_empty() {
378        return None;
379    }
380    let (target, action) = ecad_action(id)?;
381    Some(match target {
382        Target::Diagram => editors.diagram.run_action(action),
383        Target::Pcb => editors.pcb.run_action(action),
384        Target::Symbol => editors.symbol.run_action(action),
385        Target::Pads => editors.pads.run_action(action),
386    })
387}
388
389/// One eCAD action as a workbench button. `$id` is the BUTTON id — eCAD's
390/// action id behind its workbench's prefix, see [`split`] — and every
391/// predicate is eCAD's own, asked through this module. `toggle` is an action
392/// eCAD reports a pressed state for (a tool, a view, a layer); `command` one it
393/// does not. The tooltip is eCAD's label, word for word — the tests hold it.
394macro_rules! ecad_button {
395    ($id:literal, $glyph:literal, $tooltip:literal, $path:literal, toggle) => {
396        $crate::workbench::WorkbenchButton {
397            id: $id,
398            glyph: $glyph,
399            tooltip: $tooltip,
400            ribbon_path: $path,
401            size: $crate::workbench::CommandSize::Compact,
402            when: Some(|s| $crate::workbench::ecad::offered(s, $id)),
403            pressed: Some(|s| $crate::workbench::ecad::pressed(s, $id)),
404            disabled: Some(|s| $crate::workbench::ecad::disabled(s, $id)),
405            caption: Some(|s| $crate::workbench::ecad::caption(s, $id)),
406            menu: &[],
407        }
408    };
409    ($id:literal, $glyph:literal, $tooltip:literal, $path:literal, command) => {
410        $crate::workbench::WorkbenchButton {
411            id: $id,
412            glyph: $glyph,
413            tooltip: $tooltip,
414            ribbon_path: $path,
415            size: $crate::workbench::CommandSize::Compact,
416            when: Some(|s| $crate::workbench::ecad::offered(s, $id)),
417            pressed: None,
418            disabled: Some(|s| $crate::workbench::ecad::disabled(s, $id)),
419            caption: Some(|s| $crate::workbench::ecad::caption(s, $id)),
420            menu: &[],
421        }
422    };
423}
424pub(crate) use ecad_button;
425
426/// A workbench whose feature filter offers no features: eCAD's documents are
427/// not history features, as PMI's annotations and Drawing's sheets are not.
428pub(crate) fn no_features(_feature: &super::FeatureInfo<'_>) -> bool {
429    false
430}
431