use crate::{LibraryModel, MainWindow, TiltModel, ViewportModel, gui::show_toast};
use slint::{ComponentHandle, Model, SharedString};
use std::{
fmt::Write as _,
io::{self, Write as _},
process::{Command, Stdio},
};
use tracing::{info, warn};
#[cfg(target_os = "linux")]
const CLIPBOARD_TOOLS: &[(&str, &[&str])] =
&[("xclip", &["-selection", "clipboard"]), ("wl-copy", &[])];
#[cfg(target_os = "windows")]
const CLIPBOARD_TOOLS: &[(&str, &[&str])] = &[("clip", &[])];
#[cfg(target_os = "macos")]
const CLIPBOARD_TOOLS: &[(&str, &[&str])] = &[("pbcopy", &[])];
#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
const CLIPBOARD_TOOLS: &[(&str, &[&str])] = &[];
#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
fn clipboard_command(program: &str, args: &[&str]) -> Command {
let mut command = Command::new(program);
command.args(args);
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt as _;
command.creation_flags(CREATE_NO_WINDOW);
}
command
}
fn pipe_text_to(mut command: Command, text: &str) -> io::Result<()> {
let mut child = command
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
let written = child.stdin.take().map_or_else(
|| Err(io::Error::other("the child process has no stdin pipe")),
|mut stdin| {
let result = stdin.write_all(text.as_bytes());
drop(stdin);
result
},
);
let status = child.wait()?;
written?;
if status.success() {
Ok(())
} else {
Err(io::Error::other(format!("exited with {status}")))
}
}
pub fn copy_to_clipboard(text: &str) -> bool {
info!("Copying text to clipboard ({} bytes)", text.len());
for (program, args) in CLIPBOARD_TOOLS {
match pipe_text_to(clipboard_command(program, args), text) {
Ok(()) => return true,
Err(e) => warn!("Clipboard tool `{program}` failed: {e}"),
}
}
false
}
pub(in crate::gui) fn copy_to_clipboard_with_toast(
ui: &MainWindow,
text: String,
success_message: String,
) {
let ui_weak = ui.as_weak();
std::thread::spawn(move || {
let copied = copy_to_clipboard(&text);
let _ = ui_weak.upgrade_in_event_loop(move |ui| {
if copied {
show_toast(&ui, &success_message, "success");
} else {
show_toast(
&ui,
"Could not copy to the clipboard: no clipboard tool is available.",
"error",
);
}
});
});
}
const FULL_AXIS_LABELS: [&str; 4] = ["0 (length)", "45", "90 (width)", "135"];
const FULL_AXIS_SAMPLE_COUNT: usize = 181;
fn build_curve_data_csv(ui: &MainWindow) -> String {
let mut csv = String::from("Tilt Angle (°),Axis,Brilliance (%),Windowing (%),Extinction (%)\n");
let extra_brilliance = ui.global::<TiltModel>().get_graph_brilliance_extra_axes();
let extra_extinction = ui.global::<TiltModel>().get_graph_extinction_extra_axes();
let extra_windowing = ui.global::<TiltModel>().get_graph_windowing_extra_axes();
if extra_brilliance.row_count() >= FULL_AXIS_LABELS.len()
&& extra_extinction.row_count() >= FULL_AXIS_LABELS.len()
&& extra_windowing.row_count() >= FULL_AXIS_LABELS.len()
{
for (axis_idx, label) in FULL_AXIS_LABELS.iter().enumerate() {
let Some(axis_b) = extra_brilliance.row_data(axis_idx) else {
continue;
};
let Some(axis_w) = extra_windowing.row_data(axis_idx) else {
continue;
};
let Some(axis_e) = extra_extinction.row_data(axis_idx) else {
continue;
};
for i in 0..FULL_AXIS_SAMPLE_COUNT {
let angle = i as i32 - 90; let b = axis_b.row_data(i).unwrap_or(0.0);
let w = axis_w.row_data(i).unwrap_or(0.0);
let e = axis_e.row_data(i).unwrap_or(0.0);
let _ = writeln!(csv, "{angle},{label},{b:.1},{w:.1},{e:.1}");
}
}
return csv;
}
let gb = ui.global::<TiltModel>().get_graph_brilliance();
let ge = ui.global::<TiltModel>().get_graph_extinction();
let gw = ui.global::<TiltModel>().get_graph_windowing();
for i in 0..19 {
let angle = i * 5;
let b = gb.row_data(i).unwrap_or(0.0);
let w = gw.row_data(i).unwrap_or(0.0);
let e = ge.row_data(i).unwrap_or(0.0);
let _ = writeln!(csv, "{angle},0 (length),{b:.1},{w:.1},{e:.1}");
}
csv
}
pub(in crate::gui) fn setup_copy_callbacks(ui: &MainWindow) {
let ui_weak_metrics = ui.as_weak();
ui.global::<ViewportModel>().on_copy_metrics(move || {
if let Some(ui) = ui_weak_metrics.upgrade() {
let b = ui.global::<ViewportModel>().get_brilliance_pct();
let f = ui.global::<ViewportModel>().get_fire_index();
let w = ui.global::<ViewportModel>().get_windowing_pct();
let text = format!(
"Optical Metrics:\n• Brilliance: {b:.1}%\n• Fire Index: {f:.2}\n• Windowing: {w:.1}%"
);
copy_to_clipboard_with_toast(
&ui,
text,
"Optical metrics copied to clipboard!".to_owned(),
);
}
});
let ui_weak_curve = ui.as_weak();
ui.global::<TiltModel>().on_copy_curve_data(move || {
if let Some(ui) = ui_weak_curve.upgrade() {
let csv = build_curve_data_csv(&ui);
copy_to_clipboard_with_toast(
&ui,
csv,
"181-Point performance curve data (4 axes, ±90°) copied to clipboard!".to_owned(),
);
}
});
let ui_weak_copy = ui.as_weak();
ui.global::<LibraryModel>()
.on_copy_text(move |text: SharedString| {
if let Some(ui) = ui_weak_copy.upgrade() {
copy_to_clipboard_with_toast(
&ui,
text.to_string(),
"Copied to clipboard!".to_owned(),
);
}
});
let ui_weak_sched = ui.as_weak();
ui.global::<LibraryModel>().on_copy_cutting_table(move || {
if let Some(ui) = ui_weak_sched.upgrade() {
let angles_model = ui.global::<LibraryModel>().get_current_angles();
let mut text = String::from("#\tFacet\tAngle\tIndex\tNotes\n");
for i in 0..angles_model.row_count() {
if let Some(row) = angles_model.row_data(i) {
let _ = writeln!(
text,
"{}\t{}\t{}\t{}\t{}",
row.order_idx + 1,
row.code,
row.angle,
row.index_val,
row.notes
);
if !row.second_line.is_empty() {
let _ = writeln!(text, "\t{}", row.second_line);
}
}
}
copy_to_clipboard_with_toast(
&ui,
text,
"Cutting instructions copied as TSV!".to_owned(),
);
}
});
let ui_weak_row = ui.as_weak();
ui.global::<LibraryModel>()
.on_copy_cutting_row(move |idx: i32| {
if let Some(ui) = ui_weak_row.upgrade() {
let angles_model = ui.global::<LibraryModel>().get_current_angles();
if let Some(row) = angles_model.row_data(idx as usize) {
let mut text = format!(
"Facet: {}, Angle: {}, Index: {}, Notes: {}",
row.code, row.angle, row.index_val, row.notes
);
if !row.second_line.is_empty() {
let _ = write!(text, "\nTool: {}", row.second_line);
}
copy_to_clipboard_with_toast(&ui, text, format!("Copied step #{}", idx + 1));
}
}
});
}
#[cfg(test)]
mod tests {
use super::{Command, pipe_text_to};
use std::{sync::mpsc, time::Duration};
#[cfg(target_os = "windows")]
fn read_until_eof_command() -> Command {
let mut command = Command::new("cmd");
command.args(["/C", "more"]);
command
}
#[cfg(not(target_os = "windows"))]
fn read_until_eof_command() -> Command {
Command::new("cat")
}
fn pipe_with_timeout(text: String) -> std::io::Result<()> {
let (sender, receiver) = mpsc::channel();
std::thread::spawn(move || {
let _ = sender.send(pipe_text_to(read_until_eof_command(), &text));
});
receiver
.recv_timeout(Duration::from_secs(5))
.expect("pipe_text_to did not return: the child's stdin was never closed")
}
#[test]
fn piping_to_a_read_until_eof_child_completes() {
pipe_with_timeout("clipboard text\n".to_owned()).expect("the child must exit cleanly");
}
#[test]
fn piping_more_than_a_pipe_buffer_completes() {
pipe_with_timeout("clipboard text\n".repeat(8_000)).expect("the child must exit cleanly");
}
#[test]
fn a_missing_program_is_an_error_not_a_hang() {
let result = pipe_text_to(Command::new("indicatrix-no-such-clipboard-tool"), "text");
assert!(result.is_err());
}
}