//! iced shell entry point for the FEMM 4.2 port. mod doc_canvas; mod kinematic; mod spice; use doc_canvas::{CanvasMessage, PickOp, PickRestrict, RenderMode, Tool, ViewState}; use femm_doc_mag::{ArcSegment, BlockLabel, FemmDoc, Node, Segment}; use femm_doc_mag::ans::MagSolution; use femm_doc_mag::mesh::Mesh; use iced::widget::{button, column, container, row, scrollable, svg, text, text_input, tooltip}; use iced::{Alignment, Background, Border, Color, Element, Length, Point, Rectangle, Subscription, Task, Theme, Vector, clipboard, time}; use std::panic::{AssertUnwindSafe, catch_unwind}; use std::path::{Path, PathBuf}; use std::time::Duration; const ICON_FILE_OPEN: &[u8] = include_bytes!("../../../assets/icons/file-open.svg"); const ICON_FILE_NEW: &[u8] = include_bytes!("../../../assets/icons/file-new.svg"); const ICON_FILE_SAVE: &[u8] = include_bytes!("../../../assets/icons/file-save.svg"); const ICON_FILE_SAVE_AS: &[u8] = include_bytes!("../../../assets/icons/file-save-as.svg"); const ICON_TOOL_SELECT: &[u8] = include_bytes!("../../../assets/icons/tool-select.svg"); const ICON_TOOL_NODE: &[u8] = include_bytes!("../../../assets/icons/tool-add-node.svg"); const ICON_TOOL_SEGMENT: &[u8] = include_bytes!("../../../assets/icons/tool-add-segment.svg"); const ICON_TOOL_LABEL: &[u8] = include_bytes!("../../../assets/icons/tool-add-label.svg"); const ICON_EDIT_DELETE: &[u8] = include_bytes!("../../../assets/icons/edit-delete.svg"); const ICON_EDIT_COPY: &[u8] = include_bytes!("../../../assets/icons/edit-copy.svg"); const ICON_ZOOM_IN: &[u8] = include_bytes!("../../../assets/icons/view-zoom-in.svg"); const ICON_ZOOM_OUT: &[u8] = include_bytes!("../../../assets/icons/view-zoom-out.svg"); const ICON_ZOOM_FIT: &[u8] = include_bytes!("../../../assets/icons/view-zoom-fit.svg"); const ICON_ZOOM_WINDOW: &[u8] = include_bytes!("../../../assets/icons/view-zoom-window.svg"); const ICON_GRID: &[u8] = include_bytes!("../../../assets/icons/view-grid.svg"); const ICON_GRID_SNAP: &[u8] = include_bytes!("../../../assets/icons/view-grid-snap.svg"); const ICON_PX: f32 = 28.0; const DEMO_FEM: &str = include_str!("../assets/brgmodel.fem"); const ADD_TOLERANCE: f64 = 0.5; const MIN_ANGLE_DEG: f64 = 30.0; const SIM_DEFAULT_DT_S: f64 = 1.0e-4; const SIM_DEFAULT_INTERVAL_S: f64 = 0.05; const SIM_DEFAULT_SUBDIVISIONS: usize = 20; const SIM_DEFAULT_EXPRESSION: &str = "0.5 * sin(pi*s) * cos(2*pi*82.41*t)"; #[derive(Debug, Clone)] enum Message { OpenFem, NewDoc, SaveDoc, SaveDocAs, CopySelection, ZoomIn, ZoomOut, ZoomFit, ZoomWindowToggle, ZoomSelection, ToggleGrid, ToggleSnap, SelectTool(Tool), Canvas(CanvasMessage), RunMesh, RunAnalyze, SetRenderMode(RenderMode), CopyError, DismissError, SimAddTrackFromSelection, SimRemoveTrack(usize), SimSelectTrack(usize), SimEditExpressionText(String), SimSubmitExpression, SimSetDtText(String), SimSubmitDt, SimSetIntervalText(String), SimSubmitInterval, SimSetSubdivisionsText(String), SimSubmitSubdivisions, SimToggleRun, SimTick, SimResetTime, SimClear, } /// copyable diagnostic shown when a pipeline stage fails. #[derive(Debug, Clone)] struct ErrorReport { title: String, body: String, } /// continuous-time simulation: captured base doc, per-track displacement expressions, SPICE-controlled step, live mag-solution at t_now. struct Simulation { base_doc: FemmDoc, tracks: Vec, t_now: f64, dt: f64, wall_interval: Duration, running: bool, computing: bool, current: Option, last_solve_ms: u128, selected_track: Option, expression_text: String, dt_text: String, interval_text: String, subdivisions: usize, subdivisions_text: String, } struct App { doc: FemmDoc, mesh: Option, solution: Option, source_label: String, source_path: Option, status: String, error: Option, tool: Tool, render_mode: RenderMode, simulation: Option, view_state: ViewState, show_grid: bool, snap_to_grid: bool, zoom_window_active: bool, canvas_size: Option<(f32, f32)>, } impl App { fn new() -> (Self, Task) { let doc = FemmDoc::parse(DEMO_FEM).unwrap_or_default(); let app = App { doc, mesh: None, solution: None, source_label: String::from("brgmodel.fem (embedded)"), source_path: None, status: String::new(), error: None, tool: Tool::Select, render_mode: RenderMode::default(), simulation: None, view_state: ViewState::default(), show_grid: false, snap_to_grid: false, zoom_window_active: false, canvas_size: None, }; (app, Task::none()) } fn title(&self) -> String { format!("femm42 - {}", self.source_label) } /// applies a zoom multiplier around a focus offset measured from canvas centre, adjusting pan to keep that world point fixed. fn apply_zoom_factor(&mut self, factor: f32, focus_rel: Point) { let prev = if self.view_state.zoom <= 0.0 { 1.0 } else { self.view_state.zoom }; let next = (prev * factor).clamp(doc_canvas::ZOOM_MIN, doc_canvas::ZOOM_MAX); let real = next / prev; self.view_state.pan.x = focus_rel.x * (1.0 - real) + real * self.view_state.pan.x; self.view_state.pan.y = focus_rel.y * (1.0 - real) + real * self.view_state.pan.y; self.view_state.zoom = next; } /// rounds a click world coordinate to the 1-cm grid step under snap-to-grid mode. fn maybe_snap(&self, w: (f64, f64)) -> (f64, f64) { if self.snap_to_grid { let u = self.doc.length_units; (doc_canvas::snap_world(w.0, u), doc_canvas::snap_world(w.1, u)) } else { w } } fn update(&mut self, msg: Message) -> Task { match msg { Message::OpenFem => { let picked = rfd::FileDialog::new() .add_filter("FEMM magnetostatic", &["fem", "FEM"]) .add_filter("All files", &["*"]) .pick_file(); if let Some(path) = picked { let label = path .file_name() .and_then(|n| n.to_str()) .unwrap_or("?") .to_string(); match FemmDoc::open(&path) { Ok(d) => { self.doc = d; self.mesh = None; self.solution = None; self.source_label = label; self.source_path = Some(path); self.status = String::new(); } Err(e) => { self.status = format!("failed to open {label}: {e}"); } } } } Message::NewDoc => { self.doc = FemmDoc::default(); self.mesh = None; self.solution = None; self.source_label = String::from("untitled"); self.source_path = None; self.error = None; self.status = String::from("new doc"); } Message::SaveDoc => { if let Some(path) = self.source_path.clone() { match self.doc.save(&path) { Ok(()) => self.status = format!("saved: {}", path.display()), Err(e) => self.error = Some(ErrorReport { title: String::from("save .fem failed"), body: format!("{path:?}\n\n{e}"), }), } } else { return Task::done(Message::SaveDocAs); } } Message::SaveDocAs => { let picked = rfd::FileDialog::new() .add_filter("FEMM magnetostatic", &["fem"]) .save_file(); if let Some(path) = picked { match self.doc.save(&path) { Ok(()) => { let label = path .file_name() .and_then(|n| n.to_str()) .unwrap_or("?") .to_string(); self.status = format!("saved: {}", path.display()); self.source_label = label; self.source_path = Some(path); } Err(e) => self.error = Some(ErrorReport { title: String::from("save .fem failed"), body: format!("{path:?}\n\n{e}"), }), } } } Message::CopySelection => { let added = duplicate_selection(&mut self.doc); if added == 0 { self.status = String::from("nothing selected to copy"); } else { self.mesh = None; self.solution = None; self.status = format!("duplicated {added} entities, offset +10mm"); } } Message::ZoomIn => self.apply_zoom_factor(1.25, Point::ORIGIN), Message::ZoomOut => self.apply_zoom_factor(0.80, Point::ORIGIN), Message::ZoomFit => { self.view_state = ViewState::default(); self.zoom_window_active = false; } Message::ZoomWindowToggle => { self.zoom_window_active = !self.zoom_window_active; self.status = if self.zoom_window_active { String::from("zoom-window mode: drag a rectangle on the canvas") } else { String::from("zoom-window cancelled") }; } Message::ZoomSelection => { let Some((cw, ch)) = self.canvas_size else { self.error = Some(ErrorReport { title: String::from("canvas size unknown"), body: String::from("move the cursor over the canvas once, then try Zoom Selection again."), }); return Task::none(); }; match selection_bbox(&self.doc) { Some((xmin, xmax, ymin, ymax)) => { let rect = Rectangle { x: 0.0, y: 0.0, width: cw, height: ch }; if let Some((pan, zoom)) = doc_canvas::zoom_window_view(&self.doc, rect, xmin, xmax, ymin, ymax) { self.view_state.pan = pan; self.view_state.zoom = zoom; self.status = format!("zoomed to selection bbox: x [{xmin:.3}, {xmax:.3}], y [{ymin:.3}, {ymax:.3}]"); } } None => { self.error = Some(ErrorReport { title: String::from("nothing selected"), body: String::from("select at least one node, segment, arc, or label, then click Zoom Selection."), }); } } } Message::ToggleGrid => { self.show_grid = !self.show_grid; self.status = format!("grid: {}", if self.show_grid { "on" } else { "off" }); } Message::ToggleSnap => { self.snap_to_grid = !self.snap_to_grid; self.status = format!("snap: {}", if self.snap_to_grid { "on" } else { "off" }); } Message::SelectTool(t) => { self.tool = t; } Message::RunMesh => { let outcome = catch_unwind(AssertUnwindSafe(|| run_mesh(&self.doc))); match outcome { Ok(Ok(m)) => { self.status = format!("meshed: {} nodes, {} elements", m.nodes.len(), m.elements.len()); self.error = None; self.mesh = Some(m); } Ok(Err(report)) => { self.mesh = None; self.solution = None; self.status = format!("mesh failed: {}", report.title); self.error = Some(report); } Err(payload) => { self.mesh = None; self.solution = None; self.status = String::from("mesh panicked"); self.error = Some(ErrorReport { title: String::from("mesh stage panicked"), body: panic_payload_text(&payload), }); } } } Message::RunAnalyze => { self.solution = None; let mesh_outcome = catch_unwind(AssertUnwindSafe(|| run_mesh(&self.doc))); match mesh_outcome { Ok(Ok(m)) => { self.mesh = Some(m); self.error = None; } Ok(Err(report)) => { self.mesh = None; self.status = format!("mesh failed: {}", report.title); self.error = Some(report); return Task::none(); } Err(payload) => { self.mesh = None; self.status = String::from("mesh panicked"); self.error = Some(ErrorReport { title: String::from("mesh stage panicked"), body: panic_payload_text(&payload), }); return Task::none(); } } let stem = active_stem(); if let Err(report) = run_solve(&stem) { self.status = format!("solve failed: {}", report.title); self.error = Some(report); return Task::none(); } let ans_path = stem.with_extension("ans"); match MagSolution::open(&ans_path) { Ok(sol) => { self.status = format!( "solved: {} mesh nodes, {} elements", sol.mesh_nodes.len(), sol.mesh_elements.len(), ); self.error = None; self.solution = Some(sol); } Err(e) => { self.status = String::from("read .ans failed"); self.error = Some(ErrorReport { title: String::from("read .ans failed"), body: format!("{ans_path:?}\n\n{e}"), }); } } } Message::CopyError => { if let Some(report) = &self.error { let text = format!("[{}]\n\n{}", report.title, report.body); return clipboard::write(text); } } Message::DismissError => { self.error = None; } Message::Canvas(CanvasMessage::Click { world, tool }) => { let world = self.maybe_snap(world); match tool { Tool::AddNode => { let idx = self.doc.add_node(world.0, world.1, ADD_TOLERANCE); self.mesh = None; self.solution = None; self.status = format!("node {idx} at ({:.3}, {:.3})", world.0, world.1); } Tool::AddBlockLabel => { let idx = self.doc.add_block_label(world.0, world.1, ADD_TOLERANCE); self.mesh = None; self.solution = None; self.status = format!("block label {idx} at ({:.3}, {:.3})", world.0, world.1); } Tool::Select | Tool::AddSegment => {} } } Message::Canvas(CanvasMessage::SegmentBetween { from, to }) => { let from = self.maybe_snap(from); let to = self.maybe_snap(to); let n0 = self.doc.add_node(from.0, from.1, ADD_TOLERANCE) as i32; let n1 = self.doc.add_node(to.0, to.1, ADD_TOLERANCE) as i32; if self.doc.add_segment(n0, n1) { self.mesh = None; self.status = format!( "segment {n0} -> {n1} ({} total)", self.doc.segments.len(), ); } else { self.status = format!("rejected segment {n0} -> {n1}"); } } Message::Canvas(CanvasMessage::PanBy { dx, dy }) => { self.view_state.pan = self.view_state.pan + Vector::new(dx, dy); } Message::Canvas(CanvasMessage::ZoomAt { factor, focus_rel }) => { self.apply_zoom_factor(factor, focus_rel); } Message::Canvas(CanvasMessage::ResetView) => { self.view_state = ViewState::default(); self.zoom_window_active = false; } Message::Canvas(CanvasMessage::ViewportSize { width, height }) => { self.canvas_size = Some((width, height)); } Message::Canvas(CanvasMessage::SetView { pan, zoom }) => { self.view_state.pan = pan; self.view_state.zoom = zoom; self.zoom_window_active = false; } Message::Canvas(CanvasMessage::PickAt { world, pick_radius_world, op, restrict }) => { let summary = apply_pick_at(&mut self.doc, world.0, world.1, pick_radius_world, op, restrict); self.status = summary; } Message::Canvas(CanvasMessage::PickRect { p0, p1, op, restrict }) => { let summary = apply_pick_rect(&mut self.doc, p0, p1, op, restrict); self.status = summary; } Message::SetRenderMode(mode) => { self.render_mode = mode; } Message::SimAddTrackFromSelection => { let sel_segs: Vec = self.doc.segments.iter().enumerate() .filter_map(|(i, s)| if s.selected { Some(i) } else { None }) .collect(); if sel_segs.is_empty() { self.error = Some(ErrorReport { title: String::from("no segments selected"), body: String::from("select at least one segment (left-click or marquee). the track moves every node along selected segments between their leftmost and rightmost endpoints."), }); } else { let subdivisions = self.simulation.as_ref() .map(|s| s.subdivisions) .unwrap_or(SIM_DEFAULT_SUBDIVISIONS); let expr_text = self.simulation.as_ref() .map(|s| s.expression_text.clone()) .unwrap_or_else(|| String::from(SIM_DEFAULT_EXPRESSION)); let mut endpoint_nodes: Vec = Vec::new(); for &i in &sel_segs { let seg = &self.doc.segments[i]; if !endpoint_nodes.contains(&(seg.n0 as usize)) { endpoint_nodes.push(seg.n0 as usize); } if !endpoint_nodes.contains(&(seg.n1 as usize)) { endpoint_nodes.push(seg.n1 as usize); } } let mut all_members: Vec = endpoint_nodes.clone(); let mut to_subdivide = sel_segs; to_subdivide.sort_by(|a, b| b.cmp(a)); for idx in to_subdivide { let new_nodes = self.doc.subdivide_segment(idx, subdivisions); for n in new_nodes { all_members.push(n as usize); } } let label = format!("track {}", self.simulation.as_ref().map(|s| s.tracks.len() + 1).unwrap_or(1)); match kinematic::track_from_selection(&self.doc, &all_members, kinematic::Axis::PlusY, &expr_text, label) { Ok(track) => { if self.simulation.is_none() { self.simulation = Some(new_simulation(self.doc.clone(), expr_text)); } let sim = self.simulation.as_mut().unwrap(); sim.base_doc = self.doc.clone(); sim.tracks.push(track); sim.selected_track = Some(sim.tracks.len() - 1); self.status = format!("track added: {} members; {} tracks active", sim.tracks.last().map(|t| t.member_nodes.len()).unwrap_or(0), sim.tracks.len()); } Err(e) => { self.error = Some(ErrorReport { title: String::from("track creation failed"), body: e, }); } } } } Message::SimRemoveTrack(i) => { if let Some(sim) = self.simulation.as_mut() { if i < sim.tracks.len() { sim.tracks.remove(i); sim.selected_track = if sim.tracks.is_empty() { None } else { Some(i.min(sim.tracks.len() - 1)) }; } } } Message::SimSelectTrack(i) => { if let Some(sim) = self.simulation.as_mut() { if let Some(t) = sim.tracks.get(i) { sim.selected_track = Some(i); sim.expression_text = t.expression.source.clone(); } } } Message::SimEditExpressionText(v) => { if let Some(sim) = self.simulation.as_mut() { sim.expression_text = v; } } Message::SimSubmitExpression => { if let Some(sim) = self.simulation.as_mut() { if let Some(i) = sim.selected_track { match kinematic::Expression::parse(&sim.expression_text) { Ok(expr) => { if let Some(t) = sim.tracks.get_mut(i) { t.expression = expr; self.status = format!("track {} expression updated", i + 1); } } Err(e) => { self.error = Some(ErrorReport { title: String::from("expression parse failed"), body: format!("{}\n\n{e}", sim.expression_text), }); } } } } } Message::SimSetDtText(v) => { if let Some(sim) = self.simulation.as_mut() { sim.dt_text = v; } } Message::SimSubmitDt => { if let Some(sim) = self.simulation.as_mut() { match spice::parse_spice(&sim.dt_text) { Some(dt) if dt > 0.0 => { sim.dt = dt; sim.dt_text = spice::format_spice_time(dt); self.status = format!("dt = {}", sim.dt_text); } _ => { self.error = Some(ErrorReport { title: String::from("dt parse failed"), body: format!("could not read {:?} as SPICE time (try 100u, 1ms, 33n)", sim.dt_text), }); } } } } Message::SimSetIntervalText(v) => { if let Some(sim) = self.simulation.as_mut() { sim.interval_text = v; } } Message::SimSubmitInterval => { if let Some(sim) = self.simulation.as_mut() { match spice::parse_spice(&sim.interval_text) { Some(secs) if secs > 0.0 => { sim.wall_interval = Duration::from_secs_f64(secs); sim.interval_text = spice::format_spice_time(secs); self.status = format!("wall interval = {}", sim.interval_text); } _ => { self.error = Some(ErrorReport { title: String::from("interval parse failed"), body: format!("could not read {:?} as SPICE time", sim.interval_text), }); } } } } Message::SimSetSubdivisionsText(v) => { if let Some(sim) = self.simulation.as_mut() { sim.subdivisions_text = v; } } Message::SimSubmitSubdivisions => { if let Some(sim) = self.simulation.as_mut() { match sim.subdivisions_text.trim().parse::() { Ok(n) if n >= 2 => { sim.subdivisions = n; self.status = format!("subdivisions per added segment = {n}"); } _ => { self.error = Some(ErrorReport { title: String::from("subdivision count invalid"), body: format!("need an integer ≥ 2; got {:?}", sim.subdivisions_text), }); } } } } Message::SimToggleRun => { let Some(sim) = self.simulation.as_mut() else { self.error = Some(ErrorReport { title: String::from("no simulation"), body: String::from("create at least one track before running. select a segment and click \"Track from Selection\"."), }); return Task::none(); }; if sim.tracks.is_empty() { self.error = Some(ErrorReport { title: String::from("no tracks defined"), body: String::from("the simulation has no tracks to displace. select a segment, click \"Track from Selection\", then Run."), }); return Task::none(); } sim.running = !sim.running; let status = if sim.running { format!("running: t = {}", spice::format_spice_time(sim.t_now)) } else { format!("paused: t = {}", spice::format_spice_time(sim.t_now)) }; self.status = status; if sim.running { return Task::done(Message::SimTick); } } Message::SimTick => { let should_step = match self.simulation.as_ref() { Some(sim) => sim.running && !sim.computing && !sim.tracks.is_empty(), None => false, }; if !should_step { return Task::none(); } let (t_target, dt) = { let sim = self.simulation.as_ref().unwrap(); (sim.t_now + sim.dt, sim.dt) }; { let sim = self.simulation.as_mut().unwrap(); sim.computing = true; kinematic::apply_tracks(&mut self.doc, &sim.base_doc, &sim.tracks, t_target); } let started = std::time::Instant::now(); let mesh_result = catch_unwind(AssertUnwindSafe(|| run_mesh(&self.doc))); match mesh_result { Ok(Ok(m)) => { self.mesh = Some(m); } Ok(Err(report)) => { let sim = self.simulation.as_mut().unwrap(); sim.computing = false; sim.running = false; self.error = Some(report); self.status = format!("sim halted at t = {}", spice::format_spice_time(sim.t_now)); return Task::none(); } Err(payload) => { let sim = self.simulation.as_mut().unwrap(); sim.computing = false; sim.running = false; self.error = Some(ErrorReport { title: String::from("mesh stage panicked during sim tick"), body: panic_payload_text(&payload), }); return Task::none(); } } let stem = active_stem(); if let Err(report) = run_solve(&stem) { let sim = self.simulation.as_mut().unwrap(); sim.computing = false; sim.running = false; self.error = Some(report); return Task::none(); } let ans_path = stem.with_extension("ans"); match MagSolution::open(&ans_path) { Ok(sol) => { let elapsed = started.elapsed().as_millis(); let sim = self.simulation.as_mut().unwrap(); sim.current = Some(sol); sim.t_now = t_target; sim.computing = false; sim.last_solve_ms = elapsed; self.status = format!( "t = {} (dt = {}, last solve {} ms)", spice::format_spice_time(sim.t_now), spice::format_spice_time(dt), elapsed, ); } Err(e) => { let sim = self.simulation.as_mut().unwrap(); sim.computing = false; sim.running = false; self.error = Some(ErrorReport { title: String::from("read .ans during sim tick failed"), body: format!("{ans_path:?}\n\n{e}"), }); } } } Message::SimResetTime => { if let Some(sim) = self.simulation.as_mut() { sim.t_now = 0.0; self.doc = sim.base_doc.clone(); self.status = String::from("t reset to 0"); } } Message::SimClear => { if let Some(sim) = self.simulation.as_ref() { self.doc = sim.base_doc.clone(); } self.simulation = None; self.status = String::from("simulation cleared"); } Message::Canvas(CanvasMessage::DeleteSelected) => { let n = self.doc.delete_selected_nodes(); let s = self.doc.delete_selected_segments(); let a = self.doc.delete_selected_arcs(); let b = self.doc.delete_selected_block_labels(); let total = n + s + a + b; if total > 0 { self.mesh = None; } self.status = if total == 0 { String::from("nothing selected") } else { format!("deleted: {n} nodes, {s} segments, {a} arcs, {b} labels") }; } } Task::none() } fn view(&self) -> Element<'_, Message> { let stats = text(format!( "{} nodes {} segments {} arcs {} labels", self.doc.nodes.len(), self.doc.segments.len(), self.doc.arcs.len(), self.doc.block_labels.len(), )) .size(12); let file_group = row![ icon_button(ICON_FILE_NEW, "New problem", Some(Message::NewDoc)), icon_button(ICON_FILE_OPEN, "Open .fem", Some(Message::OpenFem)), icon_button(ICON_FILE_SAVE, "Save", Some(Message::SaveDoc)), icon_button(ICON_FILE_SAVE_AS, "Save As", Some(Message::SaveDocAs)), ].spacing(2); let tool_group = row![ tool_icon_button(ICON_TOOL_SELECT, "Select", Tool::Select, self.tool), tool_icon_button(ICON_TOOL_NODE, "Add Node", Tool::AddNode, self.tool), tool_icon_button(ICON_TOOL_SEGMENT, "Add Segment", Tool::AddSegment, self.tool), tool_icon_button(ICON_TOOL_LABEL, "Add Label", Tool::AddBlockLabel, self.tool), ].spacing(2); let edit_group = row![ icon_button(ICON_EDIT_COPY, "Copy selected (+10mm offset)", Some(Message::CopySelection)), icon_button(ICON_EDIT_DELETE, "Delete selected", Some(Message::Canvas(CanvasMessage::DeleteSelected))), ].spacing(2); let analysis_group = row![ text_button("Mesh", Message::RunMesh), text_button("Analyze", Message::RunAnalyze), text_button("Track from Selection", Message::SimAddTrackFromSelection), ].spacing(2); let plot_group = row![ render_mode_button("Density", RenderMode::Density, self.render_mode), render_mode_button("Contour", RenderMode::Contour, self.render_mode), ].spacing(2); let toolbar = row![ file_group, separator(), tool_group, separator(), edit_group, separator(), analysis_group, separator(), plot_group, separator(), text(&self.source_label).size(13), stats, ] .spacing(8) .align_y(Alignment::Center); let grid_tip = if self.show_grid { "Toggle grid (on)" } else { "Toggle grid (off)" }; let snap_tip = if self.snap_to_grid { "Toggle grid snap (on)" } else { "Toggle grid snap (off)" }; let zoom_window_tip = if self.zoom_window_active { "Zoom to window: drag a rectangle (click again to cancel)" } else { "Zoom to window" }; let view_strip = column![ strip_icon_button(ICON_ZOOM_IN, "Zoom in (scroll up)", Some(Message::ZoomIn)), strip_icon_button(ICON_ZOOM_OUT, "Zoom out (scroll down)", Some(Message::ZoomOut)), strip_icon_button(ICON_ZOOM_FIT, "Zoom to fit", Some(Message::ZoomFit)), strip_icon_button(ICON_ZOOM_WINDOW, zoom_window_tip, Some(Message::ZoomWindowToggle)), strip_icon_button(ICON_ZOOM_FIT, "Zoom to selection", Some(Message::ZoomSelection)), horizontal_separator(), strip_icon_button(ICON_GRID, grid_tip, Some(Message::ToggleGrid)), strip_icon_button(ICON_GRID_SNAP, snap_tip, Some(Message::ToggleSnap)), ] .spacing(4) .align_x(Alignment::Center); let active_solution = self.simulation.as_ref() .and_then(|s| s.current.as_ref()) .or(self.solution.as_ref()); let canvas = doc_canvas::view( &self.doc, self.tool, self.mesh.as_ref(), active_solution, self.render_mode, self.view_state, self.show_grid, self.zoom_window_active, ).map(Message::Canvas); let canvas_row = row![canvas, view_strip].spacing(6); let mut body = column![toolbar].spacing(8).padding(12); if let Some(report) = &self.error { body = body.push(error_panel(report)); } body = body.push(canvas_row); if let Some(sim) = &self.simulation { body = body.push(simulation_panel(sim)); } if !self.status.is_empty() { body = body.push(text(&self.status).size(12)); } container(body) .width(Length::Fill) .height(Length::Fill) .into() } /// emits SimTick at the simulation's configured wall-clock interval. fn subscription(&self) -> Subscription { match &self.simulation { Some(sim) if sim.running && !sim.computing && !sim.tracks.is_empty() => { time::every(sim.wall_interval).map(|_| Message::SimTick) } _ => Subscription::none(), } } } /// constructs an empty simulation seeded from the current doc and a default expression text. fn new_simulation(base_doc: FemmDoc, expression_text: String) -> Simulation { Simulation { base_doc, tracks: Vec::new(), t_now: 0.0, dt: SIM_DEFAULT_DT_S, wall_interval: Duration::from_secs_f64(SIM_DEFAULT_INTERVAL_S), running: false, computing: false, current: None, last_solve_ms: 0, selected_track: None, expression_text, dt_text: spice::format_spice_time(SIM_DEFAULT_DT_S), interval_text: spice::format_spice_time(SIM_DEFAULT_INTERVAL_S), subdivisions: SIM_DEFAULT_SUBDIVISIONS, subdivisions_text: SIM_DEFAULT_SUBDIVISIONS.to_string(), } } /// renders the simulation control panel: track list, expression editor, dt/interval inputs, run/pause/reset/clear controls. fn simulation_panel(sim: &Simulation) -> Element<'_, Message> { let header = row![ text(format!( "t = {} dt = {} interval = {} tracks = {}", spice::format_spice_time(sim.t_now), sim.dt_text, sim.interval_text, sim.tracks.len(), )).size(12).width(Length::Fill), button(text(if sim.running { "Pause" } else { "Run" }).size(12)) .on_press(Message::SimToggleRun).style(button::primary), button(text("Reset t").size(12)) .on_press(Message::SimResetTime).style(button::secondary), button(text("Clear").size(12)) .on_press(Message::SimClear).style(button::secondary), ].spacing(8).align_y(Alignment::Center); let dt_input = text_input("dt (SPICE)", &sim.dt_text) .on_input(Message::SimSetDtText) .on_submit(Message::SimSubmitDt) .size(12) .width(Length::Fixed(140.0)); let interval_input = text_input("wall interval", &sim.interval_text) .on_input(Message::SimSetIntervalText) .on_submit(Message::SimSubmitInterval) .size(12) .width(Length::Fixed(140.0)); let subdiv_input = text_input("subdivisions", &sim.subdivisions_text) .on_input(Message::SimSetSubdivisionsText) .on_submit(Message::SimSubmitSubdivisions) .size(12) .width(Length::Fixed(120.0)); let controls = row![ text("dt").size(12), dt_input, text("every").size(12), interval_input, text("subdiv").size(12), subdiv_input, ].spacing(8).align_y(Alignment::Center); let mut tracks_col = column![text("tracks").size(12)].spacing(4); for (i, t) in sim.tracks.iter().enumerate() { let is_selected = sim.selected_track == Some(i); let label_btn = button(text(format!( "{} {}: anchors {}→{}, {} members", if is_selected { "▸" } else { " " }, t.label, t.anchor_a, t.anchor_b, t.member_nodes.len(), )).size(11)) .on_press(Message::SimSelectTrack(i)) .style(if is_selected { button::primary } else { button::secondary }); let remove_btn = button(text("×").size(11)) .on_press(Message::SimRemoveTrack(i)) .style(button::secondary); tracks_col = tracks_col.push(row![label_btn, remove_btn].spacing(4)); } let expr_input = text_input("f(s, t) = ...", &sim.expression_text) .on_input(Message::SimEditExpressionText) .on_submit(Message::SimSubmitExpression) .size(12) .width(Length::Fill); let expr_row = row![ text("f(s,t)").size(12), expr_input, button(text("Apply").size(12)) .on_press(Message::SimSubmitExpression) .style(button::secondary), ].spacing(8).align_y(Alignment::Center); container(column![header, controls, expr_row, tracks_col].spacing(6).padding(8)) .style(|t: &Theme| { let p = t.extended_palette(); container::Style { background: Some(Background::Color(p.background.weak.color)), border: Border { color: p.primary.base.color, width: 1.0, radius: 4.0.into(), }, ..container::Style::default() } }) .width(Length::Fill) .into() } /// renders a red-bordered error panel with copy and dismiss controls above the canvas. fn error_panel(report: &ErrorReport) -> Element<'_, Message> { let header = row![ text(format!("error: {}", report.title)) .size(13) .color(Color::from_rgb(0.85, 0.15, 0.15)) .width(Length::Fill), button(text("Copy").size(12)).on_press(Message::CopyError), button(text("Dismiss").size(12)).on_press(Message::DismissError), ] .spacing(6) .align_y(iced::Alignment::Center); let body_text = text(report.body.clone()) .size(12) .font(iced::Font::MONOSPACE); let body = scrollable(body_text).height(Length::Fixed(140.0)); container(column![header, body].spacing(6).padding(10)) .style(|_t: &Theme| container::Style { background: Some(Background::Color(Color::from_rgb(1.0, 0.96, 0.94))), border: Border { color: Color::from_rgb(0.85, 0.15, 0.15), width: 1.5, radius: 4.0.into(), }, ..container::Style::default() }) .width(Length::Fill) .into() } /// stem path used by all mesh and solve operations for the active doc. fn active_stem() -> std::path::PathBuf { let dir = std::env::temp_dir().join("femm42-mesh"); std::fs::create_dir_all(&dir).ok(); dir.join("active") } /// saves the doc to a temp .fem and matching .poly, invokes Triangle, and reads back the mesh. fn run_mesh(doc: &FemmDoc) -> Result { let stem = active_stem(); let fem_path = stem.with_extension("fem"); let poly_path = stem.with_extension("poly"); let pbc_path = stem.with_extension("pbc"); if doc.nodes.is_empty() { return Err(ErrorReport { title: String::from("empty geometry"), body: String::from("the doc has zero nodes - nothing to mesh"), }); } if doc.block_labels.is_empty() { return Err(ErrorReport { title: String::from("no block labels"), body: String::from("the doc has no block labels - every closed region needs a material assignment before meshing"), }); } doc.save(&fem_path).map_err(|e| ErrorReport { title: String::from("write .fem failed"), body: format!("{fem_path:?}\n\n{e}"), })?; doc.save_poly(&poly_path).map_err(|e| ErrorReport { title: String::from("write .poly failed"), body: format!("{poly_path:?}\n\n{e}"), })?; std::fs::write(&pbc_path, "0\n0\n").map_err(|e| ErrorReport { title: String::from("write .pbc failed"), body: format!("{pbc_path:?}\n\n{e}"), })?; let triangle = locate_triangle().ok_or_else(|| ErrorReport { title: String::from("triangle binary missing"), body: String::from("expected triangle next to femm.app/Contents/MacOS/triangle, or under build/triangle/triangle, or at $FEMM_TRIANGLE - run scripts/macos/build_triangle.sh"), })?; let stem_str = stem.to_str().ok_or_else(|| ErrorReport { title: String::from("non-utf8 mesh path"), body: format!("{stem:?}"), })?; let min_angle = if doc.min_angle > 0.0 { doc.min_angle } else { MIN_ANGLE_DEG }; let output = std::process::Command::new(&triangle) .args([ "-p", "-P", "-e", "-A", "-a", "-z", "-Q", "-I", &format!("-q{min_angle}"), ]) .arg(stem_str) .output() .map_err(|e| ErrorReport { title: String::from("exec triangle failed"), body: format!("{triangle:?}\n\n{e}"), })?; if !output.status.success() { return Err(ErrorReport { title: format!("triangle exited with {}", output.status), body: format!( "triangle: {triangle:?}\nstem: {stem_str}\n\n--- stdout ---\n{}\n--- stderr ---\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr), ), }); } Mesh::load(&stem).map_err(|e| ErrorReport { title: String::from("read mesh failed"), body: format!("stem: {stem_str}\n\n{e}"), }) } /// runs the magnetostatic solver FFI pipeline against an on-disk .fem + .node + .ele rooted at the given stem. fn run_solve(stem: &Path) -> Result<(), ErrorReport> { let stem_str = stem.to_str().ok_or_else(|| ErrorReport { title: String::from("non-utf8 solve path"), body: format!("{stem:?}"), })?; let helper = locate_mag_solver().ok_or_else(|| ErrorReport { title: String::from("femm-mag-solve binary missing"), body: String::from("expected femm-mag-solve next to femm.app/Contents/MacOS/, or under target/release/, or at $FEMM_MAG_SOLVE - run `cargo xtask install`"), })?; let output = std::process::Command::new(&helper) .arg(stem_str) .output() .map_err(|e| ErrorReport { title: String::from("spawn femm-mag-solve failed"), body: format!("{helper:?}\n\n{e}"), })?; if output.status.success() { return Ok(()); } let exit_label = match output.status.code() { Some(c) => format!("exit code {c}"), None => match unix_signal(&output.status) { Some(sig) => format!("killed by signal {sig}"), None => format!("{}", output.status), }, }; let stage = solve_stage_for_exit(output.status.code()); let title = format!("magnetostatic solve {exit_label} ({stage})"); Err(ErrorReport { title, body: format!( "helper: {helper:?}\nstem: {stem_str}\n\n--- stdout ---\n{}\n--- stderr ---\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr), ), }) } /// maps a femm-mag-solve exit code onto a pipeline-stage label. fn solve_stage_for_exit(code: Option) -> &'static str { match code { Some(1) => "C++ engine called exit(1) - read the captured stderr for the actual cause", Some(2) => "argv usage", Some(3) => "stem path conversion", Some(10) => "doc_new", Some(11) => "load_fem", Some(12) => "load_mesh", Some(13) => "renumber", Some(14) => "solve", Some(_) => "unknown exit code", None => "killed by signal - likely C++ assertion or segfault inside the solver", } } /// extracts the unix signal number from a child exit status, returning None on non-unix or normal exits. #[cfg(unix)] fn unix_signal(status: &std::process::ExitStatus) -> Option { use std::os::unix::process::ExitStatusExt; status.signal() } #[cfg(not(unix))] fn unix_signal(_status: &std::process::ExitStatus) -> Option { None } /// resolves the femm-mag-solve helper path from the bundled .app, the dev target dir, or $FEMM_MAG_SOLVE. fn locate_mag_solver() -> Option { if let Ok(exe) = std::env::current_exe() { if let Some(parent) = exe.parent() { let bundled = parent.join("femm-mag-solve"); if bundled.is_file() { return Some(bundled); } let dev = parent.join("femm-mag-solve"); if let Ok(canon) = dev.canonicalize() { if canon.is_file() { return Some(canon); } } } } if let Ok(env_path) = std::env::var("FEMM_MAG_SOLVE") { let p = std::path::PathBuf::from(env_path); if p.is_file() { return Some(p); } } None } /// converts a panic payload into a printable string. fn panic_payload_text(payload: &Box) -> String { if let Some(s) = payload.downcast_ref::<&'static str>() { return (*s).to_string(); } if let Some(s) = payload.downcast_ref::() { return s.clone(); } String::from("panic payload was not a string") } /// resolves the Triangle binary path from the bundled .app, the dev build dir, or $FEMM_TRIANGLE. fn locate_triangle() -> Option { if let Ok(exe) = std::env::current_exe() { if let Some(parent) = exe.parent() { let bundled = parent.join("triangle"); if bundled.is_file() { return Some(bundled); } let dev = parent.join("../../build/triangle/triangle"); if let Ok(canon) = dev.canonicalize() { if canon.is_file() { return Some(canon); } } } } if let Ok(env_path) = std::env::var("FEMM_TRIANGLE") { let p = std::path::PathBuf::from(env_path); if p.is_file() { return Some(p); } } None } /// duplicates every selected entity offset by +10mm in both axes and transfers selection to the copies. fn duplicate_selection(doc: &mut FemmDoc) -> usize { let (dx, dy) = offset_for_unit(doc.length_units); let nodes_snapshot: Vec<(f64, f64)> = doc.nodes.iter().map(|n| (n.x, n.y)).collect(); let node_sel: Vec = doc.nodes.iter().map(|n| n.selected).collect(); let seg_snapshot: Vec = doc.segments.iter().cloned().collect(); let arc_snapshot: Vec = doc.arcs.iter().cloned().collect(); let label_snapshot: Vec = doc.block_labels.iter().cloned().collect(); for n in doc.nodes.iter_mut() { n.selected = false; } for s in doc.segments.iter_mut() { s.selected = false; } for a in doc.arcs.iter_mut() { a.selected = false; } for b in doc.block_labels.iter_mut() { b.selected = false; } let mut node_map: std::collections::HashMap = std::collections::HashMap::new(); let mut added = 0usize; for (i, src) in nodes_snapshot.iter().enumerate() { if node_sel[i] { let new_idx = doc.nodes.len() as i32; doc.nodes.push(Node { x: src.0 + dx, y: src.1 + dy, boundary_marker: String::new(), in_group: 0, selected: true, }); node_map.insert(i as i32, new_idx); added += 1; } } for s in &seg_snapshot { if !s.selected { continue; } let n0 = remap_or_clone_node(doc, s.n0, &nodes_snapshot, &mut node_map, dx, dy); let n1 = remap_or_clone_node(doc, s.n1, &nodes_snapshot, &mut node_map, dx, dy); let (Some(n0), Some(n1)) = (n0, n1) else { continue }; doc.segments.push(Segment { n0, n1, max_side_length: s.max_side_length, boundary_marker: s.boundary_marker.clone(), hidden: s.hidden, in_group: s.in_group, selected: true, }); added += 1; } for a in &arc_snapshot { if !a.selected { continue; } let n0 = remap_or_clone_node(doc, a.n0, &nodes_snapshot, &mut node_map, dx, dy); let n1 = remap_or_clone_node(doc, a.n1, &nodes_snapshot, &mut node_map, dx, dy); let (Some(n0), Some(n1)) = (n0, n1) else { continue }; doc.arcs.push(ArcSegment { n0, n1, arc_length: a.arc_length, max_side_length: a.max_side_length, boundary_marker: a.boundary_marker.clone(), hidden: a.hidden, in_group: a.in_group, normal_direction: a.normal_direction, selected: true, }); added += 1; } for b in &label_snapshot { if !b.selected { continue; } let mut clone = b.clone(); clone.x += dx; clone.y += dy; clone.selected = true; doc.block_labels.push(clone); added += 1; } added } /// returns the duplicate-node index for an original index, creating a new offset copy on first request. fn remap_or_clone_node( doc: &mut FemmDoc, original: i32, nodes_snapshot: &[(f64, f64)], node_map: &mut std::collections::HashMap, dx: f64, dy: f64, ) -> Option { if let Some(&m) = node_map.get(&original) { return Some(m); } let src = nodes_snapshot.get(original as usize)?; let new_idx = doc.nodes.len() as i32; doc.nodes.push(Node { x: src.0 + dx, y: src.1 + dy, boundary_marker: String::new(), in_group: 0, selected: true, }); node_map.insert(original, new_idx); Some(new_idx) } /// returns the world-space (dx, dy) corresponding to 10mm under the given length unit. fn offset_for_unit(unit: femm_doc_mag::LengthUnit) -> (f64, f64) { use femm_doc_mag::LengthUnit::*; let v = match unit { Inches => 10.0 / 25.4, Millimeters => 10.0, Centimeters => 1.0, Meters => 0.01, Mils => 10.0 / 0.0254, Microns => 10_000.0, }; (v, v) } /// computes the (xmin, xmax, ymin, ymax) bounding box of every selected entity, or None when nothing is selected. fn selection_bbox(doc: &FemmDoc) -> Option<(f64, f64, f64, f64)> { let mut xmin = f64::INFINITY; let mut xmax = f64::NEG_INFINITY; let mut ymin = f64::INFINITY; let mut ymax = f64::NEG_INFINITY; let mut hit = false; let mut grow = |x: f64, y: f64| { if x < xmin { xmin = x; } if x > xmax { xmax = x; } if y < ymin { ymin = y; } if y > ymax { ymax = y; } }; for n in &doc.nodes { if n.selected { grow(n.x, n.y); hit = true; } } for s in &doc.segments { if s.selected { if let (Some(a), Some(b)) = (doc.nodes.get(s.n0 as usize), doc.nodes.get(s.n1 as usize)) { grow(a.x, a.y); grow(b.x, b.y); hit = true; } } } for a in &doc.arcs { if a.selected { if let (Some(p0), Some(p1)) = (doc.nodes.get(a.n0 as usize), doc.nodes.get(a.n1 as usize)) { grow(p0.x, p0.y); grow(p1.x, p1.y); hit = true; } } } for l in &doc.block_labels { if l.selected { grow(l.x, l.y); hit = true; } } if hit { Some((xmin, xmax, ymin, ymax)) } else { None } } #[derive(Clone, Copy)] enum Kind { Node, Segment, Arc, Label } /// clears the selected flag on every entity in the doc. fn clear_selection(doc: &mut FemmDoc) { for n in &mut doc.nodes { n.selected = false; } for s in &mut doc.segments { s.selected = false; } for a in &mut doc.arcs { a.selected = false; } for b in &mut doc.block_labels { b.selected = false; } } /// mutates one entity's selected flag according to the pick op. fn apply_op(flag: &mut bool, op: PickOp) { match op { PickOp::Replace | PickOp::Add => *flag = true, PickOp::Toggle => *flag ^= true, } } /// applies the pick op to the closest entity within pick_radius_world of (x, y), honouring the restrict mode. fn apply_pick_at(doc: &mut FemmDoc, x: f64, y: f64, pick_radius_world: f64, op: PickOp, restrict: PickRestrict) -> String { use femm_doc_mag::geom_math::{shortest_distance_from_arc, shortest_distance_from_segment}; let mut best: Option<(Kind, usize, f64)> = None; let mut consider = |kind: Kind, idx: usize, d: f64| { match best { None => best = Some((kind, idx, d)), Some((_, _, bd)) if d < bd => best = Some((kind, idx, d)), _ => {} } }; for (i, n) in doc.nodes.iter().enumerate() { consider(Kind::Node, i, (n.x - x).hypot(n.y - y)); } if matches!(restrict, PickRestrict::Any) { for (i, s) in doc.segments.iter().enumerate() { if let (Some(p0), Some(p1)) = (doc.nodes.get(s.n0 as usize), doc.nodes.get(s.n1 as usize)) { consider(Kind::Segment, i, shortest_distance_from_segment((x, y), (p0.x, p0.y), (p1.x, p1.y))); } } for (i, a) in doc.arcs.iter().enumerate() { if let (Some(p0), Some(p1)) = (doc.nodes.get(a.n0 as usize), doc.nodes.get(a.n1 as usize)) { consider(Kind::Arc, i, shortest_distance_from_arc((x, y), (p0.x, p0.y), (p1.x, p1.y), a.arc_length)); } } for (i, b) in doc.block_labels.iter().enumerate() { consider(Kind::Label, i, (b.x - x).hypot(b.y - y)); } } if matches!(op, PickOp::Replace) { clear_selection(doc); } let hit = best.and_then(|(k, i, d)| if d <= pick_radius_world { Some((k, i)) } else { None }); if let Some((kind, idx)) = hit { let (label, flag) = match kind { Kind::Node => ("node", &mut doc.nodes[idx].selected), Kind::Segment => ("segment", &mut doc.segments[idx].selected), Kind::Arc => ("arc", &mut doc.arcs[idx].selected), Kind::Label => ("label", &mut doc.block_labels[idx].selected), }; apply_op(flag, op); let verb = match op { PickOp::Replace => "selected", PickOp::Add => "added", PickOp::Toggle => "toggled" }; format!("{verb} {label} {idx}") } else if matches!(op, PickOp::Replace) { String::from("selection cleared") } else { String::from("nothing at click") } } /// applies the pick op to every entity sitting inside the rectangle spanned by p0 and p1. fn apply_pick_rect(doc: &mut FemmDoc, p0: (f64, f64), p1: (f64, f64), op: PickOp, restrict: PickRestrict) -> String { let xmin = p0.0.min(p1.0); let xmax = p0.0.max(p1.0); let ymin = p0.1.min(p1.1); let ymax = p0.1.max(p1.1); let inside = |x: f64, y: f64| x >= xmin && x <= xmax && y >= ymin && y <= ymax; if matches!(op, PickOp::Replace) { clear_selection(doc); } let mut n_nodes = 0usize; let mut n_segs = 0usize; let mut n_arcs = 0usize; let mut n_labs = 0usize; for n in doc.nodes.iter_mut() { if inside(n.x, n.y) { apply_op(&mut n.selected, op); n_nodes += 1; } } if matches!(restrict, PickRestrict::Any) { let node_inside: Vec = doc.nodes.iter().map(|n| inside(n.x, n.y)).collect(); for s in doc.segments.iter_mut() { let i0 = s.n0 as usize; let i1 = s.n1 as usize; let ok = node_inside.get(i0).copied().unwrap_or(false) && node_inside.get(i1).copied().unwrap_or(false); if ok { apply_op(&mut s.selected, op); n_segs += 1; } } for a in doc.arcs.iter_mut() { let i0 = a.n0 as usize; let i1 = a.n1 as usize; let ok = node_inside.get(i0).copied().unwrap_or(false) && node_inside.get(i1).copied().unwrap_or(false); if ok { apply_op(&mut a.selected, op); n_arcs += 1; } } for b in doc.block_labels.iter_mut() { if inside(b.x, b.y) { apply_op(&mut b.selected, op); n_labs += 1; } } } let verb = match op { PickOp::Replace => "selected", PickOp::Add => "added", PickOp::Toggle => "toggled" }; format!("{verb}: {n_nodes} nodes, {n_segs} segs, {n_arcs} arcs, {n_labs} labels") } /// renders an SVG icon button with its tooltip placed to the left, suitable for a vertical right-edge strip. fn strip_icon_button<'a>(svg_bytes: &'static [u8], tip: &'a str, msg: Option) -> Element<'a, Message> { let handle = svg::Handle::from_memory(svg_bytes); let glyph = svg(handle) .width(Length::Fixed(ICON_PX)) .height(Length::Fixed(ICON_PX)); let mut btn = button(glyph).padding(4).style(button::secondary); if let Some(m) = msg { btn = btn.on_press(m); } tooltip(btn, text(tip).size(11), tooltip::Position::Left).into() } /// renders an SVG icon inside a square button with a hover tooltip. fn icon_button<'a>(svg_bytes: &'static [u8], tip: &'a str, msg: Option) -> Element<'a, Message> { let handle = svg::Handle::from_memory(svg_bytes); let glyph = svg(handle) .width(Length::Fixed(ICON_PX)) .height(Length::Fixed(ICON_PX)); let mut btn = button(glyph).padding(4).style(button::secondary); if let Some(m) = msg { btn = btn.on_press(m); } tooltip(btn, text(tip).size(11), tooltip::Position::Bottom).into() } /// renders an SVG icon button with active-tool highlighting. fn tool_icon_button<'a>(svg_bytes: &'static [u8], tip: &'a str, this_tool: Tool, active: Tool) -> Element<'a, Message> { let handle = svg::Handle::from_memory(svg_bytes); let glyph = svg(handle) .width(Length::Fixed(ICON_PX)) .height(Length::Fixed(ICON_PX)); let style = if this_tool == active { button::primary } else { button::secondary }; let btn = button(glyph) .padding(4) .style(style) .on_press(Message::SelectTool(this_tool)); tooltip(btn, text(tip).size(11), tooltip::Position::Bottom).into() } /// renders a plain text button mapped to a single message. fn text_button(label: &str, msg: Message) -> Element<'_, Message> { button(text(label).size(13)).on_press(msg).into() } /// draws a thin vertical rule between toolbar groups. fn separator<'a>() -> Element<'a, Message> { container(iced::widget::Space::new().width(Length::Fixed(1.0)).height(Length::Fixed(28.0))) .style(|_t: &Theme| container::Style { background: Some(Background::Color(Color::from_rgba(1.0, 1.0, 1.0, 0.25))), ..container::Style::default() }) .into() } /// draws a thin horizontal rule between vertical-strip sections. fn horizontal_separator<'a>() -> Element<'a, Message> { container(iced::widget::Space::new().width(Length::Fixed(28.0)).height(Length::Fixed(1.0))) .style(|_t: &Theme| container::Style { background: Some(Background::Color(Color::from_rgba(1.0, 1.0, 1.0, 0.25))), ..container::Style::default() }) .into() } /// renders a render-mode toggle button with active-mode highlighting. fn render_mode_button(label: &str, this_mode: RenderMode, active: RenderMode) -> Element<'_, Message> { let btn = button(text(label).size(13)).on_press(Message::SetRenderMode(this_mode)); if this_mode == active { btn.style(button::primary).into() } else { btn.style(button::secondary).into() } } fn main() -> iced::Result { iced::application(App::new, App::update, App::view) .title(App::title) .theme(|_app: &App| Theme::Dark) .subscription(App::subscription) .run() }