Files
OneCAD/crates/ui-egui/src/dialogs_library.rs
T

214 lines
9.4 KiB
Rust

//! The first local appearance-library consumer, inside the existing Appearance panel.
#[cfg(not(target_arch = "wasm32"))]
use crate::dialogs_appearance::selected_params;
use crate::{SolveApp, dialogs::row_label, dialogs_appearance::Ap, selection::SelInput, theme::Tokens};
use egui::RichText;
use serde_json::json;
use solidcraft_engine::io::catalog::Catalog;
#[cfg(not(target_arch = "wasm32"))]
use solidcraft_engine::io::catalog::Definition;
#[derive(Clone, Debug, Default)]
pub struct LocalLibrary {
pub search: String,
pub path: String,
pub catalog: Option<Catalog>,
pub selected: Option<String>,
pub overwrite: bool,
}
fn display_order(mut catalog: Catalog) -> Catalog {
catalog.entries.sort_by(|a, b| a.definition.name().cmp(b.definition.name()).then_with(|| a.id.cmp(&b.id)));
catalog
}
/// Runs `f` on the open Appearance dialog's state, once a file picker has answered.
fn with_state(app: &mut SolveApp, f: impl FnOnce(&mut SolveApp, &mut Ap)) {
let Some(mut dialog) = app.dialog.take() else { return };
if let crate::dialogs::Kind::Appearance(k) = &mut dialog.kind {
f(app, k);
}
if app.dialog.is_none() {
app.dialog = Some(dialog);
}
}
/// Exports the design's library to `path` and shows it as the local library.
fn export(app: &mut SolveApp, k: &mut Ap, path: String, overwrite: bool) {
k.note = Some(match app.run("library.export", json!({"path":path,"overwrite":overwrite})) {
Ok(result) => {
let parsed = result.get("catalog").cloned().and_then(|v| serde_json::from_value::<Catalog>(v).ok()).map(display_order);
let count = parsed.as_ref().map_or(0, |c| c.entries.len());
k.local.path = path;
k.local.catalog = parsed;
k.local.selected = None;
k.local.search.clear();
format!("Saved {count} reusable definitions. The design is unchanged.")
}
Err(error) => error,
});
}
#[cfg(not(target_arch = "wasm32"))]
fn load(app: &mut SolveApp, library: &mut LocalLibrary) -> Result<String, String> {
let result = app.run("library.inspect", json!({"path":library.path}))?;
let catalog: Catalog = serde_json::from_value(result.get("catalog").cloned().ok_or("Library response is missing")?).map_err(|e| e.to_string())?;
let note = format!("Opened {} ({} entries)", catalog.name, catalog.entries.len());
library.catalog = Some(display_order(catalog));
library.selected = None;
library.search.clear();
Ok(note)
}
pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &[SelInput]) {
row_label(ui, "Local library");
ui.vertical(|ui| {
#[cfg(not(target_arch = "wasm32"))]
import_rows(app, ui, k, inputs);
#[cfg(target_arch = "wasm32")]
{
let _ = inputs;
ui.label("Open local libraries in the desktop app. You can export this design's library here.");
}
ui.checkbox(&mut k.local.overwrite, crate::i18n::tr_label("Replace existing library file"));
let save = ui.button(crate::i18n::tr_label("Export design library..."));
crate::scenario::publish_handle("library:export", save.rect.center());
if save.clicked() {
let overwrite = k.local.overwrite;
if app.services.pick_save.is_some() {
// The picker answers in a later frame (#1088); the export runs then.
app.pick_save("my-library.solidcraft-library.json", &["json"], move |app, path| {
if let Some(path) = path {
with_state(app, |app, k| export(app, k, path, overwrite));
}
});
} else if !k.local.path.is_empty() {
let path = k.local.path.clone();
export(app, k, path, overwrite);
}
}
ui.label(
RichText::new("Apply your appearance first. Exports assigned appearances and materials created in this design.")
.small()
.color(Tokens::get().text_dim),
);
});
ui.end_row();
}
#[cfg(not(target_arch = "wasm32"))]
fn import_rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &[SelInput]) {
let path = ui.add(egui::TextEdit::singleline(&mut k.local.path).hint_text("Library file").desired_width(200.0));
crate::scenario::publish_handle("library:path", path.rect.center());
if path.changed() {
k.local.catalog = None;
k.local.selected = None;
}
ui.horizontal(|ui| {
if ui.button(crate::i18n::tr_label("Open library...")).clicked() {
// The picker answers in a later frame (#1088); the library loads then.
app.pick_open(|app, path| {
if let Some(path) = path {
with_state(app, |app, k| {
k.local.path = path;
k.local.catalog = None;
k.local.selected = None;
k.note = Some(load(app, &mut k.local).unwrap_or_else(|error| error));
});
}
});
}
let load_button = ui.add_enabled(!k.local.path.is_empty(), egui::Button::new(crate::i18n::tr_label("Load library")));
crate::scenario::publish_handle("library:load", load_button.rect.center());
if load_button.clicked() {
k.local.catalog = None;
k.local.selected = None;
k.note = Some(load(app, &mut k.local).unwrap_or_else(|error| error));
}
});
if let Some(catalog) = &k.local.catalog {
ui.label(RichText::new(&catalog.name).strong());
let search = ui.add(egui::TextEdit::singleline(&mut k.local.search).hint_text("Search this library").desired_width(200.0));
crate::scenario::publish_handle("library:search", search.rect.center());
if search.changed() {
k.local.selected = None;
}
let query = k.local.search.trim().to_lowercase();
let appearances: Vec<_> = catalog
.entries
.iter()
.filter(|entry| matches!(&entry.definition, Definition::Appearance(_)) && entry.definition.name().to_lowercase().contains(&query))
.collect();
if appearances.is_empty() {
ui.label("No matching appearance presets.");
}
let row_height = ui.spacing().interact_size.y;
egui::ScrollArea::vertical().id_salt("local_library_entries").max_height(120.0).show_rows(ui, row_height, appearances.len(), |ui, range| {
for index in range {
let Some(entry) = appearances.get(index) else { continue };
if let Definition::Appearance(look) = &entry.definition {
ui.horizontal(|ui| {
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate);
let [r, g, b] = look.color;
ui.colored_label(egui::Color32::from_rgb(r, g, b), "■");
let button = ui.selectable_label(k.local.selected.as_deref() == Some(entry.id.as_str()), &look.name).on_hover_text(format!(
"Opacity {:.0}% · {} · {}",
look.opacity * 100.0,
entry.provenance.license,
entry.provenance.source_url.as_deref().unwrap_or("Local definition")
));
crate::scenario::publish_handle(&format!("library:entry:{index}"), button.rect.center());
if button.clicked() {
k.local.selected = Some(entry.id.clone());
}
});
}
}
});
}
let entry = k
.local
.catalog
.as_ref()
.and_then(|catalog| {
catalog
.entries
.iter()
.find(|entry| Some(&entry.id) == k.local.selected.as_ref() && matches!(&entry.definition, Definition::Appearance(_)))
})
.cloned();
let picked = inputs.first().map(|input| input.items.as_slice()).unwrap_or(&[]);
let selected = selected_params(k, picked);
let apply = ui.add_enabled(entry.is_some() && selected.is_some(), egui::Button::new(crate::i18n::tr_label("Apply library appearance")));
crate::scenario::publish_handle("library:apply", apply.rect.center());
if apply.clicked()
&& let (Some(entry), Some(selected)) = (entry, selected)
{
let mut targets = serde_json::Map::new();
for key in ["bodies", "faces", "components"] {
if let Some(value) = selected.get(key) {
targets.insert(key.into(), value.clone());
}
}
k.note = Some(match app.run("library.apply", json!({"path":k.local.path,"entry":entry.id,"revision":entry.revision,"targets":targets})) {
Ok(_) => {
if let Definition::Appearance(look) = entry.definition {
k.color = look.color;
k.opacity = look.opacity;
k.look = Some(look.name.clone());
k.remove_override = false;
k.applied = selected_params(k, picked);
format!("{} copied into this design", look.name)
} else {
"This panel applies appearance presets only".into()
}
}
Err(error) => error,
});
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
#[path = "dialogs_library_tests.rs"]
mod tests;