mod batch;
mod editor;
mod editor_layout;
mod library;
mod optics;
pub(in crate::gui) mod pixel_font;
mod pickers;
mod remote;
mod render;
pub mod solid_preview;
mod startup_settings;
mod tilt;
mod window_sizing;
pub use library::diagram_list::sync_range_bounds_to_ui;
pub use library::search;
use startup_settings::apply_loaded_settings;
pub(in crate::gui) use startup_settings::{
color_space_from_index, env_map_status_text, is_c_axis_override_available,
local_compute_target_from_index, local_preview_scale_from_index,
refresh_lighting_preset_options, refresh_material_options,
};
use crate::{
EditorModel, LibraryModel, MainWindow, RemoteWorkerModel, SettingsModel, SolidPreviewModel,
TiltModel, ViewportModel,
bridge::{
library::source::LibrarySource,
render_thread::{PlanesOwner, RenderContext, spawn_render_thread},
},
gui::{
optics::{
crystal_optics::{custom_material_specific_gravity_from_rows, gem_material_from_row},
curve_path::tilt_curve_path,
},
remote::{refresh_worker_options, setup_remote_rendering, setup_worker_callbacks},
solid_preview::{
diagram_wiring::{self, DiagramFacetTier, DiagramHoverText, DiagramPick},
preview_state::{
DEFAULT_MESH_BOUNDING_RADIUS, PickBuffer, PreviewFrame, PreviewSink,
SolidLastSolved, SolidPickState, SolidPreviewState,
},
},
},
settings::{self, SettingsPersister},
};
use indicatrix::{geometry::plane::GpuFacetPlane, optics::materials::GemMaterial};
use indicatrix_vault::db::sqlite::Database;
use slint::{ComponentHandle, Weak};
use std::sync::{Arc, Mutex};
const DB_PATH: &str = "facet_diagrams.sqlite";
pub fn main() -> anyhow::Result<()> {
run_gui()
}
pub struct MainWindowHandle {
pub ui: MainWindow,
#[expect(
dead_code,
reason = "RAII guard field -- kept alive, never read; see comment above"
)]
render_ctx: Arc<Mutex<RenderContext>>,
settings_store: Arc<SettingsPersister>,
#[expect(
dead_code,
reason = "RAII guard field -- kept alive, never read; see comment above"
)]
remote_rendering_timer: slint::Timer,
}
pub fn run_gui() -> anyhow::Result<()> {
let handle = build_main_window()?;
handle.ui.show()?;
let editor_layout_touched = handle
.settings_store
.snapshot()
.settings
.editor_layout_touched;
window_sizing::fit_initial_window_size(&handle.ui, editor_layout_touched);
slint::run_event_loop()?;
handle.ui.hide()?;
Ok(())
}
struct SlintSolidSink {
ui: Weak<MainWindow>,
pick: Arc<Mutex<Option<PickBuffer>>>,
last_solved: SolidLastSolved,
diagram_pick: DiagramPick,
diagram_hover_text: DiagramHoverText,
diagram_facet_tier: DiagramFacetTier,
diagram_tooth_pick: DiagramPick,
hover_text: Arc<Mutex<Vec<String>>>,
facet_tier: Arc<Mutex<Vec<Option<usize>>>>,
render_ctx: Arc<Mutex<RenderContext>>,
solid_active_planes: Arc<Mutex<Option<Arc<Vec<GpuFacetPlane>>>>>,
trace_active_planes: Arc<Mutex<Option<Arc<Vec<GpuFacetPlane>>>>>,
mesh_bounding_radius: Arc<Mutex<f64>>,
}
fn planes_generations_match(
trace: &Mutex<Option<Arc<Vec<GpuFacetPlane>>>>,
solid: &Mutex<Option<Arc<Vec<GpuFacetPlane>>>>,
) -> bool {
let trace = trace
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
let solid = solid
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
match (trace, solid) {
(Some(trace), Some(solid)) => Arc::ptr_eq(&trace, &solid),
_ => true,
}
}
fn sync_planes_and_check_trace_match(
planes: &[(glam::Vec3, f32)],
render_ctx: &Mutex<RenderContext>,
solid_active_planes: &Mutex<Option<Arc<Vec<GpuFacetPlane>>>>,
trace_active_planes: &Mutex<Option<Arc<Vec<GpuFacetPlane>>>>,
generation: u64,
) -> bool {
if !planes.is_empty() {
let converted: Vec<GpuFacetPlane> = planes
.iter()
.map(|&(normal, offset)| GpuFacetPlane::new(normal, -offset))
.collect();
let mut ctx = render_ctx
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if generation != 0 && ctx.active_planes.as_ref() != &converted {
let design_gear = ctx.design_gear;
if ctx.claim_active_planes(
Arc::new(converted),
design_gear,
PlanesOwner::Editor { generation },
) {
ctx.dirty = true;
}
}
*solid_active_planes
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) =
Some(Arc::clone(&ctx.active_planes));
}
planes_generations_match(trace_active_planes, solid_active_planes)
}
fn store_if_some<T>(state: &Mutex<Option<T>>, value: Option<T>) {
if let Some(value) = value {
*state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(value);
}
}
impl PreviewSink for SlintSolidSink {
fn apply(&self, frame: PreviewFrame) {
let PreviewFrame {
image,
has_solid,
status,
solved,
stale,
pick,
edges_image,
diagram_image,
has_diagram,
diagram_pick,
diagram_tooth_pick,
diagram_hover_text,
diagram_facet_tier,
planes,
hover_text,
facet_tier,
generation,
mesh_bounding_radius,
} = frame;
let pick_state = Arc::clone(&self.pick);
let last_solved_state = Arc::clone(&self.last_solved);
let diagram_pick_state = Arc::clone(&self.diagram_pick);
let diagram_tooth_pick_state = Arc::clone(&self.diagram_tooth_pick);
let diagram_hover_text_state = Arc::clone(&self.diagram_hover_text);
let diagram_facet_tier_state = Arc::clone(&self.diagram_facet_tier);
let hover_text_state = Arc::clone(&self.hover_text);
let facet_tier_state = Arc::clone(&self.facet_tier);
let render_ctx_state = Arc::clone(&self.render_ctx);
let solid_active_planes_state = Arc::clone(&self.solid_active_planes);
let trace_active_planes_state = Arc::clone(&self.trace_active_planes);
let mesh_bounding_radius_state = Arc::clone(&self.mesh_bounding_radius);
let _ = self.ui.upgrade_in_event_loop(move |ui| {
*pick_state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(pick);
*mesh_bounding_radius_state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = mesh_bounding_radius;
if let Some(masts) = solved.as_ref() {
editor::apply_matching_preview_frame(&ui, &render_ctx_state, generation, masts);
}
*last_solved_state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = solved;
store_if_some(&diagram_pick_state, diagram_pick);
store_if_some(&diagram_tooth_pick_state, diagram_tooth_pick);
store_if_some(&diagram_hover_text_state, diagram_hover_text);
store_if_some(&diagram_facet_tier_state, diagram_facet_tier);
*hover_text_state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = hover_text;
*facet_tier_state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = facet_tier;
let trace_matches = sync_planes_and_check_trace_match(
&planes,
&render_ctx_state,
&solid_active_planes_state,
&trace_active_planes_state,
generation,
);
ui.global::<SolidPreviewModel>()
.set_trace_matches_solid(trace_matches);
ui.global::<SolidPreviewModel>()
.set_image(slint::Image::from_rgba8(image));
ui.global::<SolidPreviewModel>().set_has_solid(has_solid);
ui.global::<SolidPreviewModel>().set_status(status.into());
ui.global::<SolidPreviewModel>().set_stale(stale);
if let Some(edges_image) = edges_image {
ui.global::<SolidPreviewModel>()
.set_edges_image(slint::Image::from_rgba8(edges_image));
ui.global::<SolidPreviewModel>().set_has_solid_edges(true);
} else {
ui.global::<SolidPreviewModel>().set_has_solid_edges(false);
}
if let Some(diagram_image) = diagram_image {
ui.global::<SolidPreviewModel>()
.set_diagram_image(slint::Image::from_rgba8(diagram_image));
}
ui.global::<SolidPreviewModel>()
.set_has_diagram(has_diagram);
});
}
}
#[must_use]
fn initial_custom_materials_and_specific_gravity(
rows: &[indicatrix_vault::model::material::CustomMaterialRow],
) -> (Vec<GemMaterial>, Vec<(String, f64)>) {
let materials = rows.iter().map(gem_material_from_row).collect();
let specific_gravity = custom_material_specific_gravity_from_rows(rows);
(materials, specific_gravity)
}
#[expect(
clippy::too_many_lines,
reason = "a flat sequence of already-extracted setup_* calls (see comment above) -- \
splitting further just moves the same line count into a wrapper that \
calls them all, not a real reduction"
)]
pub fn build_main_window() -> anyhow::Result<MainWindowHandle> {
let ui = MainWindow::new()?;
ui.global::<SettingsModel>()
.set_gpu_build(cfg!(feature = "gpu"));
ui.global::<EditorModel>().set_enabled(true);
let db = match Database::new(Some(DB_PATH)) {
Ok(db_inst) => Arc::new(Mutex::new(db_inst)),
Err(e) => {
match Database::new(Some(":memory:")) {
Ok(memory_db) => {
show_toast(
&ui,
&format!(
"Could not open the design library ({e}) -- running in a \
temporary, unsaved library for this session."
),
"error",
);
Arc::new(Mutex::new(memory_db))
}
Err(memory_err) => {
return Err(anyhow::anyhow!(
"failed to open design library at {DB_PATH:?} ({e}), and the \
in-memory fallback database also failed to open ({memory_err})"
));
}
}
}
};
let render_ctx = Arc::new(Mutex::new(RenderContext::default()));
let solid_pick: Arc<Mutex<Option<PickBuffer>>> = Arc::new(Mutex::new(None));
let solid_last_solved: SolidLastSolved = Arc::new(Mutex::new(None));
let diagram_pick: DiagramPick = Arc::new(Mutex::new(None));
let diagram_tooth_pick: DiagramPick = Arc::new(Mutex::new(None));
let diagram_hover_text: DiagramHoverText = Arc::new(Mutex::new(None));
let diagram_facet_tier: DiagramFacetTier = Arc::new(Mutex::new(None));
let solid_hover_text: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let solid_facet_tier: Arc<Mutex<Vec<Option<usize>>>> = Arc::new(Mutex::new(Vec::new()));
let solid_active_planes: Arc<Mutex<Option<Arc<Vec<GpuFacetPlane>>>>> =
Arc::new(Mutex::new(None));
let trace_active_planes: Arc<Mutex<Option<Arc<Vec<GpuFacetPlane>>>>> =
Arc::new(Mutex::new(None));
let mesh_bounding_radius: Arc<Mutex<f64>> = Arc::new(Mutex::new(DEFAULT_MESH_BOUNDING_RADIUS));
let solid_preview_state = SolidPreviewState::new(Arc::new(SlintSolidSink {
ui: ui.as_weak(),
pick: Arc::clone(&solid_pick),
last_solved: Arc::clone(&solid_last_solved),
diagram_pick: Arc::clone(&diagram_pick),
diagram_tooth_pick: Arc::clone(&diagram_tooth_pick),
diagram_hover_text: Arc::clone(&diagram_hover_text),
diagram_facet_tier: Arc::clone(&diagram_facet_tier),
hover_text: Arc::clone(&solid_hover_text),
facet_tier: Arc::clone(&solid_facet_tier),
render_ctx: Arc::clone(&render_ctx),
solid_active_planes: Arc::clone(&solid_active_planes),
trace_active_planes: Arc::clone(&trace_active_planes),
mesh_bounding_radius: Arc::clone(&mesh_bounding_radius),
}));
diagram_wiring::setup_diagram_hover_and_click_callbacks(
&ui,
&diagram_pick,
&diagram_tooth_pick,
&diagram_hover_text,
&diagram_facet_tier,
&solid_preview_state,
);
let library_source: Arc<Mutex<LibrarySource>> = Arc::new(Mutex::new(LibrarySource::default()));
let custom_material_rows = db
.lock()
.unwrap()
.get_custom_materials()
.unwrap_or_default();
let (initial_custom_mats, initial_custom_sg) =
initial_custom_materials_and_specific_gravity(&custom_material_rows);
{
let mut ctx = render_ctx
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
ctx.custom_materials = Arc::new(initial_custom_mats.clone());
ctx.custom_material_specific_gravity = Arc::new(initial_custom_sg);
}
refresh_material_options(&ui, &initial_custom_mats);
let settings_path = settings::store::default_settings_path();
settings::store::migrate_legacy_settings_if_needed(&settings_path);
let loaded_settings = settings::store::load_or_default(&settings_path);
apply_loaded_settings(&ui, &render_ctx, &loaded_settings);
editor_layout::apply_editor_layout_from_settings(&ui, &loaded_settings.settings);
refresh_lighting_preset_options(&ui, &loaded_settings.presets);
refresh_worker_options(&ui, &loaded_settings.settings.remote_workers);
ui.global::<RemoteWorkerModel>()
.set_denoise_enabled(loaded_settings.settings.denoise_enabled);
let settings_store = Arc::new(SettingsPersister::spawn(settings_path, loaded_settings));
let detached_frame_target = render::detached_render::setup_live_render_visibility_callbacks(
&ui,
&render_ctx,
&solid_preview_state,
);
render::detached_render::recompute_tab_visible(&ui, &render_ctx);
let ui_weak_render = ui.as_weak();
let render_ctx_frame_push = render_ctx.clone();
let trace_active_planes_push = Arc::clone(&trace_active_planes);
let solid_active_planes_push = Arc::clone(&solid_active_planes);
spawn_render_thread(
ui_weak_render,
render_ctx.clone(),
move |ui: &MainWindow, img: slint::SharedPixelBuffer<slint::Rgba8Pixel>| {
let slint_img = slint::Image::from_rgba8(img);
ui.global::<ViewportModel>()
.set_render_image(slint_img.clone());
ui.global::<ViewportModel>().set_has_render(true);
let detached_weak = detached_frame_target.lock().unwrap().clone();
if let Some(detached) = detached_weak.and_then(|weak| weak.upgrade()) {
detached.set_render_image(slint_img);
detached.set_has_render(true);
}
let planes_now = render_ctx_frame_push
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.active_planes
.clone();
*trace_active_planes_push
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(planes_now);
ui.global::<SolidPreviewModel>()
.set_trace_matches_solid(planes_generations_match(
&trace_active_planes_push,
&solid_active_planes_push,
));
},
|ui: &MainWindow,
brilliance: f32,
fire: f32,
scintillation: f32,
windowing: f32,
extinction: f32,
graph_brilliance: [f32; 19],
graph_extinction: [f32; 19],
graph_windowing: [f32; 19],
cam_pitch_deg: f32| {
ui.global::<ViewportModel>().set_brilliance_pct(brilliance);
ui.global::<ViewportModel>().set_fire_index(fire);
ui.global::<ViewportModel>()
.set_scintillation_pct(scintillation);
ui.global::<ViewportModel>().set_windowing_pct(windowing);
ui.global::<ViewportModel>().set_extinction_pct(extinction);
ui.global::<TiltModel>()
.set_graph_brilliance(slint::ModelRc::new(slint::VecModel::from(
graph_brilliance.to_vec(),
)));
ui.global::<TiltModel>()
.set_graph_extinction(slint::ModelRc::new(slint::VecModel::from(
graph_extinction.to_vec(),
)));
ui.global::<TiltModel>()
.set_graph_windowing(slint::ModelRc::new(slint::VecModel::from(
graph_windowing.to_vec(),
)));
ui.global::<TiltModel>()
.set_graph_brilliance_path(tilt_curve_path(&graph_brilliance).into());
ui.global::<TiltModel>()
.set_graph_extinction_path(tilt_curve_path(&graph_extinction).into());
ui.global::<TiltModel>()
.set_graph_windowing_path(tilt_curve_path(&graph_windowing).into());
ui.global::<TiltModel>().set_cam_pitch_deg(cam_pitch_deg);
},
|ui: &MainWindow, text: slint::SharedString| {
ui.global::<ViewportModel>().set_gpu_status_text(text);
},
);
let settings_store_panel = settings_store.clone();
ui.global::<LibraryModel>()
.on_panel_collapsed_changed(move |collapsed: bool| {
settings_store_panel.update(|s| s.settings.library_panel_collapsed = collapsed);
});
editor_layout::setup_editor_layout_callbacks(&ui, &settings_store);
setup_user_manual_callback(&ui);
setup_reveal_last_saved_callback(&ui);
render::camera_lighting::setup_camera_and_lighting_callbacks(
&ui,
&render_ctx,
&settings_store,
&solid_preview_state,
&mesh_bounding_radius,
);
render::material_quality::setup_material_changed_callback(&ui, &render_ctx, &settings_store);
render::material_quality::setup_backdrop_callback(&ui, &render_ctx, &settings_store);
render::material_quality::setup_material_and_quality_callbacks(
&ui,
&render_ctx,
&settings_store,
);
render::material_quality::setup_material_effect_override_callbacks(
&ui,
&render_ctx,
&settings_store,
);
render::camera_lighting::setup_environment_map_callbacks(&ui, &render_ctx, &settings_store);
render::lighting_presets::setup_lighting_preset_callbacks(&ui, &render_ctx, &settings_store);
render::render_export::setup_render_export_callbacks(&ui, &render_ctx, &settings_store);
optics::custom_materials::setup_custom_material_callbacks(&ui, &render_ctx, &db);
library::clipboard::setup_copy_callbacks(&ui);
tilt::tilt_profile::setup_tilt_profile_callback(&ui, &render_ctx);
tilt::tilt_hover_preview::setup_tilt_hover_preview_callback(&ui, &render_ctx);
library::diagram_list::load_filter_options_and_initial_list(&ui, &db);
library::diagram_list::setup_search_and_filter_callbacks(&ui, &db, &library_source);
library::diagram_list::setup_regenerate_filtered_set_callbacks(&ui, &db, &library_source);
library::diagram_list::setup_diagram_selection_and_export_callbacks(
&ui,
&db,
&library_source,
&render_ctx,
&solid_preview_state,
);
library::diagram_list::setup_performance_filter_callbacks(&ui);
library::local::setup_import_callback(&ui, &db, &library_source, &settings_store);
library::local::setup_rename_callback(&ui, &db, &library_source);
library::local::setup_set_shape_callback(&ui, &db, &library_source);
library::local::setup_delete_callback(&ui, &db, &library_source, &render_ctx);
library::local::setup_export_asc_callback(&ui, &db, &library_source);
library::local::setup_ignore_toggle_callback(&ui, &db, &library_source);
library::local::setup_add_tag_callback(&ui, &db, &library_source);
library::local::setup_remove_tag_callback(&ui, &db, &library_source);
library::detail::setup_save_metadata_callback(&ui, &db, &library_source, &render_ctx);
library::detail::setup_filtered_row_count_callback(&ui);
let solid_pick_state = SolidPickState {
pick: Arc::clone(&solid_pick),
hover_text: Arc::clone(&solid_hover_text),
facet_tier: Arc::clone(&solid_facet_tier),
};
editor::setup_editor_callbacks(
&ui,
&db,
&library_source,
&render_ctx,
&solid_preview_state,
&solid_last_solved,
&solid_pick_state,
);
let settings_store_solid_view_mode = settings_store.clone();
let render_ctx_solid_view_mode = render_ctx.clone();
let preview_state_solid_view_mode = Arc::clone(&solid_preview_state);
let ui_weak_solid_view_mode = ui.as_weak();
ui.global::<SolidPreviewModel>()
.on_view_mode_changed(move |mode: i32| {
settings_store_solid_view_mode.update(|s| {
s.settings.solid_view_mode = u8::try_from(mode).unwrap_or_default();
});
if let Some(ui) = ui_weak_solid_view_mode.upgrade() {
render::detached_render::recompute_tab_visible(&ui, &render_ctx_solid_view_mode);
let mut ctx = render_ctx_solid_view_mode
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if mode == 1 || mode == 2 {
ctx.dirty = true;
}
render::camera_lighting::resubmit_at_current_pose(
&ui,
&ctx,
&preview_state_solid_view_mode,
);
drop(ctx);
}
});
let render_ctx_viewport_resized = render_ctx.clone();
let preview_state_viewport_resized = Arc::clone(&solid_preview_state);
let ui_weak_viewport_resized = ui.as_weak();
ui.global::<SolidPreviewModel>()
.on_viewport_resized(move || {
let ui_weak = ui_weak_viewport_resized.clone();
let render_ctx = render_ctx_viewport_resized.clone();
let preview_state = Arc::clone(&preview_state_viewport_resized);
slint::Timer::single_shot(std::time::Duration::ZERO, move || {
let Some(ui) = ui_weak.upgrade() else {
return;
};
let ctx = render_ctx
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
render::camera_lighting::resubmit_at_current_pose(&ui, &ctx, &preview_state);
});
});
let settings_store_live_view_mode = settings_store.clone();
let render_ctx_live_view_mode = render_ctx.clone();
let preview_state_live_view_mode = Arc::clone(&solid_preview_state);
let ui_weak_live_view_mode = ui.as_weak();
ui.global::<ViewportModel>()
.on_live_view_mode_changed(move |mode: i32| {
settings_store_live_view_mode.update(|s| {
s.settings.live_view_mode = u8::try_from(mode).unwrap_or_default();
});
let Some(ui) = ui_weak_live_view_mode.upgrade() else {
return;
};
render::detached_render::recompute_tab_visible(&ui, &render_ctx_live_view_mode);
let mut ctx = render_ctx_live_view_mode
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if mode == 1 {
ctx.dirty = true;
} else if mode == 0 {
render::camera_lighting::resubmit_live_solid(
&ui,
&ctx,
&preview_state_live_view_mode,
);
}
drop(ctx);
});
let settings_store_auto_solve_budget = settings_store.clone();
ui.global::<EditorModel>()
.on_auto_solve_budget_ms_changed(move |budget_ms: i32| {
settings_store_auto_solve_budget.update(|s| {
s.settings.editor_auto_solve_budget_ms =
u32::try_from(budget_ms).unwrap_or_default();
});
});
library::remote::setup_library_source_callbacks(&ui, &db, &library_source, &settings_store);
library::remote::setup_mirror_sync_callbacks(&ui, &db, &settings_store);
setup_worker_callbacks(&ui, &render_ctx, &settings_store);
batch::preview_cache::setup_preview_thumbnail_callback(&ui, &db);
batch::preview::setup_preview_batch_callbacks(&ui, &db, &render_ctx, &settings_store);
batch::tilt::setup_tilt_batch_callbacks(&ui, &db, &render_ctx, &settings_store);
let remote_rendering_timer = setup_remote_rendering(&ui, &render_ctx, &settings_store);
let render_ctx_close = render_ctx.clone();
let settings_store_close = settings_store.clone();
let ui_weak_close = ui.as_weak();
ui.window().on_close_requested(move || {
let is_dirty = ui_weak_close
.upgrade()
.is_some_and(|ui| ui.global::<EditorModel>().get_is_dirty());
if !is_dirty {
render_ctx_close
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.running = false;
settings_store_close.flush();
return slint::CloseRequestResponse::HideWindow;
}
if let Some(ui) = ui_weak_close.upgrade() {
ui.set_close_confirm_open(true);
}
slint::CloseRequestResponse::KeepWindowShown
});
setup_close_confirm_callbacks(&ui, &render_ctx, &settings_store);
Ok(MainWindowHandle {
ui,
render_ctx,
settings_store,
remote_rendering_timer,
})
}
fn setup_close_confirm_callbacks(
ui: &MainWindow,
render_ctx: &Arc<Mutex<RenderContext>>,
settings_store: &Arc<SettingsPersister>,
) {
let render_ctx_save = render_ctx.clone();
let settings_store_save = settings_store.clone();
let ui_weak_save = ui.as_weak();
ui.on_close_confirm_save(move || {
let Some(ui) = ui_weak_save.upgrade() else {
return;
};
ui.set_close_confirm_open(false);
ui.global::<EditorModel>().invoke_save_native();
if ui.global::<EditorModel>().get_is_dirty() {
return;
}
render_ctx_save
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.running = false;
settings_store_save.flush();
let _ = ui.hide();
});
let render_ctx_discard = render_ctx.clone();
let settings_store_discard = settings_store.clone();
let ui_weak_discard = ui.as_weak();
ui.on_close_confirm_discard(move || {
let Some(ui) = ui_weak_discard.upgrade() else {
return;
};
ui.set_close_confirm_open(false);
render_ctx_discard
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.running = false;
settings_store_discard.flush();
let _ = ui.hide();
});
}
fn starting_dir_from_picker_field(current: &str) -> Option<std::path::PathBuf> {
if current.is_empty() {
return None;
}
let path = std::path::Path::new(current);
if path.is_dir() {
Some(path.to_path_buf())
} else if path.is_file() {
path.parent().map(std::path::Path::to_path_buf)
} else {
None
}
}
fn user_manual_candidates(exe_dir: &std::path::Path) -> Vec<std::path::PathBuf> {
vec![
exe_dir.join("docs/manual/README.md"),
exe_dir.join("../docs/manual/README.md"),
exe_dir.join("../../apps/indicatrix-cut/docs/manual/README.md"),
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("docs/manual/README.md"),
]
}
fn locate_user_manual() -> Option<std::path::PathBuf> {
let exe_dir = std::env::current_exe()
.ok()
.and_then(|exe| exe.parent().map(std::path::Path::to_path_buf))?;
user_manual_candidates(&exe_dir)
.into_iter()
.find(|candidate| candidate.is_file())
}
fn setup_user_manual_callback(ui: &MainWindow) {
let ui_weak = ui.as_weak();
ui.on_open_user_manual(move || {
let Some(ui) = ui_weak.upgrade() else {
return;
};
let Some(manual_path) = locate_user_manual() else {
show_toast(&ui, "User manual not found", "error");
return;
};
open_with_os_handler(&manual_path.to_string_lossy());
});
}
fn open_with_os_handler(path: &str) {
#[cfg(target_os = "linux")]
let _ = std::process::Command::new("xdg-open").arg(path).spawn();
#[cfg(target_os = "windows")]
let _ = std::process::Command::new("cmd")
.args(["/C", "start", "", path])
.spawn();
#[cfg(target_os = "macos")]
let _ = std::process::Command::new("open").arg(path).spawn();
}
fn setup_reveal_last_saved_callback(ui: &MainWindow) {
let ui_weak = ui.as_weak();
ui.global::<EditorModel>()
.on_reveal_last_saved_path(move || {
let Some(ui) = ui_weak.upgrade() else {
return;
};
let saved = ui.global::<EditorModel>().get_last_saved_path();
if saved.is_empty() {
return;
}
let path = std::path::PathBuf::from(saved.as_str());
let target = path.parent().unwrap_or(&path);
open_with_os_handler(&target.to_string_lossy());
});
}
pub fn show_toast(ui: &MainWindow, msg: &str, toast_type: &str) {
ui.set_toast_message(msg.into());
ui.set_toast_type(toast_type.into());
ui.set_toast_visible(true);
let generation = ui.get_toast_generation() + 1;
ui.set_toast_generation(generation);
if toast_type == "error" || toast_type == "warning" {
return;
}
let ui_weak = ui.as_weak();
slint::Timer::single_shot(std::time::Duration::from_millis(3500), move || {
if let Some(ui) = ui_weak.upgrade()
&& ui.get_toast_generation() == generation
{
ui.set_toast_visible(false);
}
});
}
#[cfg(test)]
mod tests {
use super::{initial_custom_materials_and_specific_gravity, user_manual_candidates};
use indicatrix_vault::model::material::CustomMaterialRow;
fn custom_row(name: &str, specific_gravity: Option<f32>) -> CustomMaterialRow {
CustomMaterialRow {
name: name.to_string(),
refractive_index: 1.7,
dispersion: 0.02,
birefringence: 0.0,
absorption_rgb: [0.0, 0.0, 0.0],
crystal_system: None,
optical_character: None,
biaxial_delta_beta_alpha: None,
per_axis_dispersion_json: None,
specific_gravity,
}
}
#[test]
fn startup_helper_populates_both_lists_from_one_row_read() {
let rows = [
custom_row("My Garnet", Some(3.90)),
custom_row("Custom Diamond", None),
];
let (materials, specific_gravity) = initial_custom_materials_and_specific_gravity(&rows);
assert_eq!(materials.len(), 2, "every row becomes a GemMaterial");
assert!(materials.iter().any(|m| m.name == "My Garnet"));
assert!(materials.iter().any(|m| m.name == "Custom Diamond"));
assert_eq!(
specific_gravity.len(),
1,
"only the row with a recorded SG contributes an entry, and a row \
with none is skipped rather than showing up as a spurious Some(0.0)"
);
assert_eq!(specific_gravity[0].0, "My Garnet");
assert!((specific_gravity[0].1 - 3.90_f64).abs() < 1e-6);
}
#[test]
fn startup_helper_is_empty_for_no_custom_materials() {
let (materials, specific_gravity) = initial_custom_materials_and_specific_gravity(&[]);
assert_eq!(materials.len(), 0);
assert_eq!(specific_gravity.len(), 0);
}
#[test]
fn user_manual_candidates_are_relative_to_exe_dir_in_order() {
let exe_dir = std::path::Path::new("/opt/indicatrix-cut/bin");
let candidates = user_manual_candidates(exe_dir);
assert_eq!(
candidates[0],
std::path::Path::new("/opt/indicatrix-cut/bin/docs/manual/README.md")
);
assert_eq!(
candidates[1],
std::path::Path::new("/opt/indicatrix-cut/bin/../docs/manual/README.md")
);
assert_eq!(
candidates[2],
std::path::Path::new(
"/opt/indicatrix-cut/bin/../../apps/indicatrix-cut/docs/manual/README.md"
)
);
assert_eq!(
candidates[3],
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("docs/manual/README.md")
);
assert_eq!(candidates.len(), 4);
}
}