#![cfg(not(target_arch = "wasm32"))]
use super::kicad_bulk::{
AppStoreWriter, BulkRun, Ledger, PartWriter, Progress, StoreWriter, Sweep,
};
use super::kicad_import::{
read_symbol_library, DiskFiles, Folder, FootprintChoice, Imported, KicadFiles, KicadLibrary,
KicadPart,
};
use super::kicad_remote::{
default_cache_root, http_fetcher, parse_tree, read_part, symbol_libraries, Fetcher, PartReader,
PartSource, RemoteFiles, Repo, DEFAULT_TAG,
};
use crate::store::{default_model_store, ModelStore};
use brep_ecad_core::Symbol;
use eframe::egui;
use std::sync::Arc;
const PAGE: usize = 100;
const SYMBOL_VARS: &[&str] = &[
"KICAD9_SYMBOL_DIR",
"KICAD8_SYMBOL_DIR",
"KICAD7_SYMBOL_DIR",
"KICAD6_SYMBOL_DIR",
];
pub fn symbols_folder(env: &dyn Fn(&str) -> Option<String>, footprints: &str, files: &dyn KicadFiles) -> String {
for var in SYMBOL_VARS {
if let Some(path) = env(var).filter(|path| files.is_dir(path)) {
return path;
}
}
std::path::Path::new(footprints)
.parent()
.map(|dir| dir.join("symbols").to_string_lossy().into_owned())
.unwrap_or_else(|| "symbols".into())
}
pub fn local_symbol_libraries(dir: &str, files: &dyn KicadFiles) -> Vec<String> {
symbol_libraries(&files.list(dir))
}
pub fn document_name(library_id: &str) -> String {
let bare = library_id.rsplit(':').next().unwrap_or(library_id);
let cleaned: String = bare
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' })
.collect();
let trimmed = cleaned.trim_matches(['_', '.']).to_owned();
if trimmed.is_empty() {
"kicad-part".into()
} else {
trimmed
}
}
struct LibraryReader {
answer: std::sync::mpsc::Receiver<Result<(Vec<Symbol>, Vec<String>), String>>,
library: String,
}
pub struct LocalSource {
library: KicadLibrary,
}
impl LocalSource {
pub fn new(library: KicadLibrary) -> Self {
Self { library }
}
}
impl KicadFiles for LocalSource {
fn read(&self, path: &str) -> Option<Vec<u8>> {
DiskFiles.read(path)
}
fn is_file(&self, path: &str) -> bool {
DiskFiles.is_file(path)
}
fn is_dir(&self, path: &str) -> bool {
DiskFiles.is_dir(path)
}
fn list(&self, dir: &str) -> Vec<String> {
DiskFiles.list(dir)
}
}
impl PartSource for LocalSource {
fn library(&self) -> KicadLibrary {
self.library.clone()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Source {
Download,
Install,
}
impl Source {
fn label(self) -> &'static str {
match self {
Source::Download => "Download KiCad's library",
Source::Install => "Use my KiCad install",
}
}
}
pub enum Stage {
Choose,
Libraries,
Opening { library: String },
Symbols { library: String, symbols: Vec<Symbol>, warnings: Vec<String> },
Reading { symbol: String },
Review { part: Box<KicadPart>, warnings: Vec<String>, downloaded: Vec<String> },
Bulk,
}
pub struct KicadLibraryWindow {
pub stage: Stage,
source: Source,
symbols_dir: String,
footprints_dir: String,
models_dir: String,
cache_dir: String,
tag: String,
libraries: Vec<String>,
index: Option<std::sync::mpsc::Receiver<Result<String, String>>>,
index_page: u32,
reader: Option<PartReader>,
library_reader: Option<LibraryReader>,
search: String,
pending_warnings: Vec<String>,
document: String,
pub status: String,
store: Box<dyn ModelStore>,
pub saved: Vec<String>,
pub bulk: Option<BulkRun>,
force_reimport: bool,
ledger_line: String,
new_fetcher: Arc<dyn Fn() -> Fetcher + Send + Sync>,
new_writer: Arc<dyn Fn() -> Box<dyn PartWriter + Send> + Send + Sync>,
}
impl KicadLibraryWindow {
pub fn new() -> Self {
Self::with_store(default_model_store())
}
pub fn with_store(store: Box<dyn ModelStore>) -> Self {
let env = |var: &str| std::env::var(var).ok().filter(|value| !value.is_empty());
let library = KicadLibrary::load(store.as_ref(), &env, &DiskFiles);
let symbols = symbols_folder(&env, &library.footprints.path, &DiskFiles);
Self {
stage: Stage::Choose,
source: Source::Download,
symbols_dir: symbols,
footprints_dir: library.footprints.path.clone(),
models_dir: library.models.path.clone(),
cache_dir: default_cache_root().to_string_lossy().into_owned(),
tag: DEFAULT_TAG.into(),
libraries: Vec::new(),
index: None,
index_page: 0,
reader: None,
library_reader: None,
search: String::new(),
pending_warnings: Vec::new(),
document: String::new(),
status: String::new(),
store,
saved: Vec::new(),
bulk: None,
force_reimport: false,
ledger_line: String::new(),
new_fetcher: Arc::new(http_fetcher),
new_writer: Arc::new(|| Box::new(AppStoreWriter)),
}
}
pub fn sweep_through(
&mut self,
fetch: Arc<dyn Fn() -> Fetcher + Send + Sync>,
write: Arc<dyn Fn() -> Box<dyn PartWriter + Send> + Send + Sync>,
) {
self.new_fetcher = fetch;
self.new_writer = write;
}
fn install_library(&self) -> KicadLibrary {
let folder = |path: &str| Folder { path: path.to_owned(), from: format!("your KiCad install ({path})") };
KicadLibrary { footprints: folder(&self.footprints_dir), models: folder(&self.models_dir) }
}
fn remote(&self) -> RemoteFiles {
RemoteFiles::new(&self.cache_dir, &self.tag, http_fetcher())
}
pub fn choose(&mut self, source: Source, ctx: Option<&egui::Context>) {
self.source = source;
self.stage = Stage::Libraries;
self.libraries.clear();
self.search.clear();
self.index = None;
self.reader = None;
self.library_reader = None;
self.refresh_ledger_line();
match source {
Source::Install => {
self.libraries = local_symbol_libraries(&self.symbols_dir, &DiskFiles);
self.status = if self.libraries.is_empty() {
format!("no .kicad_sym library in {}", self.symbols_dir)
} else {
format!("{} symbol libraries in {}", self.libraries.len(), self.symbols_dir)
};
}
Source::Download => {
self.status = format!("listing KiCad {}'s symbol libraries\u{2026}", self.tag);
self.index_page = 0;
self.next_index_page(ctx);
}
}
}
fn next_index_page(&mut self, ctx: Option<&egui::Context>) {
self.index_page += 1;
let url = super::kicad_remote::tree_url(Repo::Symbols, &self.tag, "", self.index_page);
self.index = ctx.map(|ctx| crate::http::fetch_text(ctx, url));
}
pub fn take_index(&mut self, body: Result<String, String>, ctx: Option<&egui::Context>) {
self.index = None;
match body.and_then(|body| parse_tree(&body)) {
Ok(names) => {
let more = names.len() >= PAGE;
self.libraries.extend(symbol_libraries(&names));
self.libraries.sort();
if more && self.index_page < 64 {
self.status = format!(
"listing KiCad {}'s symbol libraries\u{2026} {} so far",
self.tag,
self.libraries.len()
);
self.next_index_page(ctx);
} else {
self.status = format!(
"{} symbol libraries in KiCad {} (type to search)",
self.libraries.len(),
self.tag
);
}
}
Err(error) => self.status = format!("the library list was not read: {error}"),
}
}
pub fn open_library(&mut self, library: &str) {
let file = format!("{library}.kicad_sym");
let bytes: Box<dyn FnOnce() -> Result<Vec<u8>, String> + Send> = match self.source {
Source::Install => {
let path = std::path::Path::new(&self.symbols_dir).join(&file);
Box::new(move || std::fs::read(&path).map_err(|error| format!("{}: {error}", path.display())))
}
Source::Download => {
let (root, tag, wanted) = (self.cache_dir.clone(), self.tag.clone(), file.clone());
Box::new(move || {
let remote = RemoteFiles::new(root, tag, http_fetcher());
remote.prepare().and_then(|()| remote.fetch_file(Repo::Symbols, &wanted))
})
}
};
let (send, answer) = std::sync::mpsc::channel();
let named = file.clone();
std::thread::spawn(move || {
let _ = send.send(bytes().and_then(|bytes| read_symbol_library(&named, &bytes)));
});
self.library_reader = Some(LibraryReader { answer, library: library.to_owned() });
self.status = format!("reading {library}\u{2026}");
self.stage = Stage::Opening { library: library.to_owned() };
}
pub fn read_symbol(&mut self, symbol: Symbol) {
let name = symbol.library_id.clone();
self.pending_warnings = match &self.stage {
Stage::Symbols { warnings, .. } => warnings.clone(),
_ => Vec::new(),
};
self.document = document_name(&name);
self.status = format!("reading {name}\u{2026}");
self.reader = Some(match self.source {
Source::Install => read_part(symbol, FootprintChoice::Named, LocalSource::new(self.install_library())),
Source::Download => {
let remote = self.remote();
let _ = remote.prepare();
read_part(symbol, FootprintChoice::Named, remote)
}
});
self.stage = Stage::Reading { symbol: name };
}
pub fn poll(&mut self, ctx: Option<&egui::Context>) {
if let Some(run) = &mut self.bulk {
let was_running = !run.progress.finished;
let moved = run.poll();
let stopped = moved && was_running && run.progress.finished;
if !run.progress.finished {
if let Some(ctx) = ctx {
ctx.request_repaint_after(std::time::Duration::from_millis(250));
}
}
if stopped {
let line = run.progress.line();
self.status = format!("import {line}");
self.refresh_ledger_line();
}
}
if let Some(index) = &self.index {
if let Ok(body) = index.try_recv() {
self.take_index(body, ctx);
}
}
if let Some(reader) = &self.library_reader {
match reader.answer.try_recv() {
Ok(Ok((symbols, warnings))) => {
let library = reader.library.clone();
self.library_reader = None;
self.status = format!("{library} holds {} symbols", symbols.len());
self.search.clear();
self.stage = Stage::Symbols { library, symbols, warnings };
}
Ok(Err(error)) => {
self.status = format!("{} was not read: {error}", reader.library);
self.library_reader = None;
self.stage = Stage::Libraries;
}
Err(std::sync::mpsc::TryRecvError::Empty) => {
if let Some(ctx) = ctx {
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
self.status = format!("{} was not read: the reader stopped", reader.library);
self.library_reader = None;
self.stage = Stage::Libraries;
}
}
}
let Some(reader) = &self.reader else {
return;
};
match reader.take() {
Some((part, downloaded)) => {
self.reader = None;
self.status = if downloaded.is_empty() {
"read".into()
} else {
format!("downloaded {} files", downloaded.len())
};
let warnings = std::mem::take(&mut self.pending_warnings);
self.stage = Stage::Review { part: Box::new(part), warnings, downloaded };
}
None => {
if let Some(ctx) = ctx {
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
}
}
}
pub fn save(&mut self) -> Result<Imported, String> {
let Stage::Review { part, .. } = &self.stage else {
return Err("nothing has been read yet".into());
};
let name = self.document.trim();
if name.is_empty() {
return Err("the part needs a name".into());
}
let name = name.to_owned();
let imported = super::kicad_bulk::write_part(&StoreWriter(self.store.as_ref()), &name, part)?;
self.saved.push(name.clone());
self.status = format!("saved {name}: {}", imported.summary);
self.stage = Stage::Libraries;
Ok(imported)
}
pub fn stage_name(&self) -> &'static str {
match self.stage {
Stage::Choose => "choose",
Stage::Libraries => "libraries",
Stage::Opening { .. } => "opening",
Stage::Symbols { .. } => "symbols",
Stage::Reading { .. } => "reading",
Stage::Review { .. } => "review",
Stage::Bulk => "bulk",
}
}
pub fn refresh_ledger_line(&mut self) {
if self.source != Source::Download {
self.ledger_line.clear();
return;
}
let ledger = Ledger::read(std::path::Path::new(&self.cache_dir), &self.tag);
let (done, retry) = ledger.counts();
if done == 0 && retry == 0 {
self.ledger_line = format!("nothing imported into {} yet", self.cache_dir);
return;
}
let grades: Vec<String> = ledger
.grades()
.into_iter()
.filter(|(_, count)| *count > 0)
.map(|(grade, count)| format!("{count} {}", grade.as_str()))
.collect();
let torn = if ledger.dropped() > 0 {
format!(", {} unreadable line(s) ignored", ledger.dropped())
} else {
String::new()
};
self.ledger_line = format!(
"KiCad {}: {done} parts already imported ({}), {retry} to retry{torn} \u{2014} {}",
self.tag,
grades.join(", "),
ledger.path().display()
);
}
pub fn start_bulk(&mut self, libraries: Vec<String>) {
if libraries.is_empty() {
self.status = "there is no library to import".into();
return;
}
if self.bulk.as_ref().is_some_and(|run| !run.finished()) {
self.status = "a sweep is already running".into();
return;
}
let plan = Sweep {
cache_root: std::path::PathBuf::from(&self.cache_dir),
tag: self.tag.clone(),
libraries,
force: self.force_reimport,
};
let wanted = plan.libraries.len();
match super::kicad_bulk::start(plan, (self.new_fetcher)(), (self.new_writer)()) {
Ok(run) => {
self.status = format!(
"importing {wanted} librar{} into {}; {} parts already done{}",
if wanted == 1 { "y" } else { "ies" },
self.cache_dir,
run.resumed,
if self.force_reimport {
", and every one of them again"
} else {
", which are skipped"
}
);
self.bulk = Some(run);
self.stage = Stage::Bulk;
}
Err(error) => self.status = format!("the import did not start: {error}"),
}
}
pub fn bulk_progress(&self) -> Option<&Progress> {
self.bulk.as_ref().map(|run| &run.progress)
}
fn matching(&self) -> Vec<String> {
let needle = self.search.to_lowercase();
self.libraries
.iter()
.filter(|name| name.to_lowercase().contains(&needle))
.cloned()
.collect()
}
}
impl eframe::App for KicadLibraryWindow {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let ctx = ui.ctx().clone();
self.poll(Some(&ctx));
egui::Panel::bottom("kicad-library-status").show(ui, |ui| {
ui.horizontal_wrapped(|ui| ui.weak(&self.status));
if !self.saved.is_empty() {
ui.weak(format!("saved this session: {}", self.saved.join(", ")));
}
});
ui.heading("Import from KiCad");
ui.add_space(4.0);
self.show_source(ui);
ui.separator();
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
self.show_stage(ui);
});
}
}
impl KicadLibraryWindow {
fn show_source(&mut self, ui: &mut egui::Ui) {
let mut chosen = None;
ui.horizontal(|ui| {
for source in [Source::Download, Source::Install] {
let picked = self.source == source && !matches!(self.stage, Stage::Choose);
if ui.selectable_label(picked, source.label()).clicked() {
chosen = Some(source);
}
}
});
match self.source {
Source::Download => {
egui::Grid::new("kicad-library-remote").num_columns(2).show(ui, |ui| {
ui.label("KiCad release");
ui.text_edit_singleline(&mut self.tag);
ui.end_row();
ui.label("Downloaded to");
ui.text_edit_singleline(&mut self.cache_dir);
ui.end_row();
});
ui.weak(
"Symbols, footprints and 3D models come from KiCad's own repositories, \
one file per part, into a folder laid out like a KiCad install.",
);
}
Source::Install => {
egui::Grid::new("kicad-library-local").num_columns(2).show(ui, |ui| {
for (label, field) in [
("Symbols", &mut self.symbols_dir),
("Footprints", &mut self.footprints_dir),
("3D models", &mut self.models_dir),
] {
ui.label(label);
ui.text_edit_singleline(field);
ui.end_row();
}
});
}
}
if let Some(source) = chosen {
let ctx = ui.ctx().clone();
self.choose(source, Some(&ctx));
}
}
fn show_stage(&mut self, ui: &mut egui::Ui) {
let mut open = None;
let mut read = None;
let mut sweep = None;
if matches!(self.stage, Stage::Libraries) && self.source == Source::Download {
sweep = self.show_bulk_controls(ui);
ui.separator();
}
if matches!(self.stage, Stage::Libraries | Stage::Symbols { .. }) {
ui.text_edit_singleline(&mut self.search);
}
match &self.stage {
Stage::Choose => {
ui.weak("Choose a source above.");
}
Stage::Libraries => {
let bulk = self.source == Source::Download;
for name in self.matching() {
ui.horizontal(|ui| {
if ui.selectable_label(false, &name).clicked() {
open = Some(name.clone());
}
if bulk && ui.small_button("import all of it").clicked() {
sweep = Some(vec![name.clone()]);
}
});
}
if self.libraries.is_empty() {
ui.weak("No symbol library here yet.");
}
}
Stage::Symbols { library, symbols, .. } => {
ui.label(format!("{library}: {} symbols", symbols.len()));
let needle = self.search.to_lowercase();
for symbol in symbols {
if !symbol.library_id.to_lowercase().contains(&needle) {
continue;
}
let row = ui.selectable_label(
false,
format!("{} ({} pins)", symbol.library_id, symbol.pins.len()),
);
if row.clicked() {
read = Some(symbol.clone());
}
}
}
Stage::Opening { library } => {
ui.horizontal(|ui| {
ui.spinner();
ui.label(format!("reading {library}\u{2026}"));
});
}
Stage::Reading { symbol } => {
ui.horizontal(|ui| {
ui.spinner();
ui.label(format!("reading {symbol} and its 3D model\u{2026}"));
});
}
Stage::Review { part, warnings, downloaded } => {
show_review(ui, part, warnings, downloaded);
}
Stage::Bulk => {
if let Some(run) = &self.bulk {
show_sweep(ui, run);
} else {
ui.weak("No import has been started.");
}
}
}
if matches!(self.stage, Stage::Bulk) {
ui.separator();
let running = self.bulk.as_ref().is_some_and(|run| !run.finished());
let stopping = self.bulk.as_ref().is_some_and(BulkRun::stopping);
ui.horizontal(|ui| {
if running {
if ui.add_enabled(!stopping, egui::Button::new("Stop")).clicked() {
if let Some(run) = &self.bulk {
run.stop();
}
self.status = "stopping after the part being imported \u{2014} \
it is finished and recorded first"
.into();
}
if stopping {
ui.weak("stopping\u{2026}");
}
} else if ui.button("Back to the libraries").clicked() {
self.stage = Stage::Libraries;
}
});
}
if matches!(self.stage, Stage::Review { .. }) {
ui.separator();
ui.horizontal(|ui| {
ui.label("Save as");
ui.text_edit_singleline(&mut self.document);
if ui.button("Save part").clicked() {
if let Err(error) = self.save() {
self.status = format!("not saved: {error}");
}
}
if ui.button("Back").clicked() {
self.stage = Stage::Libraries;
}
});
}
if let Some(libraries) = sweep {
self.start_bulk(libraries);
}
if let Some(name) = open {
self.open_library(&name);
}
if let Some(symbol) = read {
self.read_symbol(symbol);
}
}
fn show_bulk_controls(&mut self, ui: &mut egui::Ui) -> Option<Vec<String>> {
let mut wanted = None;
let matching = self.matching();
let running = self.bulk.as_ref().is_some_and(|run| !run.finished());
ui.horizontal_wrapped(|ui| {
let all = egui::Button::new(format!("Import every part of all {} libraries", self.libraries.len()));
if ui.add_enabled(!running && !self.libraries.is_empty(), all).clicked() {
wanted = Some(self.libraries.clone());
}
if matching.len() != self.libraries.len() && !matching.is_empty() {
let some = egui::Button::new(format!("\u{2026}or just the {} matching", matching.len()));
if ui.add_enabled(!running, some).clicked() {
wanted = Some(matching);
}
}
ui.checkbox(&mut self.force_reimport, "Force reimport")
.on_hover_text(
"Redo parts this cache has already imported, instead of skipping them. \
Off, an import carries on where the last one stopped.",
);
if running && ui.button("Show the import in progress").clicked() {
self.stage = Stage::Bulk;
}
});
ui.weak(&self.ledger_line);
ui.weak(
"An import can be stopped and started again as often as you like: what it finished \
is written down, and a later run skips it without downloading or converting it \
again. A part with no footprint or no 3D model still becomes a part, and says so.",
);
wanted
}
}
fn show_sweep(ui: &mut egui::Ui, run: &BulkRun) {
let progress = &run.progress;
ui.add(egui::ProgressBar::new(progress.fraction()).show_percentage());
if progress.symbols_total > 0 {
let within = progress.symbols_done as f32 / progress.symbols_total as f32;
ui.add(
egui::ProgressBar::new(within.clamp(0.0, 1.0))
.desired_height(6.0)
.text(format!(
"{}: {}/{}",
progress.library, progress.symbols_done, progress.symbols_total
)),
);
}
ui.add_space(4.0);
egui::Grid::new("kicad-library-sweep").num_columns(2).show(ui, |ui| {
for (label, value) in [
("Library", format!("{} of {}", progress.libraries_done.min(progress.libraries_total), progress.libraries_total)),
("Now", if progress.symbol.is_empty() { progress.library.clone() } else { progress.symbol.clone() }),
("Imported", progress.imported.to_string()),
("Already done", progress.skipped.to_string()),
("Failed, to retry later", progress.failed.to_string()),
("Downloaded", format!("{} files, {:.1} MB", progress.files, progress.bytes as f64 / 1_048_576.0)),
("Recorded in", run.ledger.display().to_string()),
] {
ui.label(label);
ui.label(value);
ui.end_row();
}
if progress.libraries_failed > 0 {
ui.label("Libraries not read");
ui.label(progress.libraries_failed.to_string());
ui.end_row();
}
});
if let Some(error) = &progress.error {
ui.colored_label(egui::Color32::from_rgb(220, 120, 60), error);
}
if !progress.notes.is_empty() {
ui.add_space(4.0);
ui.weak("What went wrong, most recent last \u{2014} a later import retries exactly these:");
for note in &progress.notes {
ui.weak(note);
}
}
if progress.finished && !progress.cancelled && progress.error.is_none() {
ui.add_space(4.0);
ui.label("Finished.");
}
}
fn show_review(ui: &mut egui::Ui, part: &KicadPart, warnings: &[String], downloaded: &[String]) {
egui::Grid::new("kicad-library-review").num_columns(2).show(ui, |ui| {
ui.label("Symbol");
ui.label(
part.symbol
.as_ref()
.map_or("none".into(), |s| format!("{} ({} pins)", s.library_id, s.pins.len())),
);
ui.end_row();
ui.label("Pads");
ui.label(match (&part.footprint, &part.footprint_file) {
(Some(f), Some(file)) => format!("{} ({} pads) from {file}", f.name, f.pads.len()),
(Some(f), None) => format!("{} ({} pads)", f.name, f.pads.len()),
(None, _) => "none".into(),
});
ui.end_row();
ui.label("3D model");
ui.label(match &part.model {
Some(model) => format!("{} ({} bodies)", model.step_file, model.bodies.len()),
None => "none".into(),
});
ui.end_row();
if let Some(symbol) = &part.symbol {
ui.label("Connection points");
ui.label(format!(
"{} in the part's `Pins` group, {}",
symbol.pins.len(),
if part.footprint.is_some() { "each at its pin's pad" } else { "at the part origin" }
));
ui.end_row();
}
if !downloaded.is_empty() {
ui.label("Downloaded");
ui.label(downloaded.join("\n"));
ui.end_row();
}
});
for note in warnings.iter().chain(&part.notes) {
ui.colored_label(ui.visuals().warn_fg_color, format!("\u{2022} {note}"));
}
}