From 3a56a020453583124f0f453d144310eeff29c0de Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 19:09:18 +0000 Subject: [PATCH] WIP: Copy as command in QuPath (menu button: terminal line, Python snippet); GUI test body not yet run Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Y2SN8SuEakTdiWy3F2WUqN --- .../qupath/LabConstrictorTools.groovy | 75 ++++++++++++++++++- tests/gui_test_copy_body.groovy | 69 +++++++++++++++++ 2 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 tests/gui_test_copy_body.groovy diff --git a/src/main/resources/org/cellmigrationlab/labconstrictor/qupath/LabConstrictorTools.groovy b/src/main/resources/org/cellmigrationlab/labconstrictor/qupath/LabConstrictorTools.groovy index 1baad91..56e48d8 100644 --- a/src/main/resources/org/cellmigrationlab/labconstrictor/qupath/LabConstrictorTools.groovy +++ b/src/main/resources/org/cellmigrationlab/labconstrictor/qupath/LabConstrictorTools.groovy @@ -237,6 +237,7 @@ class LcDialog { Map resultWindows = [:] // app/tool -> its last results window (Replace reuses it) Map imageChoiceAtRun = [:] // image parameter -> what was chosen when the run started Map wrappers = [:] // the node placed in the form for each parameter (enabled_when disables this one) + String lastCopied // the text the last Copy as command put on the clipboard (for tests) Map> imageBoxes = [:] Map imageOf = [:] // pixel-size parameter -> image parameter LcWorker worker @@ -278,7 +279,13 @@ class LcDialog { rescan.onAction = { rescan() } def restart = new Button("Restart worker") restart.onAction = { stopWorker(true); status.text = "worker stopped" } - def buttons = new HBox(6, cancelButton, rescan, restart, detailsButton) + def copyMenu = new MenuButton("Copy as command") + copyMenu.tooltip = new Tooltip("Copy what repeats this run outside QuPath: a terminal line or a Python snippet") + def terminalItem = new MenuItem("Terminal command"), pythonItem = new MenuItem("Python snippet") + terminalItem.onAction = { copyAsCommand("terminal") } + pythonItem.onAction = { copyAsCommand("python") } + copyMenu.items.addAll(terminalItem, pythonItem) + def buttons = new HBox(6, cancelButton, rescan, restart, detailsButton, copyMenu) def scroll = new ScrollPane(formBox) scroll.fitToWidth = true scroll.prefViewportHeight = 420 @@ -729,6 +736,72 @@ class LcDialog { } catch (Exception ignored) { /* leave what is there */ } } + // ---- Copy as command (same text as labconstrictor_tools.command) + static final Map FILE_PLACEHOLDERS = [image: "image.tif", labels: "labels.tif", table: "table.csv", file: "file", folder: "folder"] + + static String shellQuote(String text, boolean windows) { + if (windows) return (text && text ==~ /[A-Za-z0-9_.:\/\\=+,-]+/) ? text : '"' + text.replace('"', '\\"') + '"' + return (text && text ==~ /[A-Za-z0-9_@%+=:,.\/-]+/) ? text : "'" + text.replace("'", "'\"'\"'") + "'" + } + + /** The values a command line needs, in the tool's order: the file behind each image, else a placeholder; unset parameters are left out. */ + Map commandValues() { + def values = [:], missing = [], notes = [] + for (p in currentTool.inputs) { + def v = null + try { v = getters[p.name]() } catch (Exception ignored) { } + if (p.type in ["image", "labels"]) { + def path = null + if (v instanceof Map) { + if (v.file) path = v.file as String + else if (v.source != null) { + try { + def uri = v.source.call().getURIs()?.find { it.scheme == "file" } + if (uri != null) path = new File(uri).path + } catch (Exception ignored) { } + } + if (p.pick_channel && v.channel != null && v.channel >= 0) notes << ("# " + p.name + ": QuPath sent only channel " + (v.channel + 1) + "; the command sends the whole file") + } + v = path + } + if (v != null) values[p.name] = v + if (p.type in FILE_PLACEHOLDERS.keySet() && p.required && values[p.name] == null) { values[p.name] = FILE_PLACEHOLDERS[p.type]; missing << p.name } + } + return [values: values, missing: missing, notes: notes] + } + + String commandText(String kind) { + def built = commandValues() + def app = apps[appBox.value] + def head = (built.missing ? ["# replace the file for: " + built.missing.join(", ")] : []) + built.notes + def note = head ? head.join("\n") + "\n" : "" + def given = currentTool.inputs.findAll { built.values.containsKey(it.name) } + if (kind == "python") { + def literal = { v -> v instanceof Boolean ? (v ? "True" : "False") : v instanceof Number ? v.toString() : "'" + v.toString().replace("\\", "\\\\").replace("'", "\\'") + "'" } + def body = given ? "{\n" + given.collect { " '" + it.name + "': " + literal(built.values[it.name]) + "," }.join("\n") + "\n}" : "{}" + return note + "from labconstrictor_tools import client\n\ntask = client.run_once('" + appBox.value + "', '" + currentTool.id + "', " + body + ")\n" + + 'print(task.status, task.outputs if task.status == "COMPLETE" else task.error)' + } + boolean windows = System.getProperty("os.name").toLowerCase().contains("win") + def parts = [app.entry.python as String, "-m", "labconstrictor_tools", "run", appBox.value as String, currentTool.id as String].collect { shellQuote(it, windows) } + given.each { p -> + def v = built.values[p.name] + parts << shellQuote(p.name + "=" + (v instanceof Boolean ? (v ? "true" : "false") : v.toString()), windows) + } + return note + parts.join(" ") + } + + String copyAsCommand(String kind) { + if (currentTool == null) return null + def text = commandText(kind) + def content = new javafx.scene.input.ClipboardContent() + content.putString(text) + javafx.scene.input.Clipboard.systemClipboard.setContent(content) + lastCopied = text + status.text = "copied the " + (kind == "python" ? "Python snippet" : "terminal command") + " to the clipboard" + return text + } + // ---- running void run() { if (running || currentTool == null) return diff --git a/tests/gui_test_copy_body.groovy b/tests/gui_test_copy_body.groovy new file mode 100644 index 0000000..7b001f8 --- /dev/null +++ b/tests/gui_test_copy_body.groovy @@ -0,0 +1,69 @@ + +// ================================================================== GUI test body (appended to the tool script by tests/run_gui_test.sh) +import javafx.embed.swing.SwingFXUtils +import javax.imageio.ImageIO +import java.util.concurrent.CountDownLatch +import java.awt.image.BufferedImage +import javafx.stage.Window + +def OUT = new File(System.getProperty("lc.test.out", "/tmp/qp_shots")); OUT.mkdirs() +def FX = "/home/tester/q/fx" +def results = [:] +def fx = { Closure c -> + if (Platform.isFxApplicationThread()) return c() + def latch = new CountDownLatch(1); def box = [null, null] + Platform.runLater { try { box[0] = c() } catch (Throwable t) { box[1] = t } finally { latch.countDown() } } + latch.await() + if (box[1] != null) throw box[1] + return box[0] +} +def expect = { String name, boolean ok, Object detail = "" -> + results[name] = ok + println((ok ? "PASS " : "FAIL ") + name + (ok ? "" : " -> " + detail)) +} +def shot = { String name, Stage st -> + Thread.sleep(600) + fx { + def img = st.scene.snapshot(null) + ImageIO.write(SwingFXUtils.fromFXImage(img, null), "png", new File(OUT, name + ".png")) + } + println "SHOT " + name +} +def waitIdle = { dlg, int seconds = 300 -> + def end = System.currentTimeMillis() + seconds * 1000 + while (System.currentTimeMillis() < end) { Thread.sleep(200); if (!dlg.running && fx { !dlg.runButton.disable }) return true } + return false +} +def status = { dlg -> fx { dlg.status.text } } +def pick = { dlg, app, tool -> fx { dlg.appBox.value = app }; fx { dlg.toolBox.value = tool } } +def resultStages = { Window.getWindows().findAll { it instanceof Stage && it.showing && it.title?.contains(": ") && it.title != "LabConstrictor: last run" } } + + +Thread.start("lc-gui-test") { + try { + fx { + def dir = new File("/tmp/qp_project_copy"); dir.deleteDir() + qupath.lib.gui.prefs.PathPrefs.imageTypeSettingProperty().set(qupath.lib.gui.prefs.PathPrefs.ImageTypeSetting.AUTO_ESTIMATE) + def project = qupath.lib.projects.Projects.createProject(dir, BufferedImage) + def server = qupath.lib.images.servers.ImageServers.buildServer("/home/user/LabConstrictor-Fiji/tests/fixtures/sample.tif") + def entry = project.addImage(server.getBuilder()); entry.setImageName("sample.tif") + project.syncChanges(); qupath.setProject(project); qupath.openImageEntry(entry); entry + } + Thread.sleep(2500) + def dlg = fx { lcShow() } + Thread.sleep(800) + pick(dlg, "interactions", "Find bright spots"); Thread.sleep(1500) + fx { dlg.setters["threshold"](0.5d) } + def radios = fx { dlg.wrappers["look_for"].children.findAll { it instanceof RadioButton } } + fx { radios.find { it.text == "dark" }.fire() } + def line = fx { dlg.copyAsCommand("terminal") } + expect("terminal_line_names_app_tool_and_values", line != null && line.contains("run interactions find_bright_spots") && line.contains("threshold=0.5") && line.contains("look_for=dark"), line) + expect("image_file_is_used", line != null && line.contains("image=/home/user/LabConstrictor-Fiji/tests/fixtures/sample.tif"), line) + expect("clipboard_holds_the_line", fx { javafx.scene.input.Clipboard.systemClipboard.getString() } == line, line) + def snippet = fx { dlg.copyAsCommand("python") } + expect("python_snippet_has_real_values", snippet != null && snippet.contains("client.run_once('interactions', 'find_bright_spots'") && snippet.contains("'look_for': 'dark'") && snippet.contains("'threshold': 0.5"), snippet) + new File("/tmp/gtc/qp_copied_line.txt").text = line + shot("qp_copy_1_form", dlg.stage) + } catch (Throwable t) { t.printStackTrace() } + finally { println "RESULTS " + results; Thread.sleep(500); System.exit(0) } +}