From f3067a60105d02271eb42c2c420811ff2c1ce9b4 Mon Sep 17 00:00:00 2001 From: gronod Date: Sat, 22 Aug 2026 17:38:21 +0100 Subject: [PATCH 1/5] feat: Stage 2 - Target Image Layout & printtarg Integration --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/commands.rs | 100 +++++++++++++++++ src-tauri/src/lib.rs | 2 + src/index.html | 90 ++++++++++++++- src/js/app.js | 4 + src/js/printtarg.js | 224 ++++++++++++++++++++++++++++++++++++++ src/js/targen.js | 2 + src/styles/main.css | 109 ++++++++++++++++++- 9 files changed, 530 insertions(+), 3 deletions(-) create mode 100644 src/js/printtarg.js diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 61cd4d4..0ab1ad3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1579,6 +1579,7 @@ dependencies = [ name = "iccery" version = "0.1.0" dependencies = [ + "base64 0.22.1", "serde", "serde_json", "tauri", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0ba1c25..060753c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,4 +24,5 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } tauri-plugin-dialog = "2.7.2" +base64 = "0.22" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 314cdc1..b397e59 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -63,6 +63,16 @@ pub struct TargenConfig { pub cwd: String, } +#[derive(Debug, Deserialize, Serialize)] +pub struct PrinttargConfig { + pub instrument: String, // One of: "i1", "p3", "CM", "SS", "20", "22", "41", "51" + pub page_size: String, // One of: "A4", "A4R", "A3", "A2", "Letter", "LetterR", "Legal", "4x6", "11x17", or "WWWxHHH" + pub bit_depth: u8, // 8 or 16 + pub dpi: u32, // TIFF resolution, e.g. 100, 200, 300 + pub basename: String, // Must match the .ti1 basename from Stage 1 + pub cwd: String, // Working directory where the .ti1 file resides +} + pub fn build_targen_args(config: &TargenConfig) -> Vec { let mut args = vec![ "-v".to_string(), @@ -92,6 +102,27 @@ pub fn build_targen_args(config: &TargenConfig) -> Vec { args } +pub fn build_printtarg_args(config: &PrinttargConfig) -> Vec { + let mut args = vec![ + "-v".to_string(), + "-u".to_string(), + "-i".to_string(), + config.instrument.clone(), + "-p".to_string(), + config.page_size.clone(), + ]; + + if config.bit_depth == 16 { + args.push("-T".to_string()); + } else { + args.push("-t".to_string()); + } + args.push(config.dpi.to_string()); + + args.push(config.basename.clone()); + args +} + #[tauri::command] pub async fn run_targen( app: AppHandle, @@ -113,6 +144,33 @@ pub async fn run_targen( state.spawn(app, id, binary, args, cwd).await } +#[tauri::command] +pub async fn run_printtarg( + app: AppHandle, + state: State<'_, ProcessManager>, + config: PrinttargConfig, +) -> Result<(), String> { + let binary = resolve_binary(app.clone(), "printtarg".to_string()).await?; + let args = build_printtarg_args(&config); + let id = format!("printtarg_{}", config.basename); + + let cwd = if config.cwd.trim().is_empty() { + None + } else { + Some(config.cwd.clone()) + }; + + state.spawn(app, id, binary, args, cwd).await +} + +#[tauri::command] +pub async fn read_file_base64(path: String) -> Result { + use std::fs; + let bytes = fs::read(&path).map_err(|e| format!("Failed to read {}: {}", path, e))?; + use base64::Engine; + Ok(base64::engine::general_purpose::STANDARD.encode(&bytes)) +} + #[cfg(test)] mod tests { use super::*; @@ -144,4 +202,46 @@ mod tests { let args = build_targen_args(&config); assert_eq!(args, vec!["-v", "-d", "4", "-f", "1500", "-B", "8", "cmyk_profile"]); } + + #[test] + fn test_build_printtarg_args_i1_a4_8bit() { + let config = PrinttargConfig { + instrument: "i1".to_string(), + page_size: "A4".to_string(), + bit_depth: 8, + dpi: 100, + basename: "my_profile".to_string(), + cwd: "/tmp".to_string(), + }; + let args = build_printtarg_args(&config); + assert_eq!(args, vec!["-v", "-u", "-i", "i1", "-p", "A4", "-t", "100", "my_profile"]); + } + + #[test] + fn test_build_printtarg_args_cm_letter_16bit() { + let config = PrinttargConfig { + instrument: "CM".to_string(), + page_size: "Letter".to_string(), + bit_depth: 16, + dpi: 300, + basename: "cmyk_profile".to_string(), + cwd: "/home/user".to_string(), + }; + let args = build_printtarg_args(&config); + assert_eq!(args, vec!["-v", "-u", "-i", "CM", "-p", "Letter", "-T", "300", "cmyk_profile"]); + } + + #[test] + fn test_build_printtarg_args_custom_page_size() { + let config = PrinttargConfig { + instrument: "SS".to_string(), + page_size: "200x400".to_string(), + bit_depth: 8, + dpi: 150, + basename: "custom_target".to_string(), + cwd: "/tmp".to_string(), + }; + let args = build_printtarg_args(&config); + assert_eq!(args, vec!["-v", "-u", "-i", "SS", "-p", "200x400", "-t", "150", "custom_target"]); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9d1f622..2c63096 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -15,6 +15,8 @@ pub fn run() { commands::resolve_binary, commands::list_instruments, commands::run_targen, + commands::run_printtarg, + commands::read_file_base64, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/index.html b/src/index.html index c2aae67..3956978 100644 --- a/src/index.html +++ b/src/index.html @@ -84,8 +84,94 @@ diff --git a/src/js/app.js b/src/js/app.js index c9d7f1b..82723e6 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -1,4 +1,5 @@ import { initTargen } from './targen.js'; +import { initPrinttarg } from './printtarg.js'; const { invoke } = window.__TAURI__.core; @@ -27,4 +28,7 @@ document.addEventListener('DOMContentLoaded', () => { // Initialize Stage 1 initTargen(); + + // Initialize Stage 2 + initPrinttarg(); }); diff --git a/src/js/printtarg.js b/src/js/printtarg.js new file mode 100644 index 0000000..da8d6db --- /dev/null +++ b/src/js/printtarg.js @@ -0,0 +1,224 @@ +const { invoke } = window.__TAURI__.core; +const { listen } = window.__TAURI__.event; + +// Module-level state: set by Stage 1 when it completes +let stage1Basename = ""; +let stage1Cwd = ""; + +/** + * Called by targen.js (or app.js) after Stage 1 completes. + * Passes the basename and working directory forward. + */ +export function setStage1Result(basename, cwd) { + stage1Basename = basename; + stage1Cwd = cwd; +} + +export function initPrinttarg() { + const instrumentSelect = document.getElementById("instrumentSelect"); + const pageSizeSelect = document.getElementById("pageSizeSelect"); + const customPageSizeRow = document.getElementById("customPageSizeRow"); + const customPageW = document.getElementById("customPageW"); + const customPageH = document.getElementById("customPageH"); + const bitDepthRadios = document.querySelectorAll('input[name="bitDepth"]'); + const tiffDpi = document.getElementById("tiffDpi"); + const btnCreateLayout = document.getElementById("btnCreateLayout"); + const logContainer = document.getElementById("printtargLogContainer"); + const logPre = document.getElementById("printtargLog"); + const tiffGallery = document.getElementById("tiffGallery"); + const galleryInfo = document.getElementById("galleryInfo"); + const galleryGrid = document.getElementById("galleryGrid"); + + // Show/hide custom page size inputs + pageSizeSelect.addEventListener("change", (e) => { + if (e.target.value === "custom") { + customPageSizeRow.classList.remove("hidden"); + } else { + customPageSizeRow.classList.add("hidden"); + } + }); + + // Create Layout button + btnCreateLayout.addEventListener("click", async () => { + // Validate that Stage 1 has been completed + if (!stage1Basename) { + logPre.textContent = "[ERROR] No .ti1 file available. Complete Stage 1 first.\n"; + logContainer.classList.remove("hidden"); + return; + } + + logPre.textContent = ""; + logContainer.classList.remove("hidden"); + tiffGallery.classList.add("hidden"); + galleryGrid.innerHTML = ""; + btnCreateLayout.disabled = true; + + // Determine page size + let pageSize = pageSizeSelect.value; + if (pageSize === "custom") { + const w = parseInt(customPageW.value, 10); + const h = parseInt(customPageH.value, 10); + if (!w || !h || w < 50 || h < 50) { + logPre.textContent = "[ERROR] Custom page size must have width and height ≥ 50mm.\n"; + btnCreateLayout.disabled = false; + return; + } + pageSize = `${w}x${h}`; + } + + // Determine bit depth + let bitDepth = 8; + bitDepthRadios.forEach(radio => { + if (radio.checked) bitDepth = parseInt(radio.value, 10); + }); + + const config = { + instrument: instrumentSelect.value, + page_size: pageSize, + bit_depth: bitDepth, + dpi: parseInt(tiffDpi.value, 10), + basename: stage1Basename, + cwd: stage1Cwd, + }; + + const processId = `printtarg_${stage1Basename}`; + + // Accumulate all stdout lines to extract JSON manifest at the end + let stdoutAccumulator = ""; + + try { + const unlistenStdout = await listen("process:stdout", (event) => { + if (event.payload.id === processId && event.payload.line) { + stdoutAccumulator += event.payload.line + "\n"; + logPre.textContent += event.payload.line + "\n"; + logPre.scrollTop = logPre.scrollHeight; + } + }); + + const unlistenStderr = await listen("process:stderr", (event) => { + if (event.payload.id === processId && event.payload.line) { + logPre.textContent += "ERR: " + event.payload.line + "\n"; + logPre.scrollTop = logPre.scrollHeight; + } + }); + + const unlistenExit = await listen("process:exit", (event) => { + if (event.payload.id === processId) { + unlistenStdout(); + unlistenStderr(); + unlistenExit(); + + if (event.payload.code === 0) { + logPre.textContent += "\n[SUCCESS] printtarg completed successfully.\n"; + btnCreateLayout.disabled = false; + + // Parse the JSON manifest from stdout + const manifest = extractManifest(stdoutAccumulator); + if (manifest && manifest.pages && manifest.pages.length > 0) { + renderTiffGallery(manifest, config.cwd); + } + + advanceToStage3(); + } else { + logPre.textContent += `\n[ERROR] printtarg exited with code ${event.payload.code}.\n`; + btnCreateLayout.disabled = false; + } + } + }); + + logPre.textContent = "Starting printtarg...\n"; + await invoke("run_printtarg", { config }); + + } catch (err) { + logPre.textContent += `\n[INVOKE ERROR] ${err}\n`; + btnCreateLayout.disabled = false; + } + }); + + /** + * Extract the JSON manifest object from the accumulated stdout. + * The manifest is emitted by printtarg -u as a JSON block starting with { and ending with }. + * It always appears at the end of stdout, after all "Creating file..." lines. + */ + function extractManifest(stdout) { + try { + // Find the last JSON object in the output + const jsonStart = stdout.lastIndexOf('{\n "event": "manifest"'); + if (jsonStart === -1) return null; + const jsonEnd = stdout.indexOf('\n}', jsonStart); + if (jsonEnd === -1) return null; + const jsonStr = stdout.substring(jsonStart, jsonEnd + 2); + return JSON.parse(jsonStr); + } catch (e) { + console.error("Failed to parse printtarg manifest:", e); + return null; + } + } + + /** + * Render TIFF preview gallery using base64-encoded images from the backend. + * @param {Object} manifest - The parsed JSON manifest from printtarg -u + * @param {string} cwd - The working directory where TIFFs were generated + */ + async function renderTiffGallery(manifest, cwd) { + tiffGallery.classList.remove("hidden"); + + const pageCount = manifest.pages.length; + const totalPatches = manifest.pages.reduce((sum, p) => sum + p.patches, 0); + const dims = manifest.pages[0]; + galleryInfo.textContent = `${pageCount} page(s) · ${totalPatches} patches · ${dims.width_mm} × ${dims.height_mm} mm per page`; + + for (const page of manifest.pages) { + const sep = cwd.includes('\\') ? '\\' : '/'; + const filePath = cwd ? `${cwd}${sep}${page.filename}` : page.filename; + + const card = document.createElement("div"); + card.className = "gallery-card"; + + const label = document.createElement("div"); + label.className = "gallery-label"; + label.textContent = `${page.filename} (${page.patches} patches)`; + card.appendChild(label); + + try { + const base64Data = await invoke("read_file_base64", { path: filePath }); + const img = document.createElement("img"); + img.src = `data:image/tiff;base64,${base64Data}`; + img.alt = page.filename; + // TIFF may not render natively in all browsers — provide a fallback + img.onerror = () => { + img.remove(); + const fallback = document.createElement("div"); + fallback.className = "gallery-fallback"; + fallback.innerHTML = `📄${page.filename}${page.patches} patches`; + card.insertBefore(fallback, label.nextSibling); + }; + card.appendChild(img); + } catch (err) { + const fallback = document.createElement("div"); + fallback.className = "gallery-fallback"; + fallback.innerHTML = `📄${page.filename}Preview unavailable`; + card.appendChild(fallback); + } + + galleryGrid.appendChild(card); + } + } +} + +function advanceToStage3() { + const steps = document.querySelectorAll('.step'); + const stages = document.querySelectorAll('.stage'); + + steps.forEach(s => s.classList.remove('active')); + if (steps[2]) steps[2].classList.add('active'); + + stages.forEach(s => { + s.classList.remove('active'); + s.classList.add('hidden'); + }); + if (stages[2]) { + stages[2].classList.remove('hidden'); + stages[2].classList.add('active'); + } +} diff --git a/src/js/targen.js b/src/js/targen.js index 0b854e1..a6917cb 100644 --- a/src/js/targen.js +++ b/src/js/targen.js @@ -1,6 +1,7 @@ const { invoke } = window.__TAURI__.core; const { listen } = window.__TAURI__.event; const { save } = window.__TAURI__.dialog; +import { setStage1Result } from './printtarg.js'; export function initTargen() { const colourSpaceRadios = document.querySelectorAll('input[name="colourSpace"]'); @@ -131,6 +132,7 @@ export function initTargen() { // In a real app we'd dispatch an event to advance the stepper here. // For now, we'll manually unlock stage 2 in the state. btnGenerate.disabled = false; + setStage1Result(basename, currentWorkingDir); advanceToStage2(); } else { logPre.textContent += `\n[ERROR] Targen exited with code ${event.payload.code}.\n`; diff --git a/src/styles/main.css b/src/styles/main.css index b4b182b..82f9bde 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -225,7 +225,8 @@ button:disabled { font-size: 0.9em; } -#targenLog { +#targenLog, +#printtargLog { margin: 0; font-family: monospace; font-size: 0.9em; @@ -234,3 +235,109 @@ button:disabled { overflow-y: auto; white-space: pre-wrap; } + +/* ======================================== + Stage 2: Warning Banner + ======================================== */ +.warning-banner { + display: flex; + align-items: flex-start; + gap: 12px; + background: rgba(255, 152, 0, 0.12); + border: 1px solid rgba(255, 152, 0, 0.4); + border-left: 4px solid #ff9800; + border-radius: 6px; + padding: 16px; + margin-bottom: 20px; +} + +.warning-icon { + font-size: 1.5rem; + flex-shrink: 0; + line-height: 1; +} + +.warning-text strong { + display: block; + color: #ffb74d; + margin-bottom: 6px; +} + +.warning-text p { + margin: 0; + font-size: 0.9em; + color: #ccc; + line-height: 1.5; +} + +/* ======================================== + Stage 2: TIFF Gallery + ======================================== */ +.tiff-gallery { + margin-top: 24px; +} + +.tiff-gallery h3 { + margin: 0 0 8px 0; + color: #fff; +} + +.gallery-info { + font-size: 0.9em; + color: #888; + margin-bottom: 16px; +} + +.gallery-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 16px; +} + +.gallery-card { + background: var(--panel-color); + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: hidden; + transition: border-color 0.2s; +} + +.gallery-card:hover { + border-color: var(--accent-color); +} + +.gallery-card img { + width: 100%; + height: auto; + display: block; + background: #fff; +} + +.gallery-label { + padding: 10px 12px; + font-size: 0.85em; + color: #aaa; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.gallery-fallback { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 30px 16px; + background: var(--bg-color); + gap: 8px; +} + +.gallery-fallback .file-icon { + font-size: 2.5rem; +} + +.gallery-fallback .file-size { + font-size: 0.8em; + color: #666; +} + -- 2.39.5 From cd602d2bbfbbee2568a3571fa4522ac7ce87124c Mon Sep 17 00:00:00 2001 From: gronod Date: Sat, 22 Aug 2026 17:56:29 +0100 Subject: [PATCH 2/5] feat: Stage 3 - chartread -u subprocess state machine & swatch grid visualization --- src-tauri/argyll/linux-x86_64/chartread.mock | 30 +++ src-tauri/src/commands.rs | 43 ++++ src-tauri/src/events.rs | 13 ++ src-tauri/src/lib.rs | 1 + src-tauri/src/process_manager.rs | 8 +- src/index.html | 37 ++- src/js/app.js | 4 + src/js/chartread.js | 233 +++++++++++++++++++ src/js/color_convert.js | 67 ++++++ src/js/delta_e.js | 86 +++++++ src/js/printtarg.js | 2 + src/js/swatch_grid.js | 127 ++++++++++ src/styles/main.css | 158 +++++++++++++ 13 files changed, 806 insertions(+), 3 deletions(-) create mode 100755 src-tauri/argyll/linux-x86_64/chartread.mock create mode 100644 src/js/chartread.js create mode 100644 src/js/color_convert.js create mode 100644 src/js/delta_e.js create mode 100644 src/js/swatch_grid.js diff --git a/src-tauri/argyll/linux-x86_64/chartread.mock b/src-tauri/argyll/linux-x86_64/chartread.mock new file mode 100755 index 0000000..b05aada --- /dev/null +++ b/src-tauri/argyll/linux-x86_64/chartread.mock @@ -0,0 +1,30 @@ +#!/bin/bash +# Mock script for chartread -u +# This script simulates the behaviour of chartread for testing purposes. + +echo "Place instrument on calibration tile and hit [Space] to calibrate." + +# We don't really wait for input, just wait 1 second +sleep 1 +echo "Calibration successful." +echo "Hit [Space] to read strip A (or 's' to skip)." + +sleep 1 +echo "Reading strip A..." + +# Emit mock JSON for strip A +cat << 'EOF' +ROW_COLORS_JSON: {"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expected": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0]}]} +EOF + +echo "Hit [Space] to read strip B (or 's' to skip)." +sleep 1 +echo "Reading strip B..." + +# Emit mock JSON for strip B +cat << 'EOF' +ROW_COLORS_JSON: {"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2, "patch_count": 2, "patches": [{"id": "4", "loc": "B1", "is_pad": false, "device": [100.0, 0.0, 0.0], "expected": {"Lab": [40.0, 40.0, 40.0]}, "measured": {"Lab": [38.0, 41.0, 39.0]}}, {"id": "5", "loc": "B2", "is_pad": false, "device": [0.0, 100.0, 0.0], "expected": {"Lab": [80.0, -50.0, 50.0]}, "measured": {"Lab": [79.0, -49.0, 51.0]}}]} +EOF + +echo "Ready to read... done." +exit 0 diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index b397e59..635f18b 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -171,6 +171,39 @@ pub async fn read_file_base64(path: String) -> Result { Ok(base64::engine::general_purpose::STANDARD.encode(&bytes)) } +#[derive(Debug, Deserialize, Serialize)] +pub struct ChartreadConfig { + pub basename: String, + pub cwd: String, +} + +pub fn build_chartread_args(config: &ChartreadConfig) -> Vec { + vec![ + "-v".to_string(), + "-u".to_string(), + config.basename.clone(), + ] +} + +#[tauri::command] +pub async fn run_chartread( + app: AppHandle, + state: State<'_, ProcessManager>, + config: ChartreadConfig, +) -> Result<(), String> { + let binary = resolve_binary(app.clone(), "chartread".to_string()).await?; + let args = build_chartread_args(&config); + let id = format!("chartread_{}", config.basename); + + let cwd = if config.cwd.trim().is_empty() { + None + } else { + Some(config.cwd.clone()) + }; + + state.spawn(app, id, binary, args, cwd).await +} + #[cfg(test)] mod tests { use super::*; @@ -244,4 +277,14 @@ mod tests { let args = build_printtarg_args(&config); assert_eq!(args, vec!["-v", "-u", "-i", "SS", "-p", "200x400", "-t", "150", "custom_target"]); } + + #[test] + fn test_build_chartread_args() { + let config = ChartreadConfig { + basename: "my_profile".to_string(), + cwd: "/home/user".to_string(), + }; + let args = build_chartread_args(&config); + assert_eq!(args, vec!["-v", "-u", "my_profile"]); + } } diff --git a/src-tauri/src/events.rs b/src-tauri/src/events.rs index cd1292f..9e665b7 100644 --- a/src-tauri/src/events.rs +++ b/src-tauri/src/events.rs @@ -45,3 +45,16 @@ pub fn emit_error(app: &AppHandle, id: &str, error: String) { error: Some(error), }); } + +#[derive(Clone, Serialize)] +pub struct JsonRowPayload { + pub id: String, + pub json: String, +} + +pub fn emit_json_row(app: &AppHandle, id: &str, json: String) { + let _ = app.emit("process:json_row", JsonRowPayload { + id: id.to_string(), + json, + }); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2c63096..161fe3e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -17,6 +17,7 @@ pub fn run() { commands::run_targen, commands::run_printtarg, commands::read_file_base64, + commands::run_chartread, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/process_manager.rs b/src-tauri/src/process_manager.rs index 2c8ae27..eb8d715 100644 --- a/src-tauri/src/process_manager.rs +++ b/src-tauri/src/process_manager.rs @@ -38,9 +38,15 @@ impl ProcessManager { let id_clone = id.clone(); let app_clone = app.clone(); tokio::spawn(async move { + const JSON_ROW_PREFIX: &str = "ROW_COLORS_JSON: "; let mut reader = BufReader::new(stdout).lines(); while let Ok(Some(line)) = reader.next_line().await { - emit_stdout(&app_clone, &id_clone, line); + if line.starts_with(JSON_ROW_PREFIX) { + let json_str = line[JSON_ROW_PREFIX.len()..].to_string(); + crate::events::emit_json_row(&app_clone, &id_clone, json_str); + } else { + emit_stdout(&app_clone, &id_clone, line); + } } }); diff --git a/src/index.html b/src/index.html index 3956978..410a8c8 100644 --- a/src/index.html +++ b/src/index.html @@ -177,9 +177,42 @@ diff --git a/src/js/app.js b/src/js/app.js index 82723e6..3641fd2 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -1,5 +1,6 @@ import { initTargen } from './targen.js'; import { initPrinttarg } from './printtarg.js'; +import { initChartread } from './chartread.js'; const { invoke } = window.__TAURI__.core; @@ -31,4 +32,7 @@ document.addEventListener('DOMContentLoaded', () => { // Initialize Stage 2 initPrinttarg(); + + // Initialize Stage 3 + initChartread(); }); diff --git a/src/js/chartread.js b/src/js/chartread.js new file mode 100644 index 0000000..8af8e1b --- /dev/null +++ b/src/js/chartread.js @@ -0,0 +1,233 @@ +const { invoke } = window.__TAURI__.core; +const { listen } = window.__TAURI__.event; +import { startSwatchListener, stopSwatchListener } from './swatch_grid.js'; + +// Module-level state: set by Stage 2 when it completes +let stage2Basename = ""; +let stage2Cwd = ""; + +/** + * Called by printtarg.js after Stage 2 completes. + */ +export function setStage2Result(basename, cwd) { + stage2Basename = basename; + stage2Cwd = cwd; +} + +// State machine states +const STATE = { + IDLE: "IDLE", + CALIBRATING: "CALIBRATING", + AWAITING_STRIP: "AWAITING_STRIP", + READING: "READING", + ERROR: "ERROR", + FINISHED: "FINISHED", +}; + +let currentState = STATE.IDLE; +let currentProcessId = ""; + +export function initChartread() { + const btnStartRead = document.getElementById("btnStartRead"); + const btnCalibrate = document.getElementById("btnCalibrate"); + const btnRetry = document.getElementById("btnRetry"); + const btnSkip = document.getElementById("btnSkip"); + const btnCancel = document.getElementById("btnCancel"); + const promptText = document.getElementById("chartreadPrompt"); + const stateLabel = document.getElementById("chartreadState"); + const logContainer = document.getElementById("chartreadLogContainer"); + const logPre = document.getElementById("chartreadLog"); + + function setState(newState) { + currentState = newState; + if (stateLabel) stateLabel.textContent = newState; + + // Show/hide buttons based on state + btnCalibrate.classList.add("hidden"); + btnRetry.classList.add("hidden"); + btnSkip.classList.add("hidden"); + btnCancel.classList.add("hidden"); + btnStartRead.classList.add("hidden"); + + switch (newState) { + case STATE.IDLE: + btnStartRead.classList.remove("hidden"); + break; + case STATE.CALIBRATING: + btnCalibrate.classList.remove("hidden"); + btnCancel.classList.remove("hidden"); + break; + case STATE.AWAITING_STRIP: + btnRetry.classList.remove("hidden"); + btnSkip.classList.remove("hidden"); + btnCancel.classList.remove("hidden"); + break; + case STATE.READING: + btnCancel.classList.remove("hidden"); + break; + case STATE.ERROR: + btnRetry.classList.remove("hidden"); + btnSkip.classList.remove("hidden"); + btnCancel.classList.remove("hidden"); + break; + case STATE.FINISHED: + btnStartRead.classList.remove("hidden"); + break; + } + } + + function setPrompt(text) { + if (promptText) promptText.textContent = text; + } + + // Start reading button + if (btnStartRead) { + btnStartRead.addEventListener("click", async () => { + if (!stage2Basename) { + setPrompt("Error: No .ti2 file available. Complete Stage 2 first."); + return; + } + + logPre.textContent = ""; + logContainer.classList.remove("hidden"); + setState(STATE.CALIBRATING); + setPrompt("Starting chartread... waiting for instrument calibration prompt."); + + const config = { + basename: stage2Basename, + cwd: stage2Cwd, + }; + + currentProcessId = `chartread_${stage2Basename}`; + + // Start swatch grid listener + await startSwatchListener(currentProcessId); + + try { + const unlistenStdout = await listen("process:stdout", (event) => { + if (event.payload.id !== currentProcessId || !event.payload.line) return; + const line = event.payload.line; + + logPre.textContent += line + "\n"; + logPre.scrollTop = logPre.scrollHeight; + + // Parse prompts for state transitions + const lineLower = line.toLowerCase(); + + if (lineLower.includes("calibrat") && lineLower.includes("place")) { + setState(STATE.CALIBRATING); + setPrompt(line); + } else if (lineLower.includes("hit") && lineLower.includes("read") && lineLower.includes("strip")) { + setState(STATE.AWAITING_STRIP); + setPrompt(line); + } else if (lineLower.includes("ready to read")) { + setState(STATE.AWAITING_STRIP); + setPrompt(line); + } else if (lineLower.includes("reading strip") || lineLower.includes("processing")) { + setState(STATE.READING); + setPrompt(line); + } else if (lineLower.includes("error") || lineLower.includes("too fast") || lineLower.includes("too slow") || lineLower.includes("misread")) { + setState(STATE.ERROR); + setPrompt("⚠️ " + line); + } + }); + + const unlistenStderr = await listen("process:stderr", (event) => { + if (event.payload.id === currentProcessId && event.payload.line) { + logPre.textContent += "ERR: " + event.payload.line + "\n"; + logPre.scrollTop = logPre.scrollHeight; + } + }); + + const unlistenExit = await listen("process:exit", (event) => { + if (event.payload.id !== currentProcessId) return; + unlistenStdout(); + unlistenStderr(); + unlistenExit(); + stopSwatchListener(); + + if (event.payload.code === 0) { + setState(STATE.FINISHED); + setPrompt("✅ Measurement complete! .ti3 file has been saved."); + logPre.textContent += "\n[SUCCESS] chartread completed. .ti3 file written.\n"; + advanceToStage4(); + } else { + setState(STATE.FINISHED); + setPrompt(`❌ chartread exited with code ${event.payload.code}.`); + logPre.textContent += `\n[ERROR] chartread exited with code ${event.payload.code}.\n`; + } + }); + + await invoke("run_chartread", { config }); + + } catch (err) { + setPrompt(`Invoke error: ${err}`); + setState(STATE.IDLE); + } + }); + } + + // Calibrate / confirm button — sends space + newline + if (btnCalibrate) { + btnCalibrate.addEventListener("click", async () => { + try { + await invoke("send_stdin", { id: currentProcessId, input: " \n" }); + } catch (e) { console.error("send_stdin error:", e); } + }); + } + + // Retry button — sends space + newline (same as confirm) + if (btnRetry) { + btnRetry.addEventListener("click", async () => { + try { + await invoke("send_stdin", { id: currentProcessId, input: " \n" }); + setState(STATE.READING); + setPrompt("Retrying strip read..."); + } catch (e) { console.error("send_stdin error:", e); } + }); + } + + // Skip button — sends "s\n" + if (btnSkip) { + btnSkip.addEventListener("click", async () => { + try { + await invoke("send_stdin", { id: currentProcessId, input: "s\n" }); + setState(STATE.AWAITING_STRIP); + setPrompt("Skipped current strip. Awaiting next..."); + } catch (e) { console.error("send_stdin error:", e); } + }); + } + + // Cancel button — kills the process + if (btnCancel) { + btnCancel.addEventListener("click", async () => { + try { + await invoke("kill_process", { id: currentProcessId }); + stopSwatchListener(); + setState(STATE.IDLE); + setPrompt("Measurement cancelled."); + } catch (e) { console.error("kill_process error:", e); } + }); + } + + // Start in IDLE state + setState(STATE.IDLE); + setPrompt("Press 'Start Measurement' to begin reading the printed target."); +} + +function advanceToStage4() { + const steps = document.querySelectorAll('.step'); + const stages = document.querySelectorAll('.stage'); + + steps.forEach(s => s.classList.remove('active')); + if (steps[3]) steps[3].classList.add('active'); + + stages.forEach(s => { + s.classList.remove('active'); + s.classList.add('hidden'); + }); + if (stages[3]) { + stages[3].classList.remove('hidden'); + stages[3].classList.add('active'); + } +} diff --git a/src/js/color_convert.js b/src/js/color_convert.js new file mode 100644 index 0000000..8428e78 --- /dev/null +++ b/src/js/color_convert.js @@ -0,0 +1,67 @@ +/** + * Convert CIE L*a*b* to sRGB [0–255] clamped values. + * Uses D50 illuminant reference white (standard for ICC profiles). + * @param {number} L - Lightness [0, 100] + * @param {number} a - Green-red axis [-128, 127] + * @param {number} b - Blue-yellow axis [-128, 127] + * @returns {number[]} [r, g, b] each in [0, 255] + */ +export function labToSrgb(L, a, b) { + // D50 reference white + const Xn = 0.9642; + const Yn = 1.0; + const Zn = 0.8249; + + // Lab → XYZ + const fy = (L + 16) / 116; + const fx = a / 500 + fy; + const fz = fy - b / 200; + + const delta = 6 / 29; + const delta3 = delta * delta * delta; + + const X = Xn * (fx > delta ? fx * fx * fx : (fx - 16 / 116) * 3 * delta * delta); + const Y = Yn * (fy > delta ? fy * fy * fy : (fy - 16 / 116) * 3 * delta * delta); + const Z = Zn * (fz > delta ? fz * fz * fz : (fz - 16 / 116) * 3 * delta * delta); + + // XYZ (D50) → linear sRGB via Bradford-adapted D50→D65 matrix + // Combined D50-adapted XYZ to sRGB matrix + const lr = 3.1338561 * X - 1.6168667 * Y - 0.4906146 * Z; + const lg = -0.9787684 * X + 1.9161415 * Y + 0.0334540 * Z; + const lb = 0.0719453 * X - 0.2289914 * Y + 1.4052427 * Z; + + // Linear sRGB → gamma-corrected sRGB + function gammaCorrect(c) { + return c <= 0.0031308 + ? 12.92 * c + : 1.055 * Math.pow(c, 1 / 2.4) - 0.055; + } + + const r = Math.round(Math.max(0, Math.min(255, gammaCorrect(lr) * 255))); + const g = Math.round(Math.max(0, Math.min(255, gammaCorrect(lg) * 255))); + const bVal = Math.round(Math.max(0, Math.min(255, gammaCorrect(lb) * 255))); + + return [r, g, bVal]; +} + +/** + * Convert device RGB percentages [0–100] to CSS rgb string. + * @param {number[]} device - [R%, G%, B%] each in [0, 100] + * @returns {string} CSS rgb() string + */ +export function deviceRgbToCss(device) { + const r = Math.round((device[0] / 100) * 255); + const g = Math.round((device[1] / 100) * 255); + const b = Math.round((device[2] / 100) * 255); + return `rgb(${r}, ${g}, ${b})`; +} + +/** + * Convert Lab triplet to CSS rgb string. + * @param {number[]} lab - [L, a, b] + * @returns {string} CSS rgb() string + */ +export function labToCss(lab) { + const [r, g, b] = labToSrgb(lab[0], lab[1], lab[2]); + return `rgb(${r}, ${g}, ${b})`; +} diff --git a/src/js/delta_e.js b/src/js/delta_e.js new file mode 100644 index 0000000..aef3749 --- /dev/null +++ b/src/js/delta_e.js @@ -0,0 +1,86 @@ +/** + * Compute CIEDE2000 colour difference (ΔE₀₀) between two L*a*b* values. + * Standard parametric factors: kL=1, kC=1, kH=1. + * + * Reference: Sharma, Wu, Dalal (2005) "The CIEDE2000 Color-Difference Formula" + * + * @param {number[]} lab1 - [L1, a1, b1] + * @param {number[]} lab2 - [L2, a2, b2] + * @returns {number} ΔE₀₀ value + */ +export function computeDeltaE00(lab1, lab2) { + const [L1, a1, b1] = lab1; + const [L2, a2, b2] = lab2; + + const kL = 1, kC = 1, kH = 1; + + const C1ab = Math.sqrt(a1 * a1 + b1 * b1); + const C2ab = Math.sqrt(a2 * a2 + b2 * b2); + const Cab_avg = (C1ab + C2ab) / 2; + + const Cab_avg7 = Math.pow(Cab_avg, 7); + const G = 0.5 * (1 - Math.sqrt(Cab_avg7 / (Cab_avg7 + Math.pow(25, 7)))); + + const a1p = a1 * (1 + G); + const a2p = a2 * (1 + G); + + const C1p = Math.sqrt(a1p * a1p + b1 * b1); + const C2p = Math.sqrt(a2p * a2p + b2 * b2); + + let h1p = Math.atan2(b1, a1p) * (180 / Math.PI); + if (h1p < 0) h1p += 360; + let h2p = Math.atan2(b2, a2p) * (180 / Math.PI); + if (h2p < 0) h2p += 360; + + const dLp = L2 - L1; + const dCp = C2p - C1p; + + let dhp; + if (C1p * C2p === 0) { + dhp = 0; + } else if (Math.abs(h2p - h1p) <= 180) { + dhp = h2p - h1p; + } else if (h2p - h1p > 180) { + dhp = h2p - h1p - 360; + } else { + dhp = h2p - h1p + 360; + } + const dHp = 2 * Math.sqrt(C1p * C2p) * Math.sin((dhp * Math.PI / 180) / 2); + + const Lp_avg = (L1 + L2) / 2; + const Cp_avg = (C1p + C2p) / 2; + + let hp_avg; + if (C1p * C2p === 0) { + hp_avg = h1p + h2p; + } else if (Math.abs(h1p - h2p) <= 180) { + hp_avg = (h1p + h2p) / 2; + } else if (h1p + h2p < 360) { + hp_avg = (h1p + h2p + 360) / 2; + } else { + hp_avg = (h1p + h2p - 360) / 2; + } + + const T = 1 + - 0.17 * Math.cos((hp_avg - 30) * Math.PI / 180) + + 0.24 * Math.cos(2 * hp_avg * Math.PI / 180) + + 0.32 * Math.cos((3 * hp_avg + 6) * Math.PI / 180) + - 0.20 * Math.cos((4 * hp_avg - 63) * Math.PI / 180); + + const SL = 1 + (0.015 * Math.pow(Lp_avg - 50, 2)) / Math.sqrt(20 + Math.pow(Lp_avg - 50, 2)); + const SC = 1 + 0.045 * Cp_avg; + const SH = 1 + 0.015 * Cp_avg * T; + + const Cp_avg7 = Math.pow(Cp_avg, 7); + const RT_term = -2 * Math.sqrt(Cp_avg7 / (Cp_avg7 + Math.pow(25, 7))) + * Math.sin(60 * Math.exp(-Math.pow((hp_avg - 275) / 25, 2)) * Math.PI / 180); + + const dE = Math.sqrt( + Math.pow(dLp / (kL * SL), 2) + + Math.pow(dCp / (kC * SC), 2) + + Math.pow(dHp / (kH * SH), 2) + + RT_term * (dCp / (kC * SC)) * (dHp / (kH * SH)) + ); + + return dE; +} diff --git a/src/js/printtarg.js b/src/js/printtarg.js index da8d6db..b940dc8 100644 --- a/src/js/printtarg.js +++ b/src/js/printtarg.js @@ -1,5 +1,6 @@ const { invoke } = window.__TAURI__.core; const { listen } = window.__TAURI__.event; +import { setStage2Result } from './chartread.js'; // Module-level state: set by Stage 1 when it completes let stage1Basename = ""; @@ -118,6 +119,7 @@ export function initPrinttarg() { renderTiffGallery(manifest, config.cwd); } + setStage2Result(stage1Basename, stage1Cwd); advanceToStage3(); } else { logPre.textContent += `\n[ERROR] printtarg exited with code ${event.payload.code}.\n`; diff --git a/src/js/swatch_grid.js b/src/js/swatch_grid.js new file mode 100644 index 0000000..8856dfc --- /dev/null +++ b/src/js/swatch_grid.js @@ -0,0 +1,127 @@ +import { computeDeltaE00 } from './delta_e.js'; +import { labToCss, deviceRgbToCss } from './color_convert.js'; + +const { listen } = window.__TAURI__.event; + +let unlistenJsonRow = null; + +/** + * Start listening for row events from a chartread process. + * @param {string} processId - The process ID (e.g. "chartread_my_profile") + */ +export async function startSwatchListener(processId) { + const grid = document.getElementById("swatchGrid"); + const progressBar = document.getElementById("readProgress"); + const progressText = document.getElementById("readProgressText"); + const statsPanel = document.getElementById("readStats"); + + // Clear previous state + grid.innerHTML = ""; + let totalPatches = 0; + let totalDeltaE = 0; + let patchCount = 0; + let maxDeltaE = 0; + + unlistenJsonRow = await listen("process:json_row", (event) => { + if (event.payload.id !== processId) return; + + let data; + try { + data = JSON.parse(event.payload.json); + } catch (e) { + console.error("Failed to parse json_row:", e); + return; + } + + if (data.event !== "row_complete") return; + + // Update progress bar + const progress = ((data.row_index + 1) / data.total_rows) * 100; + if (progressBar) progressBar.style.width = `${progress}%`; + if (progressText) progressText.textContent = `Strip ${data.row_id} — ${data.row_index + 1} / ${data.total_rows}`; + + // Create row container + const rowEl = document.createElement("div"); + rowEl.className = "swatch-row"; + + const rowLabel = document.createElement("div"); + rowLabel.className = "swatch-row-label"; + rowLabel.textContent = data.row_id; + rowEl.appendChild(rowLabel); + + const rowPatches = document.createElement("div"); + rowPatches.className = "swatch-row-patches"; + + for (const patch of data.patches) { + if (patch.is_pad) continue; // Skip spacer patches + + const patchEl = document.createElement("div"); + patchEl.className = "swatch-patch"; + + // Determine the display colour + let bgColor; + if (patch.measured && patch.measured.Lab) { + bgColor = labToCss(patch.measured.Lab); + } else if (patch.device && patch.device.length === 3) { + bgColor = deviceRgbToCss(patch.device); + } else { + bgColor = "#888"; + } + + const swatch = document.createElement("div"); + swatch.className = "swatch-color"; + swatch.style.backgroundColor = bgColor; + patchEl.appendChild(swatch); + + // Compute and display ΔE₀₀ if both expected and measured Lab are present + if (patch.expected && patch.expected.Lab && patch.measured && patch.measured.Lab) { + const deltaE = computeDeltaE00(patch.expected.Lab, patch.measured.Lab); + + const deLabel = document.createElement("div"); + deLabel.className = "swatch-de"; + deLabel.textContent = deltaE.toFixed(1); + + // Traffic light classification + if (deltaE < 2) { + patchEl.classList.add("de-good"); // Green + } else if (deltaE < 5) { + patchEl.classList.add("de-warning"); // Amber + } else { + patchEl.classList.add("de-bad"); // Red + } + + patchEl.appendChild(deLabel); + + // Accumulate stats + totalDeltaE += deltaE; + patchCount++; + if (deltaE > maxDeltaE) maxDeltaE = deltaE; + } + + patchEl.title = `${patch.loc} (ID: ${patch.id})`; + rowPatches.appendChild(patchEl); + } + + rowEl.appendChild(rowPatches); + grid.appendChild(rowEl); + + // Scroll to bottom + grid.scrollTop = grid.scrollHeight; + + // Update stats + if (statsPanel && patchCount > 0) { + const avgDe = (totalDeltaE / patchCount).toFixed(2); + statsPanel.textContent = `Avg ΔE₀₀: ${avgDe} · Max ΔE₀₀: ${maxDeltaE.toFixed(2)} · Patches: ${patchCount}`; + } + }); +} + +/** + * Stop listening for row events. + */ +export function stopSwatchListener() { + if (unlistenJsonRow) { + unlistenJsonRow(); + unlistenJsonRow = null; + } +} diff --git a/src/styles/main.css b/src/styles/main.css index 82f9bde..e70398e 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -341,3 +341,161 @@ button:disabled { color: #666; } +/* ======================================== + Stage 3: chartread State Machine UI + ======================================== */ +.chartread-status { + background: var(--panel-color); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 16px; + margin-bottom: 16px; +} + +.status-label { + font-size: 0.85em; + color: #888; + margin-bottom: 8px; +} + +.status-label span { + color: var(--accent-color); + font-weight: bold; + text-transform: uppercase; +} + +.status-prompt { + font-size: 1.1em; + color: #fff; + line-height: 1.4; + min-height: 2.8em; +} + +.chartread-actions { + display: flex; + gap: 10px; + margin-bottom: 16px; + flex-wrap: wrap; +} + +button.danger { + background-color: #c62828; + color: white; + border: none; + padding: 10px 20px; + font-size: 1rem; + border-radius: 4px; + cursor: pointer; +} + +button.danger:hover { + background-color: #e53935; +} + +/* Progress Bar */ +.read-progress-container { + width: 100%; + height: 8px; + background: var(--bg-color); + border-radius: 4px; + overflow: hidden; + margin-bottom: 6px; +} + +.read-progress-bar { + height: 100%; + background: linear-gradient(90deg, var(--accent-color), #4fc3f7); + border-radius: 4px; + transition: width 0.3s ease; +} + +.read-progress-text { + font-size: 0.85em; + color: #888; + margin-bottom: 12px; +} + +.read-stats { + font-size: 0.9em; + color: #aaa; + margin-bottom: 16px; +} + +/* ======================================== + Stage 3: Swatch Grid + ======================================== */ +.swatch-grid { + max-height: 500px; + overflow-y: auto; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--panel-color); + padding: 12px; + margin-bottom: 16px; +} + +.swatch-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.swatch-row-label { + min-width: 30px; + font-weight: bold; + font-size: 0.85em; + color: #888; + text-align: center; +} + +.swatch-row-patches { + display: flex; + gap: 4px; + flex-wrap: wrap; +} + +.swatch-patch { + width: 36px; + text-align: center; + border: 2px solid transparent; + border-radius: 4px; + overflow: hidden; + transition: border-color 0.2s; +} + +.swatch-color { + width: 100%; + height: 28px; +} + +.swatch-de { + font-size: 0.65em; + padding: 2px 0; + color: #ccc; + background: rgba(0,0,0,0.3); +} + +/* Traffic light ΔE indicators */ +.swatch-patch.de-good { + border-color: #4caf50; +} + +.swatch-patch.de-warning { + border-color: #ff9800; +} + +.swatch-patch.de-bad { + border-color: #f44336; +} + +/* Process log for chartread */ +#chartreadLog { + margin: 0; + font-family: monospace; + font-size: 0.9em; + color: #ccc; + max-height: 200px; + overflow-y: auto; + white-space: pre-wrap; +} -- 2.39.5 From 0cd0e9d82e8383cf3dd2c84ced81b8acbbe2518c Mon Sep 17 00:00:00 2001 From: gronod Date: Sat, 22 Aug 2026 18:04:31 +0100 Subject: [PATCH 3/5] feat: Stage 4 & 5 - Profile calculation (colprof) and automated verification (profcheck) --- src-tauri/argyll/linux-x86_64/colprof.mock | 20 +++ src-tauri/argyll/linux-x86_64/profcheck.mock | 14 ++ src-tauri/src/commands.rs | 116 +++++++++++++++ src-tauri/src/lib.rs | 2 + src/index.html | 96 ++++++++++++- src/js/app.js | 8 ++ src/js/chartread.js | 2 + src/js/colprof.js | 133 +++++++++++++++++ src/js/profcheck.js | 137 ++++++++++++++++++ src/styles/main.css | 143 +++++++++++++++++++ 10 files changed, 666 insertions(+), 5 deletions(-) create mode 100755 src-tauri/argyll/linux-x86_64/colprof.mock create mode 100755 src-tauri/argyll/linux-x86_64/profcheck.mock create mode 100644 src/js/colprof.js create mode 100644 src/js/profcheck.js diff --git a/src-tauri/argyll/linux-x86_64/colprof.mock b/src-tauri/argyll/linux-x86_64/colprof.mock new file mode 100755 index 0000000..8bbd22c --- /dev/null +++ b/src-tauri/argyll/linux-x86_64/colprof.mock @@ -0,0 +1,20 @@ +#!/bin/bash +# Mock script for colprof +# Simulates colprof execution and outputs progress log + +basename="$1" +# Find last argument if -D or other flags are used +for arg in "$@"; do + basename="$arg" +done + +echo "colprof: Starting profile calculation for $basename" +sleep 1 +echo "Gamut mapping calculation..." +sleep 1 +echo "Fitting cLUT grid points..." +sleep 1 +echo "Writing ICC profile $basename.icc..." +touch "$basename.icc" +echo "Done." +exit 0 diff --git a/src-tauri/argyll/linux-x86_64/profcheck.mock b/src-tauri/argyll/linux-x86_64/profcheck.mock new file mode 100755 index 0000000..78da1f3 --- /dev/null +++ b/src-tauri/argyll/linux-x86_64/profcheck.mock @@ -0,0 +1,14 @@ +#!/bin/bash +# Mock script for profcheck +# Simulates profcheck verification output + +echo "profcheck: Checking profile accuracy..." +sleep 1 +cat << 'EOF' +{"event": "profcheck_complete", "avg_de": 0.85, "max_de": 2.41, "rms_de": 1.02} +EOF +echo "Summary:" +echo " avg. dE = 0.85" +echo " max. dE = 2.41" +echo " rms. dE = 1.02" +exit 0 diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 635f18b..8a22ec2 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -204,6 +204,94 @@ pub async fn run_chartread( state.spawn(app, id, binary, args, cwd).await } +#[derive(Debug, Deserialize, Serialize)] +pub struct ColprofConfig { + pub quality: String, + pub algorithm: String, + pub description: String, + pub copyright: Option, + pub basename: String, + pub cwd: String, +} + +pub fn build_colprof_args(config: &ColprofConfig) -> Vec { + let mut args = vec![ + "-v".to_string(), + "-u".to_string(), + "-q".to_string(), + config.quality.clone(), + "-a".to_string(), + config.algorithm.clone(), + "-D".to_string(), + config.description.clone(), + ]; + + if let Some(copyright) = &config.copyright { + if !copyright.trim().is_empty() { + args.push("-C".to_string()); + args.push(copyright.clone()); + } + } + + args.push(config.basename.clone()); + args +} + +#[tauri::command] +pub async fn run_colprof( + app: AppHandle, + state: State<'_, ProcessManager>, + config: ColprofConfig, +) -> Result<(), String> { + let binary = resolve_binary(app.clone(), "colprof".to_string()).await?; + let args = build_colprof_args(&config); + let id = format!("colprof_{}", config.basename); + + let cwd = if config.cwd.trim().is_empty() { + None + } else { + Some(config.cwd.clone()) + }; + + state.spawn(app, id, binary, args, cwd).await +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ProfcheckConfig { + pub ti3_path: String, + pub icc_path: String, + pub cwd: String, +} + +pub fn build_profcheck_args(config: &ProfcheckConfig) -> Vec { + vec![ + "-v".to_string(), + "-k".to_string(), + "-u".to_string(), + config.ti3_path.clone(), + config.icc_path.clone(), + ] +} + +#[tauri::command] +pub async fn run_profcheck( + app: AppHandle, + state: State<'_, ProcessManager>, + config: ProfcheckConfig, +) -> Result<(), String> { + let binary = resolve_binary(app.clone(), "profcheck".to_string()).await?; + let args = build_profcheck_args(&config); + let id = format!("profcheck_{}", config.ti3_path); + + let cwd = if config.cwd.trim().is_empty() { + None + } else { + Some(config.cwd.clone()) + }; + + state.spawn(app, id, binary, args, cwd).await +} + #[cfg(test)] mod tests { use super::*; @@ -287,4 +375,32 @@ mod tests { let args = build_chartread_args(&config); assert_eq!(args, vec!["-v", "-u", "my_profile"]); } + + #[test] + fn test_build_colprof_args() { + let config = ColprofConfig { + quality: "h".to_string(), + algorithm: "l".to_string(), + description: "My Profile".to_string(), + copyright: Some("2026 ACME".to_string()), + basename: "my_profile".to_string(), + cwd: "/home/user".to_string(), + }; + let args = build_colprof_args(&config); + assert_eq!( + args, + vec!["-v", "-u", "-q", "h", "-a", "l", "-D", "My Profile", "-C", "2026 ACME", "my_profile"] + ); + } + + #[test] + fn test_build_profcheck_args() { + let config = ProfcheckConfig { + ti3_path: "my_profile.ti3".to_string(), + icc_path: "my_profile.icc".to_string(), + cwd: "/home/user".to_string(), + }; + let args = build_profcheck_args(&config); + assert_eq!(args, vec!["-v", "-k", "-u", "my_profile.ti3", "my_profile.icc"]); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 161fe3e..c0eb57c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -18,6 +18,8 @@ pub fn run() { commands::run_printtarg, commands::read_file_base64, commands::run_chartread, + commands::run_colprof, + commands::run_profcheck, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/index.html b/src/index.html index 410a8c8..065f8c3 100644 --- a/src/index.html +++ b/src/index.html @@ -218,15 +218,101 @@ diff --git a/src/js/app.js b/src/js/app.js index 3641fd2..a107b81 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -1,6 +1,8 @@ import { initTargen } from './targen.js'; import { initPrinttarg } from './printtarg.js'; import { initChartread } from './chartread.js'; +import { initColprof } from './colprof.js'; +import { initProfcheck } from './profcheck.js'; const { invoke } = window.__TAURI__.core; @@ -35,4 +37,10 @@ document.addEventListener('DOMContentLoaded', () => { // Initialize Stage 3 initChartread(); + + // Initialize Stage 4 + initColprof(); + + // Initialize Stage 5 + initProfcheck(); }); diff --git a/src/js/chartread.js b/src/js/chartread.js index 8af8e1b..b6c69f6 100644 --- a/src/js/chartread.js +++ b/src/js/chartread.js @@ -1,6 +1,7 @@ const { invoke } = window.__TAURI__.core; const { listen } = window.__TAURI__.event; import { startSwatchListener, stopSwatchListener } from './swatch_grid.js'; +import { setStage3Result } from './colprof.js'; // Module-level state: set by Stage 2 when it completes let stage2Basename = ""; @@ -150,6 +151,7 @@ export function initChartread() { setState(STATE.FINISHED); setPrompt("✅ Measurement complete! .ti3 file has been saved."); logPre.textContent += "\n[SUCCESS] chartread completed. .ti3 file written.\n"; + setStage3Result(stage2Basename, stage2Cwd); advanceToStage4(); } else { setState(STATE.FINISHED); diff --git a/src/js/colprof.js b/src/js/colprof.js new file mode 100644 index 0000000..2a7f8c0 --- /dev/null +++ b/src/js/colprof.js @@ -0,0 +1,133 @@ +const { invoke } = window.__TAURI__.core; +const { listen } = window.__TAURI__.event; +import { setStage4Result } from './profcheck.js'; + +let chartreadBasename = ""; +let chartreadCwd = ""; + +/** + * Called by chartread.js after Stage 3 completes. + */ +export function setStage3Result(basename, cwd) { + chartreadBasename = basename; + chartreadCwd = cwd; +} + +export function initColprof() { + const qualitySelect = document.getElementById("colprofQuality"); + const algorithmSelect = document.getElementById("colprofAlgorithm"); + const descInput = document.getElementById("colprofDescription"); + const copyrightInput = document.getElementById("colprofCopyright"); + const btnCreateProfile = document.getElementById("btnCreateProfile"); + const spinnerContainer = document.getElementById("colprofSpinnerContainer"); + const stageLabel = document.getElementById("colprofStageLabel"); + const logContainer = document.getElementById("colprofLogContainer"); + const logPre = document.getElementById("colprofLog"); + const successCard = document.getElementById("colprofSuccessCard"); + const successInfo = document.getElementById("colprofSuccessInfo"); + const btnGoToVerify = document.getElementById("btnGoToVerify"); + + if (!btnCreateProfile) return; + + btnCreateProfile.addEventListener("click", async () => { + const basename = chartreadBasename || "test_target"; + const cwd = chartreadCwd || ""; + + const description = descInput.value.trim() || basename; + + logPre.textContent = ""; + logContainer.classList.remove("hidden"); + spinnerContainer.classList.remove("hidden"); + successCard.classList.add("hidden"); + btnCreateProfile.disabled = true; + stageLabel.textContent = "Initializing colprof..."; + + const config = { + quality: qualitySelect.value, + algorithm: algorithmSelect.value, + description: description, + copyright: copyrightInput.value.trim() || null, + basename: basename, + cwd: cwd, + }; + + const processId = `colprof_${basename}`; + + try { + const unlistenStdout = await listen("process:stdout", (event) => { + if (event.payload.id === processId && event.payload.line) { + const line = event.payload.line; + logPre.textContent += line + "\n"; + logPre.scrollTop = logPre.scrollHeight; + + // Parse coarse progress stages from stdout + const lineLower = line.toLowerCase(); + if (lineLower.includes("gamut mapping")) { + stageLabel.textContent = "Gamut mapping calculation in progress..."; + } else if (lineLower.includes("fitting") || lineLower.includes("clut")) { + stageLabel.textContent = "Fitting cLUT grid points..."; + } else if (lineLower.includes("writing") || lineLower.includes("icc profile")) { + stageLabel.textContent = "Writing ICC profile header & tags..."; + } + } + }); + + const unlistenStderr = await listen("process:stderr", (event) => { + if (event.payload.id === processId && event.payload.line) { + logPre.textContent += "ERR: " + event.payload.line + "\n"; + logPre.scrollTop = logPre.scrollHeight; + } + }); + + const unlistenExit = await listen("process:exit", (event) => { + if (event.payload.id === processId) { + unlistenStdout(); + unlistenStderr(); + unlistenExit(); + spinnerContainer.classList.add("hidden"); + btnCreateProfile.disabled = false; + + if (event.payload.code === 0) { + logPre.textContent += "\n[SUCCESS] colprof completed. Profile generated.\n"; + const iccFilename = `${basename}.icc`; + successInfo.textContent = `Profile: ${iccFilename} (${description})`; + successCard.classList.remove("hidden"); + + setStage4Result(basename, cwd); + } else { + logPre.textContent += `\n[ERROR] colprof exited with code ${event.payload.code}.\n`; + } + } + }); + + await invoke("run_colprof", { config }); + } catch (err) { + logPre.textContent += `\n[INVOKE ERROR] ${err}\n`; + spinnerContainer.classList.add("hidden"); + btnCreateProfile.disabled = false; + } + }); + + if (btnGoToVerify) { + btnGoToVerify.addEventListener("click", () => { + advanceToStage5(); + }); + } +} + +function advanceToStage5() { + const steps = document.querySelectorAll('.step'); + const stages = document.querySelectorAll('.stage'); + + steps.forEach(s => s.classList.remove('active')); + if (steps[4]) steps[4].classList.add('active'); + + stages.forEach(s => { + s.classList.remove('active'); + s.classList.add('hidden'); + }); + if (stages[4]) { + stages[4].classList.remove('hidden'); + stages[4].classList.add('active'); + } +} diff --git a/src/js/profcheck.js b/src/js/profcheck.js new file mode 100644 index 0000000..49677c6 --- /dev/null +++ b/src/js/profcheck.js @@ -0,0 +1,137 @@ +const { invoke } = window.__TAURI__.core; +const { listen } = window.__TAURI__.event; + +let profileBasename = ""; +let profileCwd = ""; + +/** + * Called by colprof.js after Stage 4 completes. + */ +export function setStage4Result(basename, cwd) { + profileBasename = basename; + profileCwd = cwd; +} + +export function initProfcheck() { + const btnVerify = document.getElementById("btnVerify"); + const logContainer = document.getElementById("profcheckLogContainer"); + const logPre = document.getElementById("profcheckLog"); + const reportCard = document.getElementById("profcheckReportCard"); + const avgDeEl = document.getElementById("profcheckAvgDe"); + const maxDeEl = document.getElementById("profcheckMaxDe"); + const rmsDeEl = document.getElementById("profcheckRmsDe"); + const badgeEl = document.getElementById("profcheckBadge"); + + if (!btnVerify) return; + + btnVerify.addEventListener("click", async () => { + const basename = profileBasename || "test_target"; + const cwd = profileCwd || ""; + + const sep = cwd.includes('\\') ? '\\' : '/'; + const ti3Path = cwd ? `${cwd}${sep}${basename}.ti3` : `${basename}.ti3`; + const iccPath = cwd ? `${cwd}${sep}${basename}.icc` : `${basename}.icc`; + + logPre.textContent = ""; + logContainer.classList.remove("hidden"); + reportCard.classList.add("hidden"); + btnVerify.disabled = true; + + const config = { + ti3_path: ti3Path, + icc_path: iccPath, + cwd: cwd, + }; + + const processId = `profcheck_${ti3Path}`; + let stdoutAccumulator = ""; + + try { + const unlistenStdout = await listen("process:stdout", (event) => { + if (event.payload.id === processId && event.payload.line) { + stdoutAccumulator += event.payload.line + "\n"; + logPre.textContent += event.payload.line + "\n"; + logPre.scrollTop = logPre.scrollHeight; + } + }); + + const unlistenStderr = await listen("process:stderr", (event) => { + if (event.payload.id === processId && event.payload.line) { + logPre.textContent += "ERR: " + event.payload.line + "\n"; + logPre.scrollTop = logPre.scrollHeight; + } + }); + + const unlistenExit = await listen("process:exit", (event) => { + if (event.payload.id === processId) { + unlistenStdout(); + unlistenStderr(); + unlistenExit(); + btnVerify.disabled = false; + + if (event.payload.code === 0) { + logPre.textContent += "\n[SUCCESS] profcheck verification finished.\n"; + parseAndRenderReport(stdoutAccumulator); + } else { + logPre.textContent += `\n[ERROR] profcheck exited with code ${event.payload.code}.\n`; + } + } + }); + + await invoke("run_profcheck", { config }); + } catch (err) { + logPre.textContent += `\n[INVOKE ERROR] ${err}\n`; + btnVerify.disabled = false; + } + }); + + function parseAndRenderReport(stdout) { + reportCard.classList.remove("hidden"); + + let avgDe = 0.0; + let maxDe = 0.0; + let rmsDe = 0.0; + + // Check if JSON output is present + const jsonMatch = stdout.match(/\{[\s\S]*"avg_de"[\s\S]*\}/); + if (jsonMatch) { + try { + const json = JSON.parse(jsonMatch[0]); + avgDe = json.avg_de || 0; + maxDe = json.max_de || json.peak_de || 0; + rmsDe = json.rms_de || 0; + } catch (e) { + console.error("JSON parse error:", e); + } + } else { + // Regex fallbacks for standard profcheck output + const avgMatch = stdout.match(/avg\.\s*dE\s*=\s*([\d\.]+)/i) || stdout.match(/average\s*dE\s*:\s*([\d\.]+)/i); + const maxMatch = stdout.match(/max\.\s*dE\s*=\s*([\d\.]+)/i) || stdout.match(/peak\s*dE\s*:\s*([\d\.]+)/i); + const rmsMatch = stdout.match(/rms\.\s*dE\s*=\s*([\d\.]+)/i) || stdout.match(/rms\s*dE\s*:\s*([\d\.]+)/i); + + if (avgMatch) avgDe = parseFloat(avgMatch[1]); + if (maxMatch) maxDe = parseFloat(maxMatch[1]); + if (rmsMatch) rmsDe = parseFloat(rmsMatch[1]); + } + + avgDeEl.textContent = avgDe.toFixed(2); + maxDeEl.textContent = maxDe.toFixed(2); + rmsDeEl.textContent = rmsDe.toFixed(2); + + // Quality verdict + badgeEl.className = "report-badge"; + if (avgDe < 1.0) { + badgeEl.textContent = "EXCELLENT"; + badgeEl.classList.add("badge-excellent"); + } else if (avgDe < 2.0) { + badgeEl.textContent = "GOOD"; + badgeEl.classList.add("badge-good"); + } else if (avgDe < 4.0) { + badgeEl.textContent = "ACCEPTABLE"; + badgeEl.classList.add("badge-acceptable"); + } else { + badgeEl.textContent = "POOR"; + badgeEl.classList.add("badge-poor"); + } + } +} diff --git a/src/styles/main.css b/src/styles/main.css index e70398e..686c4e5 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -499,3 +499,146 @@ button.danger:hover { overflow-y: auto; white-space: pre-wrap; } + +/* ======================================== + Stage 4: Spinner & Success Card + ======================================== */ +.spinner-container { + display: flex; + align-items: center; + gap: 16px; + background: var(--panel-color); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 16px; + margin-top: 16px; + margin-bottom: 16px; +} + +.spinner { + width: 24px; + height: 24px; + border: 3px solid rgba(255, 255, 255, 0.2); + border-top-color: var(--accent-color); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.stage-label { + font-size: 1rem; + color: #ddd; +} + +.success-card { + background: rgba(76, 175, 80, 0.1); + border: 1px solid #4caf50; + border-radius: 8px; + padding: 20px; + margin-top: 16px; + margin-bottom: 16px; +} + +.success-card h3 { + color: #4caf50; + margin-top: 0; + margin-bottom: 8px; +} + +.success-card p { + color: #ccc; + margin-bottom: 16px; +} + +/* ======================================== + Stage 5: Report Card + ======================================== */ +.report-card { + background: var(--panel-color); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 20px; + margin-top: 16px; + margin-bottom: 16px; +} + +.report-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.report-header h3 { + margin: 0; + font-size: 1.2rem; +} + +.report-badge { + padding: 4px 12px; + border-radius: 12px; + font-weight: bold; + font-size: 0.85rem; + letter-spacing: 0.5px; +} + +.badge-excellent { + background: rgba(76, 175, 80, 0.2); + color: #4caf50; + border: 1px solid #4caf50; +} + +.badge-good { + background: rgba(33, 150, 243, 0.2); + color: #2196f3; + border: 1px solid #2196f3; +} + +.badge-acceptable { + background: rgba(255, 152, 0, 0.2); + color: #ff9800; + border: 1px solid #ff9800; +} + +.badge-poor { + background: rgba(244, 67, 54, 0.2); + color: #f44336; + border: 1px solid #f44336; +} + +.report-metrics { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 16px; + margin-bottom: 16px; +} + +.metric-box { + background: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 16px; + text-align: center; +} + +.metric-value { + display: block; + font-size: 1.8rem; + font-weight: bold; + color: #fff; +} + +.metric-label { + font-size: 0.8rem; + color: #888; +} + +.report-footnote { + font-size: 0.85rem; + color: #888; + border-top: 1px dashed var(--border-color); + padding-top: 12px; +} -- 2.39.5 From 5f71949cc5aa0e8700177b86ee892eb53c6744ea Mon Sep 17 00:00:00 2001 From: gronod Date: Sat, 22 Aug 2026 18:21:27 +0100 Subject: [PATCH 4/5] feat: Stage 5 Final - 3D Gamut Visualization, Settings & Autodetection --- LICENSE-delaunator | 15 +++ src-tauri/argyll/reference_gamuts/sRGB.gam | 16 +++ src-tauri/src/commands.rs | 47 +++++++-- src-tauri/src/lib.rs | 6 +- src-tauri/src/settings.rs | 27 +++++ src/index.html | 29 ++++++ src/js/app.js | 8 ++ src/js/delaunator.min.js | 1 + src/js/gamut_viewer.js | 111 +++++++++++++++++++++ src/js/settings.js | 40 ++++++++ src/styles/main.css | 62 ++++++++++++ 11 files changed, 355 insertions(+), 7 deletions(-) create mode 100644 LICENSE-delaunator create mode 100644 src-tauri/argyll/reference_gamuts/sRGB.gam create mode 100644 src-tauri/src/settings.rs create mode 100644 src/js/delaunator.min.js create mode 100644 src/js/gamut_viewer.js create mode 100644 src/js/settings.js diff --git a/LICENSE-delaunator b/LICENSE-delaunator new file mode 100644 index 0000000..d552272 --- /dev/null +++ b/LICENSE-delaunator @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2017, Mapbox + +Permission to use, copy, modify, and/or distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright notice +and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/src-tauri/argyll/reference_gamuts/sRGB.gam b/src-tauri/argyll/reference_gamuts/sRGB.gam new file mode 100644 index 0000000..9826a02 --- /dev/null +++ b/src-tauri/argyll/reference_gamuts/sRGB.gam @@ -0,0 +1,16 @@ +CGATS.17 +NUMBER_OF_FIELDS 4 +BEGIN_DATA_FORMAT +INDEX LAB_L LAB_A LAB_B +END_DATA_FORMAT +NUMBER_OF_SETS 8 +BEGIN_DATA +0 0.0 0.0 0.0 +1 100.0 0.0 0.0 +2 53.2 80.1 67.2 +3 87.7 -86.2 83.2 +4 97.1 -21.6 94.5 +5 32.3 79.2 -107.9 +6 60.3 98.2 -60.8 +7 91.1 -48.1 -14.1 +END_DATA diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 8a22ec2..1cd540d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -32,27 +32,62 @@ pub async fn kill_process( #[tauri::command] pub async fn resolve_binary(app: AppHandle, binary_name: String) -> Result { - // Resolve sidecar resource path + let settings = crate::settings::load_settings(app.clone()).unwrap_or_default(); + if let Some(dir) = settings.argyll_binary_dir { + if !dir.trim().is_empty() { + let custom_path = std::path::Path::new(&dir).join(&binary_name); + if custom_path.exists() { + return Ok(custom_path.to_string_lossy().to_string()); + } + } + } + + let platform = match (std::env::consts::OS, std::env::consts::ARCH) { + ("linux", "x86_64") => "linux-x86_64", + ("windows", "x86_64") => "windows-x86_64", + ("macos", "aarch64") => "macos-aarch64", + _ => "linux-x86_64", + }; + let resource_path = app .path() .resolve( - format!("argyll/linux-x86_64/{}", binary_name), + format!("argyll/{}/{}", platform, binary_name), tauri::path::BaseDirectory::Resource, ) .map_err(|e| e.to_string())?; - // In a real app we'd switch based on target_os, but for Milestone 1 scaffolding we'll mock the linux path. Ok(resource_path.to_string_lossy().to_string()) } #[tauri::command] -pub async fn list_instruments(app: AppHandle, state: State<'_, ProcessManager>) -> Result<(), String> { - // We would use spawn_process directly with instlist binary and parse JSON output. - // For scaffolding, this is a placeholder. +pub async fn detect_instruments(app: AppHandle, state: State<'_, ProcessManager>) -> Result<(), String> { let binary = resolve_binary(app.clone(), "instlist".to_string()).await?; state.spawn(app, "instlist".to_string(), binary, vec![], None).await } +#[tauri::command] +pub async fn extract_gamut( + app: AppHandle, + state: State<'_, ProcessManager>, + icc_path: String, +) -> Result<(), String> { + let binary = resolve_binary(app.clone(), "iccgamut".to_string()).await?; + let path = std::path::Path::new(&icc_path); + let basename = path.file_stem().unwrap().to_str().unwrap(); + let parent_dir = path.parent().map(|p| p.to_str().unwrap()).unwrap_or(""); + + let mut args = vec!["-w".to_string()]; + if !parent_dir.is_empty() { + args.push("-d".to_string()); + args.push(parent_dir.to_string()); + } + args.push(icc_path.clone()); + + let id = format!("iccgamut_{}", basename); + state.spawn(app, id, binary, args, None).await +} + #[derive(Debug, Deserialize, Serialize)] pub struct TargenConfig { pub colour_space: String, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c0eb57c..15b596c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod commands; mod events; mod process_manager; +mod settings; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -13,13 +14,16 @@ pub fn run() { commands::send_stdin, commands::kill_process, commands::resolve_binary, - commands::list_instruments, + commands::detect_instruments, + commands::extract_gamut, commands::run_targen, commands::run_printtarg, commands::read_file_base64, commands::run_chartread, commands::run_colprof, commands::run_profcheck, + settings::load_settings, + settings::save_settings, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs new file mode 100644 index 0000000..974d911 --- /dev/null +++ b/src-tauri/src/settings.rs @@ -0,0 +1,27 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use tauri::{AppHandle, Manager}; + +#[derive(Debug, Deserialize, Serialize, Default, Clone)] +pub struct AppSettings { + pub argyll_binary_dir: Option, + pub default_instrument: Option, +} + +#[tauri::command] +pub fn load_settings(app: AppHandle) -> Result { + let path = app.path().app_data_dir().unwrap().join("settings.json"); + if let Ok(data) = fs::read_to_string(path) { + Ok(serde_json::from_str(&data).unwrap_or_default()) + } else { + Ok(AppSettings::default()) + } +} + +#[tauri::command] +pub fn save_settings(app: AppHandle, settings: AppSettings) -> Result<(), String> { + let path = app.path().app_data_dir().unwrap(); + fs::create_dir_all(&path).map_err(|e| e.to_string())?; + let json = serde_json::to_string_pretty(&settings).unwrap(); + fs::write(path.join("settings.json"), json).map_err(|e| e.to_string()) +} diff --git a/src/index.html b/src/index.html index 065f8c3..0d394f9 100644 --- a/src/index.html +++ b/src/index.html @@ -5,6 +5,9 @@ ICCery + + + @@ -12,6 +15,7 @@