use super::{
counts::{is_remote, refresh_counts, refresh_counts_if_filter_changed, sync_remote_message},
cut_faces::{corner_names, edge_names, options_model},
editing,
exclusions::{self, ExclusionWorker},
format::to_i32,
inputs::{built_in_choices, material_choices},
library_link, run, saved,
session::{DEFAULT_MATERIAL, MaterialChoice, Session},
shape_worker::ShapeWorker,
view,
};
use crate::{
MainWindow, RoughPlanModel, RoughPlannerWindow,
bridge::library::source::LibrarySource,
gui::{show_toast, tutorial_events::raise},
};
use indicatrix_editor::guide::viewing_events::ROUGH_PLANNER_OPENED;
use indicatrix_vault::db::sqlite::Database;
use slint::{
CloseRequestResponse, ComponentHandle, ModelRc, SharedString, Timer, TimerMode, VecModel, Weak,
winit_030::WinitWindowAccessor,
};
use std::{
cell::{Cell, RefCell},
rc::Rc,
sync::{Arc, Mutex, atomic::Ordering},
time::Duration,
};
use tracing::warn;
const FILTER_POLL: Duration = Duration::from_secs(1);
const MAX_WIDTH_FRACTION: f32 = 0.94;
const MAX_HEIGHT_FRACTION: f32 = 0.88;
pub(super) struct Host {
pub(super) window: RoughPlannerWindow,
filter_watch: Timer,
size_fitted: Cell<bool>,
pub(super) main: Weak<MainWindow>,
pub(super) db: Arc<Mutex<Database>>,
pub(super) source: Arc<Mutex<LibrarySource>>,
pub(super) session: Rc<RefCell<Session>>,
pub(super) shape: ShapeWorker,
pub(super) exclusions: ExclusionWorker,
}
thread_local! {
static PLANNER: RefCell<Option<Rc<Host>>> = const { RefCell::new(None) };
}
pub(super) fn with_host<R>(f: impl FnOnce(&Rc<Host>) -> R) -> Option<R> {
let host = PLANNER.with(|cell| cell.borrow().clone())?;
Some(f(&host))
}
pub(super) fn on_host(f: impl FnOnce(&Rc<Host>)) {
let _ = with_host(f);
}
pub(super) fn on_idle_host(f: impl FnOnce(&Rc<Host>)) {
on_host(|host| {
if !host.window.global::<RoughPlanModel>().get_running() {
f(host);
}
});
}
#[must_use]
fn selected_material_index(choices: &[MaterialChoice], previous: Option<&str>) -> usize {
previous
.and_then(|name| choices.iter().position(|c| c.name == name))
.or_else(|| choices.iter().position(|c| c.name == DEFAULT_MATERIAL))
.unwrap_or(0)
}
fn apply_choices(host: &Rc<Host>, choices: Vec<MaterialChoice>) {
let model = host.window.global::<RoughPlanModel>();
let previous = usize::try_from(model.get_material_index())
.ok()
.and_then(|i| host.session.borrow().choices.get(i).map(|c| c.name.clone()));
let index = selected_material_index(&choices, previous.as_deref());
let labels: Vec<SharedString> = choices
.iter()
.map(|c| format!("{} (SG {:.2})", c.name, c.specific_gravity).into())
.collect();
model.set_materials(ModelRc::new(VecModel::from(labels)));
model.set_material_index(to_i32(index));
host.session.borrow_mut().choices = choices;
}
fn merge_choices(host: &Rc<Host>, choices: Vec<MaterialChoice>) {
if host.session.borrow().choices == choices {
return;
}
apply_choices(host, choices);
editing::readout_inputs_changed(host);
}
fn load_catalogue_materials(host: &Rc<Host>) {
let db = Arc::clone(&host.db);
let window = host.window.as_weak();
let spawned = std::thread::Builder::new()
.name("rough-materials".to_string())
.spawn(move || {
let choices = material_choices(&db);
let _ = window.upgrade_in_event_loop(move |_window| {
on_host(|host| merge_choices(host, choices));
});
});
if let Err(error) = spawned {
warn!("Rough planner: could not start the materials thread: {error}");
}
}
fn fill_materials(host: &Rc<Host>) {
if host.session.borrow().choices.is_empty() {
apply_choices(host, built_in_choices());
}
load_catalogue_materials(host);
}
fn push_links_enabled(host: &Rc<Host>) {
let model = host.window.global::<RoughPlanModel>();
let remote = is_remote(&host.source);
model.set_links_enabled(!remote);
sync_remote_message(&model, remote);
}
fn recount(host: &Rc<Host>) {
if host.window.global::<RoughPlanModel>().get_running() {
return;
}
if let Some(main) = host.main.upgrade() {
refresh_counts(&main, &host.window, &host.db, &host.source, &host.session);
}
}
pub(in crate::gui) fn refresh_links_enabled() {
on_host(|host| {
push_links_enabled(host);
recount(host);
});
}
fn recount_on_filter_change(host: &Rc<Host>) {
if !host.window.window().is_visible() {
return;
}
if let Some(main) = host.main.upgrade() {
refresh_counts_if_filter_changed(
&main,
&host.window,
&host.db,
&host.source,
&host.session,
);
}
}
fn fitted_size(
monitor_width: f32,
monitor_height: f32,
current: slint::LogicalSize,
) -> Option<slint::LogicalSize> {
if monitor_width <= 0.0 || monitor_height <= 0.0 {
return None;
}
let width = current.width.min(monitor_width * MAX_WIDTH_FRACTION);
let height = current.height.min(monitor_height * MAX_HEIGHT_FRACTION);
let changed = (width - current.width).abs() > 0.5 || (height - current.height).abs() > 0.5;
changed.then(|| slint::LogicalSize::new(width.floor(), height.floor()))
}
fn fit_to_monitor(window: &RoughPlannerWindow) {
let monitor = window
.window()
.with_winit_window(|winit_window| {
winit_window
.current_monitor()
.or_else(|| winit_window.primary_monitor())
.map(|monitor| {
let physical = monitor.size();
let scale = monitor.scale_factor();
(
f64::from(physical.width) / scale,
f64::from(physical.height) / scale,
)
})
})
.flatten();
let Some((monitor_width, monitor_height)) = monitor else {
warn!("Rough planner: could not determine the monitor size; keeping the window size.");
return;
};
let current = window
.window()
.size()
.to_logical(window.window().scale_factor());
if let Some(size) = fitted_size(monitor_width as f32, monitor_height as f32, current) {
window.window().set_size(size);
}
}
fn fit_on_first_show(host: &Rc<Host>) {
if host.size_fitted.replace(true) {
return;
}
let weak = host.window.as_weak();
Timer::single_shot(Duration::ZERO, move || {
if let Some(window) = weak.upgrade() {
fit_to_monitor(&window);
}
});
}
fn refresh_on_open(main: &MainWindow, host: &Rc<Host>) {
push_links_enabled(host);
if host.window.global::<RoughPlanModel>().get_running() {
return;
}
fill_materials(host);
exclusions::reload(host);
refresh_counts(main, &host.window, &host.db, &host.source, &host.session);
editing::readout_inputs_changed(host);
}
fn create_host(
main: &MainWindow,
db: &Arc<Mutex<Database>>,
source: &Arc<Mutex<LibrarySource>>,
) -> Option<Rc<Host>> {
let window = match RoughPlannerWindow::new() {
Ok(window) => window,
Err(error) => {
warn!("Could not create the rough planner window: {error}");
return None;
}
};
crate::gui::preferences::bind_planner_window_theme(&window);
let host = Rc::new(Host {
shape: ShapeWorker::new(window.as_weak()),
exclusions: ExclusionWorker::new(window.as_weak(), Arc::clone(db)),
window,
filter_watch: Timer::default(),
size_fitted: Cell::new(false),
main: main.as_weak(),
db: Arc::clone(db),
source: Arc::clone(source),
session: Rc::new(RefCell::new(Session::default())),
});
push_links_enabled(&host);
host.window
.window()
.on_close_requested(|| CloseRequestResponse::HideWindow);
let model = host.window.global::<RoughPlanModel>();
model.set_time_limit_secs(crate::plan_limit::stored_limit_secs().to_string().into());
model.set_edge_options(options_model(edge_names()));
model.set_corner_options(options_model(corner_names()));
editing::setup_edit_callbacks(&host);
view::setup_view_callbacks(&host);
run::setup_run_callbacks(&host);
saved::setup_saved_callbacks(&host);
library_link::setup_link_callbacks(&host);
exclusions::setup_callbacks(&host);
host.filter_watch
.start(TimerMode::Repeated, FILTER_POLL, || {
on_host(recount_on_filter_change);
});
PLANNER.with(|cell| *cell.borrow_mut() = Some(Rc::clone(&host)));
Some(host)
}
pub(in crate::gui) fn open_planner(
main: &MainWindow,
db: &Arc<Mutex<Database>>,
source: &Arc<Mutex<LibrarySource>>,
) {
let Some(host) = with_host(Rc::clone).or_else(|| create_host(main, db, source)) else {
show_toast(main, "Could not open the Rough Planner window.", "error");
return;
};
refresh_on_open(main, &host);
if let Err(error) = host.window.show() {
warn!("Could not show the rough planner window: {error}");
show_toast(main, "Could not open the Rough Planner window.", "error");
return;
}
host.window.window().set_minimized(false);
fit_on_first_show(&host);
view::sync_view_size(&host);
raise(main, ROUGH_PLANNER_OPENED);
}
fn risk_message(unsaved_results: usize, plan_running: bool) -> Option<String> {
let mut parts = Vec::new();
if unsaved_results > 0 {
let noun = if unsaved_results == 1 {
"result"
} else {
"results"
};
parts.push(format!(
"The Rough Planner has {unsaved_results} unsaved {noun}."
));
}
if plan_running {
parts.push("A rough plan is still running.".to_string());
}
(!parts.is_empty()).then(|| parts.join(" "))
}
pub(in crate::gui) fn planner_work_at_risk() -> Option<String> {
with_host(|host| {
let unsaved = host.session.try_borrow().map_or(0, |session| {
if session.run.results_unsaved {
session.run.layouts.len()
} else {
0
}
});
let running = host.window.global::<RoughPlanModel>().get_running();
risk_message(unsaved, running)
})
.flatten()
}
pub(in crate::gui) fn close_planner_window() {
let Some(host) = PLANNER.with(|cell| cell.borrow_mut().take()) else {
return;
};
if let Some(flag) = &host.session.borrow().cancel {
flag.store(true, Ordering::Relaxed);
}
let _ = host.window.hide();
super::locate::close_windows();
}
#[cfg(test)]
mod tests {
use super::*;
fn choice(name: &str) -> MaterialChoice {
MaterialChoice {
name: name.to_string(),
specific_gravity: 3.0,
}
}
#[test]
fn the_material_picked_before_stays_selected_when_it_is_still_listed() {
let choices = [choice("Corundum"), choice("Quartz"), choice("Beryl")];
assert_eq!(selected_material_index(&choices, Some("Beryl")), 2);
assert_eq!(selected_material_index(&choices, Some("Corundum")), 0);
}
#[test]
fn a_missing_material_falls_back_to_the_default_then_to_the_first() {
let choices = [choice("Corundum"), choice("Quartz"), choice("Beryl")];
assert_eq!(selected_material_index(&choices, Some("Gone")), 1);
assert_eq!(selected_material_index(&choices, None), 1);
let without_default = [choice("Corundum"), choice("Beryl")];
assert_eq!(selected_material_index(&without_default, None), 0);
assert_eq!(selected_material_index(&[], Some("Quartz")), 0);
}
#[test]
fn a_window_is_shrunk_to_a_share_of_a_small_monitor_and_never_enlarged() {
let current = slint::LogicalSize::new(1280.0, 820.0);
let size = fitted_size(1536.0, 864.0, current).expect("the height must shrink");
assert_eq!(size.width, 1280.0);
assert_eq!(size.height, (864.0_f32 * MAX_HEIGHT_FRACTION).floor());
assert!(fitted_size(2560.0, 1440.0, current).is_none());
assert!(fitted_size(7680.0, 4320.0, current).is_none());
let small = slint::LogicalSize::new(900.0, 600.0);
assert!(fitted_size(1536.0, 864.0, small).is_none());
assert!(fitted_size(0.0, 864.0, current).is_none());
assert!(fitted_size(1536.0, -1.0, current).is_none());
}
#[test]
fn closing_warns_about_unsaved_results_and_a_running_plan() {
assert_eq!(risk_message(0, false), None);
assert_eq!(
risk_message(3, false).as_deref(),
Some("The Rough Planner has 3 unsaved results.")
);
assert_eq!(
risk_message(1, false).as_deref(),
Some("The Rough Planner has 1 unsaved result.")
);
assert_eq!(
risk_message(0, true).as_deref(),
Some("A rough plan is still running.")
);
assert_eq!(
risk_message(10, true).as_deref(),
Some("The Rough Planner has 10 unsaved results. A rough plan is still running.")
);
}
#[test]
fn the_planner_host_does_not_exist_before_the_window_is_opened() {
assert!(with_host(|_| ()).is_none());
assert_eq!(planner_work_at_risk(), None);
let mut ran = false;
on_host(|_| ran = true);
on_idle_host(|_| ran = true);
assert!(!ran);
crate::gui::rough_plan::exclusions_changed();
}
}